name: Release on: workflow_run: workflows: [CI] types: [completed] branches: [master] workflow_dispatch: inputs: tag: description: "Existing tag to (re-)publish against, e.g. v0.7.0. Empty runs the normal auto-tag flow on the latest master CI." required: false default: "" skip_determinism: description: "Reuse a prior run's byte-proven dist instead of rebuilding it. Re-publishes the identical artifacts, so winget/homebrew hashes stay valid. Only works while the prior run's dist-* artifacts survive (90-day retention)." type: boolean required: false default: false dist_run_id: description: "Release run id whose preserved dist-* artifacts to reuse when skip_determinism is set. Empty auto-resolves to the latest Release run for the target commit. Artifacts expire after 90 days; tags older than that cannot be re-published from preserved dist." required: false default: "" skip_publishers: description: "Comma-separated publishers to skip for THIS run only (e.g. chocolatey to ship everything else while one publisher is blocked). Appended to the always-skipped npm submitter, which the dedicated publish-npm job owns. Empty on the normal CI-driven flow." required: false default: "" anodizer_version: description: "anodizer build that runs the publish, e.g. 0.8.0 to re-publish an older tag with a newer anodizer. Empty uses the binary built for the target commit." required: false default: "" permissions: contents: write actions: read concurrency: group: release-${{ github.repository }} cancel-in-progress: false env: CARGO_TERM_COLOR: always jobs: # Central pre-tag secret gate. Built FROM THE CHECKOUT (not the published # anodizer-action@v1 binary) because --preflight-secrets does not exist in # the published action until this release ships — chicken-and-egg. Validates # that every runner-agnostic publish secret / credential the downstream # `release` + `publish-npm` jobs need is present (and key material is # well-formed) WITHOUT checking host-local tools, so a missing CI secret # aborts before a tag is ever minted. Carries the full secret env block the # `release` job uses so the check is meaningful. preflight: name: Preflight secrets if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 25 # id-token: write so the OIDC request vars (ACTIONS_ID_TOKEN_REQUEST_URL / # _TOKEN) the npm/mcp provenance surfaces require are injected and the # gate sees them present — mirroring the publish-npm job's permission. permissions: contents: read id-token: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ inputs.tag || github.event.workflow_run.head_sha || github.sha }} - uses: ./.github/actions/setup-rust with: # Warms the cargo cache so the action's `from-source` build of HEAD is # fast on re-runs. anodizer is the one consumer that builds ITSELF from # source to gate its own release; a downstream consumer would pin # `version:` to a published binary and need no Rust toolchain here. cache-key: preflight-secrets - name: Validate publish secrets # Dogfood the published action as the preflight gate: it builds anodizer # from this checkout (from-source), installs cosign for the offline # key-load check, and materializes the GPG/APK key files from the same # secrets — exporting GPG_KEY_PATH / APK_PRIVATE_KEY_PATH under the exact # names the rpm/deb/apk signers resolve. This validates the ACTUAL key # material the publish runner will load, through the SAME action, instead # of a hand-rolled reproduction that could silently drift from it. # # --skip=blob: the blob stage's S3/MinIO creds (AWS_ACCESS_KEY_ID / # AWS_SECRET_ACCESS_KEY) are AMBIENT on the self-hosted arc-anodizer # runner, not GitHub repo secrets — so this github-hosted gate cannot # see them and must not demand them (it would false-fail and block # every release). Blob's creds are validated in-pipeline on arc, where # they live. Every OTHER publish secret IS a registered repo secret and # is validated here, before the tag is minted. uses: tj-smith47/anodizer-action@v1 with: from-source: true # Explicit (not auto-install): --preflight-secrets is a secrets/ # key-load check, not the publish pipeline, so its tool set is a # deliberate narrow pick (cosign for the offline key-load) rather than # the config-derived set the sibling publish jobs auto-install. install: cosign gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} apk-private-key: ${{ secrets.APK_PRIVATE_KEY }} args: release --preflight-secrets --skip=blob env: AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} CLOUDSMITH_TOKEN: ${{ secrets.CLOUDSMITH_TOKEN }} COSIGN_KEY: ${{ secrets.COSIGN_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} FURY_API_TOKEN: ${{ secrets.FURY_API_TOKEN }} FURY_PUSH_TOKEN: ${{ secrets.FURY_PUSH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PAT }} GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} UPLOAD_JARVISPRO_SECRET: ${{ secrets.UPLOAD_JARVISPRO_SECRET }} UPLOAD_JARVISPRO_USERNAME: ${{ secrets.UPLOAD_JARVISPRO_USERNAME }} # Pre-tag in-cluster reachability gate. The github-hosted `preflight` job # above cannot route to the in-cluster endpoints — the blob store # (minio.jarvispro.svc.cluster.local) and the uploads mirror # (api-server.jarvispro.svc.cluster.local) — and so `--skip=blob`s the former # while the latter's reachability goes unchecked, leaving BOTH unverified # until publish time, on the one irreversible leg of the run. This job closes # that gap on arc-anodizer, the ONLY runner that can reach the cluster, so an # unreachable MinIO OR upload host fails BEFORE a tag is cut (fully reversible # — nothing has shipped). # # It runs the standalone `preflight` command, NOT `release # --preflight-secrets`: the secrets-only scope retains only credential # requirements and DROPS endpoint reachability, so it can never probe an # endpoint. The publish-only scope here keeps the `Endpoint` requirements the # blob stage and the uploads publisher emit, driving a real HTTP round-trip # per endpoint (any response — incl 403/404 — is reachable; a transport # failure is not). `--publishers blob,uploads` selects only those two # endpoint-bearing surfaces; `--skip sign,verify-release` drops the two other # publish-time stages that would otherwise demand cosign/GPG (blob consumes # detached signatures) and docker (install_smoke), neither of which bears on # endpoint reachability. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / # MINIO_ENDPOINT are ambient on arc-anodizer, so no secret env block is # needed — the same reason the github-hosted gate cannot validate them. blob-preflight: name: Preflight in-cluster endpoints if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} runs-on: arc-anodizer timeout-minutes: 10 permissions: contents: read actions: read # cross-run download of the CI-built anodizer-linux artifact steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ inputs.tag || github.event.workflow_run.head_sha || github.sha }} - name: Probe in-cluster blob + upload endpoint reachability uses: tj-smith47/anodizer-action@v1 with: from-artifact: anodizer-linux artifact-workflow: ci.yml artifact-run-id: auto args: preflight --publish-only --publishers blob,uploads --skip sign,verify-release tag: name: Auto-tag needs: [preflight, blob-preflight] if: ${{ needs.preflight.result == 'success' && needs.blob-preflight.result == 'success' && (github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch') }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write actions: read outputs: sha: ${{ steps.resolve.outputs.sha }} tagged: ${{ steps.resolve.outputs.tagged }} dist_run_id: ${{ steps.resolve.outputs.dist_run_id }} should_run_determinism: ${{ steps.resolve.outputs.should_run_determinism }} publish_from_artifact: ${{ steps.resolve.outputs.publish_from_artifact }} publish_version: ${{ steps.resolve.outputs.publish_version }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ inputs.tag || github.event.workflow_run.head_sha || github.sha }} - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Auto-tag release id: autotag if: ${{ github.event_name != 'workflow_dispatch' || inputs.tag == '' }} uses: tj-smith47/anodizer-action@v1 with: from-artifact: anodizer-linux artifact-workflow: ci.yml artifact-run-id: auto # Tag-only default (lockstep): master is advanced post-publish, below. args: tag --changelog env: # GITHUB_TOKEN: its pushes do NOT retrigger CI, so the tag push cannot loop the pipeline. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Resolve release target id: resolve uses: ./.github/actions/resolve-release-target with: tag: ${{ inputs.tag }} skip-determinism: ${{ inputs.skip_determinism }} dist-run-id: ${{ inputs.dist_run_id }} anodizer-version: ${{ inputs.anodizer_version }} autotag-sha: ${{ steps.autotag.outputs.head-sha }} autotag-tagged: ${{ steps.autotag.outputs.tagged }} github-token: ${{ secrets.GITHUB_TOKEN }} # Sharded byte-for-byte reproducibility gate, one shard per host/target. # The shard matrix lives in the reusable determinism.yml so it is defined # once; each shard uploads its preserved dist for the release job to consume. determinism-check: name: Determinism needs: tag if: needs.tag.result == 'success' && needs.tag.outputs.should_run_determinism == 'true' uses: ./.github/workflows/determinism.yml with: head_sha: ${{ needs.tag.outputs.sha }} secrets: inherit release: name: Publish Release needs: [tag, determinism-check] # !cancelled() overrides GHA's implicit success() so a *skipped* determinism # (the re-publish path) still publishes. determinism must exclude BOTH # failure AND cancelled: a cancelled shard leaves the merged dist incomplete. if: ${{ !cancelled() && needs.tag.outputs.tagged == 'true' && needs.determinism-check.result != 'failure' && needs.determinism-check.result != 'cancelled' }} runs-on: arc-anodizer timeout-minutes: 30 permissions: contents: write actions: read id-token: write packages: write attestations: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ needs.tag.outputs.sha }} - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable branch with: toolchain: stable - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Download preserved dist artifacts uses: ./.github/actions/download-preserved-dist with: run-id: ${{ needs.tag.outputs.dist_run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Verify preserved dist run: | set -euo pipefail mapfile -t files < <(find dist -maxdepth 1 \( -name 'context.json' -o -name 'context-*.json' \) -type f) if [ "${#files[@]}" -eq 0 ]; then echo "::error::No preserved-dist context manifests found in dist/" exit 1 fi printf 'Found %d preserved-dist context manifest(s):\n' "${#files[@]}" printf ' %s\n' "${files[@]}" - name: Assert all shards present run: | set -euo pipefail expected=(ubuntu-latest macos-latest windows-x86_64 windows-aarch64) missing=() for shard in "${expected[@]}"; do [ -f "dist/context-${shard}.json" ] || missing+=("$shard") done if [ "${#missing[@]}" -gt 0 ]; then printf '::error::Missing context-.json for: %s\n' "${missing[*]}" exit 1 fi echo "All ${#expected[@]} shards uploaded preserved dist." - name: Run anodizer release --publish-only uses: tj-smith47/anodizer-action@v1 with: from-artifact: ${{ needs.tag.outputs.publish_from_artifact }} version: ${{ needs.tag.outputs.publish_version }} artifact-run-id: auto artifact-workflow: ci.yml auto-install: true gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} apk-private-key: ${{ secrets.APK_PRIVATE_KEY }} # npm is peeled onto the github-hosted publish-npm job below: npm # provenance is minted from a GitHub-hosted OIDC token and the # registry rejects it (HTTP 422) on the self-hosted arc-anodizer # runner, so the main publish skips npm and the hosted job runs it. args: release --publish-only --skip=npm${{ inputs.skip_publishers != '' && format(',{0}', inputs.skip_publishers) || '' }} env: AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} CLOUDSMITH_TOKEN: ${{ secrets.CLOUDSMITH_TOKEN }} COSIGN_KEY: ${{ secrets.COSIGN_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} FURY_API_TOKEN: ${{ secrets.FURY_API_TOKEN }} FURY_PUSH_TOKEN: ${{ secrets.FURY_PUSH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PAT }} GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} UPLOAD_JARVISPRO_SECRET: ${{ secrets.UPLOAD_JARVISPRO_SECRET }} UPLOAD_JARVISPRO_USERNAME: ${{ secrets.UPLOAD_JARVISPRO_USERNAME }} # No workflow-side rollback step: the binary executes the # `release.on_failure` policy in-process (rollback by default, # auto-degrading to hold once any one-way-door publisher landed). # A cancelled run cannot execute its own policy; recover manually # with `anodizer tag rollback` / `release --rollback-only` after # reading the uploaded summary. - name: Upload run summary if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: run-summary-${{ needs.tag.outputs.sha }} path: | dist/run-*/summary.json dist/run-*/determinism.json if-no-files-found: error # npm is peeled off the self-hosted `release` job onto this github-hosted # one. npm provenance is minted from a GitHub Actions OIDC token, and the # registry only accepts that attestation from a github-hosted runner — on # the self-hosted arc-anodizer runner the publish 422s (anodizer detects # this and degrades to a non-provenance publish). Running npm here, on # ubuntu-latest with `id-token: write`, lets the npm publish carry # provenance while the main publish stays on arc-anodizer (which the # determinism / blob / uploads legs require). It consumes the same # preserved `dist-*` artifacts the main job used (npm optional-deps mode # embeds the built platform binaries). publish-npm: name: Publish npm (provenance) needs: [tag, release] # always() defeats GitHub's skipped-need propagation: a re-publish / # skip_determinism dispatch skips determinism-check, and without always() # this job is auto-skipped even when `release` succeeded — which silently # dropped npm from the release set since 0.12.3. The explicit # `&& result == 'success'` keeps it gated on a real publish. if: ${{ always() && needs.release.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read id-token: write attestations: write # anodizer-action's build-provenance step writes artifact attestations to the GH attestations API steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ needs.tag.outputs.sha }} - name: Download preserved dist artifacts uses: ./.github/actions/download-preserved-dist with: run-id: ${{ needs.tag.outputs.dist_run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Run anodizer release --publish-only --publishers npm uses: tj-smith47/anodizer-action@v1 with: from-artifact: ${{ needs.tag.outputs.publish_from_artifact }} version: ${{ needs.tag.outputs.publish_version }} artifact-run-id: auto artifact-workflow: ci.yml auto-install: true # No --skip: `--publishers npm` auto-determines the surface. The # allowlist deselects every non-npm PUBLISHER — including # github-release (so the GitHub Release the main job owns is not # re-touched), blob, snapcraft-publish, docker, docker-sign, and # announce — and the sign stage self-skips BOTH cosign/GPG loops on # this github-hosted runner: the detached-signature (`signs:`) loop # because all of its consumers (github-release / blob / artifactory / # uploads) are deselected, and the raw-binary (`binary_signs:`) loop # because publish-only mode discards binary-sign output (it has no # publish-time consumer — binaries are signed at build time on the # main job). So this runner is never asked for cosign/GPG material it # does not carry. args: release --publish-only --publishers npm env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Fast-forward master onto the published bump commit once publish succeeds. # tag pushed the tag (not the branch); the bump commit is reachable via the # tag, so a failed release advances neither master nor publishes a release. # force=false makes GitHub enforce the fast-forward server-side. advance-master: name: Advance master needs: [tag, release] # always() for the same reason as publish-npm: a skipped determinism-check # must not auto-skip this job when `release` itself succeeded. if: ${{ always() && needs.release.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write steps: - name: Fast-forward master onto the published bump commit env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SHA: ${{ needs.tag.outputs.sha }} run: | if ! gh api -X PATCH "repos/${{ github.repository }}/git/refs/heads/master" \ -f sha="$SHA" -F force=false; then echo "::error::Could not fast-forward master to $SHA (master moved during release?). \ The version IS published — do NOT re-cut. Reconcile by hand: \ git fetch origin && git push origin $SHA:refs/heads/master" exit 1 fi