feat(styles): field-level runtime scope cascade merge #544
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |