Skip to content

feat(styles): field-level runtime scope cascade merge #544

feat(styles): field-level runtime scope cascade merge

feat(styles): field-level runtime scope cascade merge #544

Workflow file for this run

name: Release
on:
pull_request:
types: [closed]
branches: [main]
push:
tags: ["v*"]
workflow_dispatch:
inputs:
command:
description: "Action to perform"
required: true
default: release-pr
type: choice
options:
- release-pr
- publish-crates
- publish-jsr
release_ref:
description: "Release tag to recover (required for publish-crates, e.g. v0.75.0)"
required: false
type: string
permissions:
contents: read
jobs:
# ── Step 1: Detect what changed and infer bump level ──────────────
detect:
if: |
(github.event_name == 'workflow_dispatch' && inputs.command == 'release-pr') ||
(github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'release/next' &&
!startsWith(github.event.pull_request.title, 'chore(release)'))
runs-on: ubuntu-latest
outputs:
code-changed: ${{ steps.infer.outputs.code-changed }}
schema-changed: ${{ steps.infer.outputs.schema-changed }}
bump-level: ${{ steps.infer.outputs.level }}
should-release: ${{ steps.infer.outputs.should-release }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Infer bump level from commits
id: infer
run: |
LAST_TAG=$(git tag --sort=-version:refname -l 'v*' | head -1)
if [ -z "$LAST_TAG" ]; then
ROOT=$(git rev-list --max-parents=0 HEAD | tail -1)
if [ "$ROOT" = "$(git rev-parse HEAD)" ]; then
RANGE="HEAD"
else
RANGE="${ROOT}..HEAD"
fi
else
RANGE="${LAST_TAG}..HEAD"
fi
CURRENT=$(python3 - <<'PY'
import re, pathlib
text = pathlib.Path("Cargo.toml").read_text()
# Match [workspace.package] section and extract version within it
pattern = r'^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"'
match = re.search(pattern, text, re.DOTALL | re.MULTILINE)
if not match:
raise SystemExit("workspace package version not found in Cargo.toml")
print(match.group(1))
PY
)
python3 scripts/infer-release-bump.py \
--range "$RANGE" \
--current-version "$CURRENT" \
--github-output "$GITHUB_OUTPUT"
# ── Step 2: Create/update the release PR ──────────────────────────
release-pr:
needs: detect
if: needs.detect.outputs.should-release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
# persist-credentials: true (default) — needed for git push to release/next
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Install tools
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2
with:
tool: cargo-release,cargo-semver-checks,git-cliff
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Semver safety check
if: needs.detect.outputs.code-changed == 'true'
continue-on-error: true
id: semver
run: |
if cargo semver-checks --workspace 2>&1; then
echo "breaking=false" >> "$GITHUB_OUTPUT"
else
echo "breaking=true" >> "$GITHUB_OUTPUT"
fi
- name: Resolve final bump level
id: resolve
run: |
LEVEL="${{ needs.detect.outputs.bump-level }}"
# If semver-checks found breaking changes, escalate
if [ "${{ steps.semver.outputs.breaking }}" = "true" ]; then
CURRENT=$(grep -oP 'version = "\K[^"]+' Cargo.toml | head -1)
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
if [ "$MAJOR" = "0" ]; then LEVEL="minor"; else LEVEL="major"; fi
fi
echo "level=$LEVEL" >> "$GITHUB_OUTPUT"
- name: Configure branching and tagging
id: config
run: |
# All releases flow through a release PR on release/next.
{
echo "branch=release/next"
echo "create_pr=true"
echo "push_tags=false"
} >> "$GITHUB_OUTPUT"
- name: Create release branch
if: steps.config.outputs.create_pr == 'true'
run: git checkout -B "${{ steps.config.outputs.branch }}"
- name: Generate changelog
run: |
LAST_TAG=$(git tag --sort=-version:refname -l 'v*' | head -1)
CURRENT=$(python3 - <<'PY'
import re, pathlib
text = pathlib.Path("Cargo.toml").read_text()
m = re.search(r'^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', text, re.DOTALL|re.MULTILINE)
if not m:
raise SystemExit("workspace package version not found")
print(m.group(1))
PY
)
LEVEL="${{ steps.resolve.outputs.level }}"
NEW_VERSION=$(python3 -c "
v = '$CURRENT'.split('.')
if '$LEVEL' == 'major':
v[0] = str(int(v[0]) + 1); v[1] = '0'; v[2] = '0'
elif '$LEVEL' == 'minor':
v[1] = str(int(v[1]) + 1); v[2] = '0'
else:
v[2] = str(int(v[2]) + 1)
print('.'.join(v))
")
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
git cliff "$RANGE" --tag "v${NEW_VERSION}" --prepend CHANGELOG.md
git add CHANGELOG.md
if ! git diff --cached --quiet; then
git commit \
-m "docs: update changelog for v${NEW_VERSION}" \
-m "Auto-generated by git-cliff."
fi
- name: Bump schema version (if schema changed)
if: needs.detect.outputs.schema-changed == 'true'
run: |
python3 scripts/bump.py schema "${{ steps.resolve.outputs.level }}" \
--yes --no-commit --no-tag --no-validate
cargo run --bin citum --features schema -- schema --out-dir docs/schemas
git add crates/citum-schema-style/src/version.rs \
docs/reference/SCHEMA_VERSIONING.md \
docs/schemas
if ! git diff --cached --quiet; then
git commit \
-m "chore(schema): bump schema version" \
-m "Update published schema version and generated schemas for release."
fi
- name: Bump workspace version
run: |
BRANCH="${{ steps.config.outputs.branch }}"
TAG_FLAG=""
if [ "${{ steps.config.outputs.push_tags }}" = "false" ]; then
TAG_FLAG="--no-tag"
fi
cargo release --workspace --no-publish --no-push $TAG_FLAG --no-confirm --execute \
"${{ steps.resolve.outputs.level }}"
git push -f origin "$BRANCH"
if [ "${{ steps.config.outputs.push_tags }}" = "true" ]; then
git push origin --tags
fi
- name: Tag schema release when schema changed
if: steps.config.outputs.push_tags == 'true' && needs.detect.outputs.schema-changed == 'true'
run: |
SCHEMA_VERSION=$(sed -n \
's/.*STYLE_SCHEMA_VERSION: \&str = "\([^"]*\)".*/\1/p' \
crates/citum-schema-style/src/version.rs)
if [ -z "$SCHEMA_VERSION" ]; then
echo "Error: STYLE_SCHEMA_VERSION not found" >&2
exit 1
fi
TAG="schema-v${SCHEMA_VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Schema tag ${TAG} already exists."
else
git tag -a "$TAG" -m "Schema ${TAG}"
git push origin "$TAG"
fi
- name: Create or update release PR
if: steps.config.outputs.create_pr == 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
NEW_VERSION=$(sed -n '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p;}' Cargo.toml | head -1)
BODY="Automated release PR for **v${NEW_VERSION}**.
| Detail | Value |
|--------|-------|
| Bump level | \`${{ steps.resolve.outputs.level }}\` |
| Schema changed | ${{ needs.detect.outputs.schema-changed }} |
| Code changed | ${{ needs.detect.outputs.code-changed }} |"
EXISTING=$(gh pr list --head "release/next" --json number --jq '.[0].number' || true)
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --title "chore: release v${NEW_VERSION}" --body "$BODY"
else
# Ensure the 'release' label exists; --force handles existing labels gracefully by updating them
gh label create "release" --color "0E8A16" --description "Automated release pull requests" --force
gh pr create --head "release/next" --base main \
--title "chore: release v${NEW_VERSION}" --body "$BODY" \
--label "release"
fi
# ── Step 3: Auto-tag when release PR merges ───────────────────────
auto-tag:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'release/next'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
# persist-credentials: true (default) — needed for git push tags
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Install cargo-release
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2
with:
tool: cargo-release
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Tag workspace release
run: |
VERSION=$(sed -n '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p;}' Cargo.toml | head -1)
TAG="v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag ${TAG} already exists."
else
git tag -a "$TAG" -m "Release ${TAG}"
git push origin "$TAG"
fi
- name: Tag schema release when schema changed
run: |
if git diff --name-only "${{ github.event.pull_request.base.sha }}" HEAD \
| grep -qE '^(crates/citum-schema-style/src/version.rs|docs/schemas/)'; then
SCHEMA_VERSION=$(sed -n \
's/.*STYLE_SCHEMA_VERSION: \&str = "\([^"]*\)".*/\1/p' \
crates/citum-schema-style/src/version.rs)
if [ -z "$SCHEMA_VERSION" ]; then
echo "Error: STYLE_SCHEMA_VERSION not found" >&2
exit 1
fi
TAG="schema-v${SCHEMA_VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Schema tag ${TAG} already exists."
else
git tag -a "$TAG" -m "Schema ${TAG}"
git push origin "$TAG"
fi
fi
# ── Step 4: Build cross-platform binaries for a tag or recovery ───
# Tag pushes and manual crates.io recovery both run the full build matrix
# before publishing. Each matrix job builds citum + citum-server for its
# target and uploads the tarball + SHA256 as job artifacts. The `release`
# job aggregates them only for tag-push releases.
build:
if: |
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && inputs.command == 'publish-crates')
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
use_cross: "0"
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
use_cross: "1"
# glibc Linux targets: rusty_v8 (citum-migrate's V8 dependency)
# only publishes prebuilt static libs for gnu/glibc, not musl.
# This target carries the x86_64 Linux citum-migrate prebuilt while
# citum/citum-server keep shipping from the musl target above.
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
use_cross: "0"
- target: aarch64-apple-darwin
os: macos-latest
use_cross: "0"
- target: x86_64-pc-windows-msvc
os: windows-latest
use_cross: "0"
runs-on: ${{ matrix.os }}
name: build (${{ matrix.target }})
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
ref: ${{ inputs.release_ref || github.ref }}
- name: Validate recovery tag
if: github.event_name == 'workflow_dispatch' && inputs.command == 'publish-crates'
shell: bash
env:
RELEASE_REF: ${{ inputs.release_ref }}
run: |
case "$RELEASE_REF" in
v[0-9]*) ;;
*) echo "publish-crates requires release_ref to be a v* tag" >&2; exit 1 ;;
esac
if ! git rev-parse -q --verify "refs/tags/${RELEASE_REF}" >/dev/null; then
echo "release_ref ${RELEASE_REF} is not a tag in this checkout" >&2
exit 1
fi
if [ "$(git describe --exact-match --tags HEAD)" != "$RELEASE_REF" ]; then
echo "checkout does not resolve to ${RELEASE_REF}" >&2
exit 1
fi
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: release-${{ matrix.target }}
- name: Install musl-tools (linux only)
if: contains(matrix.target, 'linux-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Install cross (when needed)
if: matrix.use_cross == '1'
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2
with:
tool: cross
- name: Build, package, checksum
shell: bash
env:
USE_CROSS: ${{ matrix.use_cross }}
TARGET: ${{ matrix.target }}
REF_NAME: ${{ inputs.release_ref || github.ref_name }}
run: bash scripts/release-binary.sh "$TARGET" "$REF_NAME"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: citum-${{ matrix.target }}
path: release-out/${{ matrix.target }}/*
# ── Step 5: Create the GitHub Release with all artifacts ──────────
release:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
path: release-artifacts
pattern: citum-*
merge-multiple: true
- name: Aggregate SHA256SUMS
run: |
set -euo pipefail
cd release-artifacts
# Each per-target script emitted `<hash> <tarball>` in a
# per-arch .sha256 file; concat into one canonical manifest.
cat ./*.sha256 > SHA256SUMS
rm ./*.sha256
echo "=== SHA256SUMS ==="
cat SHA256SUMS
- name: Copy install.sh into the release payload
run: cp scripts/install.sh release-artifacts/install.sh
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
awk "/^## \[${VERSION}\]/{found=1; next} found && /^## \[/{exit} found{print}" \
CHANGELOG.md > /tmp/release-notes.md
gh release create "$TAG" \
--title "$TAG" \
--notes-file /tmp/release-notes.md \
--verify-tag \
release-artifacts/*.tar.gz \
release-artifacts/SHA256SUMS \
release-artifacts/install.sh
# ── Step 6: Publish to crates.io ──────────────────────────────────
# `needs: build` so cross-platform compilation is the strongest
# pre-publish signal: `cargo publish` is irreversible apart from
# `yank`, so we treat "won't compile on Windows" as a blocker.
# The script is idempotent — re-running after partial failure
# skips already-published versions.
publish-crates:
if: |
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && inputs.command == 'publish-crates')
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
ref: ${{ inputs.release_ref || github.ref }}
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: publish-crates
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: bash scripts/publish-crates.sh
# ── Step 7: Publish WASM/TypeScript bindings to JSR ───────────────
# JSR uses GitHub OIDC trusted publishing, so this job needs
# id-token: write but no registry token secret. The package must be
# linked to citum/citum-core in the JSR package settings first.
# This job intentionally does not wait for the binary build matrix:
# it builds its own package, and metadata/OIDC failures should surface
# quickly. Use workflow_dispatch command=publish-jsr to recover a JSR
# publish after a tag workflow failure; the selected ref supplies the
# package version from Cargo.toml.
publish-jsr:
if: |
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && inputs.command == 'publish-jsr')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-unknown-unknown
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "22"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: publish-jsr
- name: Install wasm-pack
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2
with:
tool: wasm-pack
- name: Build JSR package
run: ./scripts/build-jsr-package.sh
- name: Dry-run JSR publish
working-directory: target/jsr/citum
run: npx --yes jsr publish --dry-run
- name: Publish to JSR
working-directory: target/jsr/citum
run: npx --yes jsr publish