diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index b64e3329ce..aec03abc56 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -79,7 +79,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.approved_sha }} + ref: ${{ github.sha }} - name: Verify caller and approved release ref env: ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} @@ -88,9 +88,11 @@ jobs: CALLER_REF: ${{ github.ref }} CALLER_REF_PROTECTED: ${{ github.ref_protected }} CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + CALLER_SHA: ${{ github.sha }} APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} APPROVED_SHA: ${{ inputs.approved_sha }} RELEASE_TAG: ${{ inputs.tag }} + VERIFY_RELEASE_CHECKOUT: false run: | bash scripts/verify-release-authorization.sh bash scripts/verify-release-tag.sh @@ -126,6 +128,7 @@ jobs: build: name: build (${{ matrix.name }}) needs: cache-guard + if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }} permissions: contents: read # checkout only; the publish job holds contents: write actions: read # SignPath reads run details + downloads the unsigned artifact @@ -300,6 +303,7 @@ jobs: publish: name: publish release needs: build + if: ${{ always() && !cancelled() && needs.build.result == 'success' }} runs-on: ubuntu-latest # The stable caller has already passed the single GitHub release approval. # Direct prereleases/manual stable recovery pass release-gate; canaries run @@ -387,7 +391,7 @@ jobs: contents: write # gh release download + compatibility manifest upload actions: write # dispatch pages.yml to re-bake the site version # Skip cleanly when R2 isn't configured; GitHub release still works as fallback. - if: ${{ github.repository_owner == 'esengine' }} + if: ${{ always() && !cancelled() && needs.publish.result == 'success' && github.repository_owner == 'esengine' }} env: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 8e81624b4f..29e7d819f9 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -78,7 +78,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.approved_sha }} + ref: ${{ github.sha }} - name: Verify caller and approved release ref env: ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} @@ -87,9 +87,11 @@ jobs: CALLER_REF: ${{ github.ref }} CALLER_REF_PROTECTED: ${{ github.ref_protected }} CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + CALLER_SHA: ${{ github.sha }} APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} APPROVED_SHA: ${{ inputs.approved_sha }} RELEASE_TAG: ${{ inputs.tag }} + VERIFY_RELEASE_CHECKOUT: false run: | bash scripts/verify-release-authorization.sh bash scripts/verify-release-tag.sh @@ -122,6 +124,7 @@ jobs: npm: name: publish npm packages needs: cache-guard + if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }} runs-on: ubuntu-latest # The stable caller has already passed the single GitHub release approval. # Direct prereleases/manual stable recovery pass release-gate above; canaries diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 6bde9b551b..0260e4c1b7 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -4,9 +4,11 @@ run-name: Release stable ${{ inputs.tag || github.ref_name }} # Stable publication has exactly one GitHub environment gate. Push the CLI, # npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow, # preflight verifies that all three tags point to the current main-v2 commit, -# and one GitHub approval releases every channel. SignPath intentionally keeps -# its separate manual confirmations. Prerelease and canary workflows remain -# independently callable. +# and one GitHub approval releases every channel. A manual recovery runs the +# fixed control plane from protected main-v2 while preserving an older tagged +# candidate on main-v2 history. SignPath intentionally keeps its separate +# manual confirmations. Prerelease and canary workflows remain independently +# callable. on: push: tags: @@ -45,7 +47,9 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.tag || github.ref }} + # A recovery dispatch uses the fixed workflow/scripts from protected + # main-v2. Publishers still check out the immutable approved tag SHA. + ref: ${{ github.sha }} - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -57,6 +61,7 @@ jobs: id: release env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }} run: bash scripts/resolve-stable-release.sh - name: Validate reviewed release notes run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md @@ -130,3 +135,41 @@ jobs: approved_sha: ${{ needs.authorize.outputs.sha }} orchestrated: true secrets: inherit + + postflight: + name: verify stable release artifacts + needs: [authorize, cli, npm, desktop] + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require every publisher to succeed + env: + CLI_RESULT: ${{ needs.cli.result }} + NPM_RESULT: ${{ needs.npm.result }} + DESKTOP_RESULT: ${{ needs.desktop.result }} + run: | + set -euo pipefail + for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do + if [ "$result" != "success" ]; then + echo "::error::stable publisher result is $result, expected success" + exit 1 + fi + done + - uses: actions/checkout@v7 + with: + # Postflight belongs to the trusted control plane, not the old build + # candidate, which may predate this verifier. + ref: ${{ github.sha }} + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Verify public artifacts and npm latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_VERSION: ${{ needs.authorize.outputs.version }} + CLI_TAG: ${{ needs.authorize.outputs.cli_tag }} + DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }} + run: bash scripts/verify-stable-release-artifacts.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ebf9729ee..643fcc103a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.approved_sha }} + ref: ${{ github.sha }} - name: Verify caller and approved release ref env: ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} @@ -57,9 +57,11 @@ jobs: CALLER_REF: ${{ github.ref }} CALLER_REF_PROTECTED: ${{ github.ref_protected }} CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + CALLER_SHA: ${{ github.sha }} APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }} APPROVED_SHA: ${{ inputs.approved_sha }} RELEASE_TAG: ${{ inputs.tag }} + VERIFY_RELEASE_CHECKOUT: false run: | bash scripts/verify-release-authorization.sh bash scripts/verify-release-tag.sh @@ -92,6 +94,7 @@ jobs: goreleaser: name: archives + checksums + homebrew tap needs: cache-guard + if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }} runs-on: ubuntu-latest # The stable caller has already passed the single GitHub release approval. # Standalone prereleases pass release-gate above. This job therefore must not diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 60af863043..3717cc97de 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -45,10 +45,13 @@ separate SignPath confirmations for the AMD64 and ARM64 requests. > environment. The reusable publishers additionally require the stable orchestrator to run on -the protected stable tag (or protected `main-v2` recovery ref), bind the caller -workflow commit to the approved SHA, check out that SHA, and revalidate each -remote release tag immediately before publication. An unprotected branch cannot -claim that it already passed the approval job. +the protected stable tag (or protected `main-v2` recovery ref). Normal tag-push +releases bind the caller workflow commit to the approved SHA. Recovery keeps the +fixed control-plane workflow on protected `main-v2`, resolves the existing three +tags to one immutable historical SHA on `main-v2`, and uses that SHA only for the +actual build and publication checkouts. Every publisher revalidates its remote +release tag immediately before publication. An unprotected branch cannot claim +that it already passed the approval job. Repository `write` access remains a privileged role: GitHub Actions workflows on repository branches can access repository-level Actions secrets. Do not grant @@ -101,7 +104,10 @@ from accidental or unauthorized invocation. stable npm tags and `npm update -g` silently downgraded users to 0.53.2 (#5822). The CLI and npm jobs run concurrently; the CLI's freshness check may warn while npm is still propagating, while release-npm.yml's verify step owns the final - assertion. + assertion. The stable orchestrator finishes with a postflight that verifies + both GitHub Releases contain their required assets and npm `latest` exactly + matches the approved version; missing artifacts can no longer produce a green + stable run. 7. **Next cycle** — the canary rolls on toward `1.5.0`. ## Notes @@ -109,6 +115,11 @@ from accidental or unauthorized invocation. - Canary version numbers use the workflow `run_number`, so the desktop and CLI canary numbers differ (e.g. `canary.11` vs `canary.2`). Only monotonicity per channel matters. - A stable `-rc` tag (e.g. `npm-v1.4.0-rc.1`) still ships under `next`, not `canary`. +- Recover an interrupted stable release by dispatching **Release stable** from + protected `main-v2` with the existing `vX.Y.Z` tag. Recovery requires the CLI, + npm, and Desktop tags to remain aligned on an ancestor of current `main-v2`, + then uses the same single approval and postflight. Never move or recreate the + published tags to pick up a workflow fix. - Windows release signing uses SignPath trusted-build and origin verification. Keep **Use approval process** enabled on `release-signing`: the AMD64 and ARM64 requests can each require a manual confirmation after the single GitHub diff --git a/scripts/release-workflows.test.sh b/scripts/release-workflows.test.sh index b47c41bde7..0e21465635 100755 --- a/scripts/release-workflows.test.sh +++ b/scripts/release-workflows.test.sh @@ -20,7 +20,12 @@ for workflow in release.yml release-npm.yml release-desktop.yml; do grep -Eq 'inputs\.approved_sha' "$repo_root/.github/workflows/$workflow" grep -Eq 'verify-release-tag\.sh' "$repo_root/.github/workflows/$workflow" grep -Eq 'release-stable\.yml' "$repo_root/.github/workflows/$workflow" + grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/$workflow" done +grep -Eq "needs\.build\.result == 'success'" "$repo_root/.github/workflows/release-desktop.yml" +grep -Eq "needs\.publish\.result == 'success'" "$repo_root/.github/workflows/release-desktop.yml" +grep -Eq '^ postflight:$' "$repo_root/.github/workflows/release-stable.yml" +grep -Eq 'verify-stable-release-artifacts\.sh' "$repo_root/.github/workflows/release-stable.yml" git init --bare -q "$test_root/remote.git" git clone -q "$test_root/remote.git" "$test_root/repo" @@ -43,15 +48,50 @@ git clone -q "$test_root/remote.git" "$test_root/repo" ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/tags/v1.2.3' \ EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/tags/v1.2.3' \ CALLER_EVENT_NAME=push CALLER_REF=refs/tags/v1.2.3 CALLER_REF_PROTECTED=true \ - CALLER_WORKFLOW_SHA="$approved_sha" APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + CALLER_WORKFLOW_SHA="$approved_sha" CALLER_SHA="$approved_sha" \ + APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ "$repo_root/scripts/verify-release-authorization.sh" RELEASE_TAG=desktop-v1.2.3 APPROVED_SHA="$approved_sha" \ "$repo_root/scripts/verify-release-tag.sh" + git commit --allow-empty -q -m "release workflow fix" + git push -q origin main-v2 + recovery_workflow_sha="$(git rev-parse HEAD)" + if RELEASE_TAG=v1.2.3 "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/stale-main.log" 2>&1; then + echo "old stable tag unexpectedly passed normal release resolution" >&2 + exit 1 + fi + if ! grep -Eq 'points to .*expected' "$test_root/stale-main.log"; then + sed -n '1,20p' "$test_root/stale-main.log" >&2 + exit 1 + fi + GITHUB_OUTPUT="$test_root/recovery.out" ALLOW_STABLE_RECOVERY=true RELEASE_TAG=v1.2.3 \ + "$repo_root/scripts/resolve-stable-release.sh" + grep -Eq '^sha='"$approved_sha"'$' "$test_root/recovery.out" + ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \ + EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \ + CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/main-v2 CALLER_REF_PROTECTED=true \ + CALLER_WORKFLOW_SHA="$recovery_workflow_sha" CALLER_SHA="$recovery_workflow_sha" \ + APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-authorization.sh" + RELEASE_TAG=v1.2.3 APPROVED_SHA="$approved_sha" VERIFY_RELEASE_CHECKOUT=false \ + "$repo_root/scripts/verify-release-tag.sh" + if ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \ + EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/main-v2' \ + CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/main-v2 CALLER_REF_PROTECTED=true \ + CALLER_WORKFLOW_SHA="$approved_sha" CALLER_SHA="$recovery_workflow_sha" \ + APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + "$repo_root/scripts/verify-release-authorization.sh" >"$test_root/stale-workflow.log" 2>&1; then + echo "stale recovery workflow unexpectedly passed release authorization" >&2 + exit 1 + fi + grep -Eq 'recovery caller workflow SHA is' "$test_root/stale-workflow.log" + if ACTUAL_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/topic' \ EXPECTED_CALLER_WORKFLOW_REF='example/reasonix/.github/workflows/release-stable.yml@refs/heads/topic' \ CALLER_EVENT_NAME=workflow_dispatch CALLER_REF=refs/heads/topic CALLER_REF_PROTECTED=false \ - CALLER_WORKFLOW_SHA="$approved_sha" APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ + CALLER_WORKFLOW_SHA="$recovery_workflow_sha" CALLER_SHA="$recovery_workflow_sha" \ + APPROVED_CLI_TAG=v1.2.3 APPROVED_SHA="$approved_sha" \ "$repo_root/scripts/verify-release-authorization.sh" >"$test_root/unprotected.log" 2>&1; then echo "unprotected caller unexpectedly passed release authorization" >&2 exit 1 @@ -68,8 +108,19 @@ git clone -q "$test_root/remote.git" "$test_root/repo" grep -Eq 'required stable release tag is missing: desktop-v1\.2\.4' "$test_root/missing.log" other_sha="$(git commit-tree HEAD^{tree} -p HEAD -m "other")" + git tag v1.2.6 "$other_sha" + git tag npm-v1.2.6 "$other_sha" + git tag desktop-v1.2.6 "$other_sha" + git push -q origin v1.2.6 npm-v1.2.6 desktop-v1.2.6 + if ALLOW_STABLE_RECOVERY=true RELEASE_TAG=v1.2.6 \ + "$repo_root/scripts/resolve-stable-release.sh" >"$test_root/non-ancestor.log" 2>&1; then + echo "non-ancestor recovery tags unexpectedly passed release resolution" >&2 + exit 1 + fi + grep -Eq 'is not an ancestor of' "$test_root/non-ancestor.log" git tag -f desktop-v1.2.3 "$other_sha" >/dev/null git push -q -f origin desktop-v1.2.3 + git checkout -q --detach "$approved_sha" if RELEASE_TAG=desktop-v1.2.3 APPROVED_SHA="$approved_sha" \ "$repo_root/scripts/verify-release-tag.sh" >"$test_root/moved-tag.log" 2>&1; then echo "moved release tag unexpectedly passed approved SHA validation" >&2 @@ -78,6 +129,7 @@ git clone -q "$test_root/remote.git" "$test_root/repo" grep -Eq 'moved to .* after approval' "$test_root/moved-tag.log" git tag -f desktop-v1.2.3 "$approved_sha" >/dev/null git push -q -f origin desktop-v1.2.3 + git switch -q main-v2 git tag v1.2.5 git tag npm-v1.2.5 "$other_sha" diff --git a/scripts/resolve-stable-release.sh b/scripts/resolve-stable-release.sh index 4ca7ba736c..70b9cb4aa6 100755 --- a/scripts/resolve-stable-release.sh +++ b/scripts/resolve-stable-release.sh @@ -1,12 +1,22 @@ #!/usr/bin/env bash # Validate one stable release tag set and emit the values shared by all release # workflows. Stable releases are deliberately all-or-nothing: the CLI, npm, and -# desktop tags must already exist on the current main-v2 commit before the sole -# release-environment approval is requested. +# desktop tags must resolve to one commit. A tag-push release requires current +# main-v2; an explicit recovery may target an older commit on main-v2 history +# while the trusted workflow itself remains on current protected main-v2. set -euo pipefail release_tag="${RELEASE_TAG:?RELEASE_TAG is required}" release_remote="${RELEASE_REMOTE:-origin}" +allow_recovery="${ALLOW_STABLE_RECOVERY:-false}" + +case "$allow_recovery" in +true | false) ;; +*) + echo "::error::ALLOW_STABLE_RECOVERY must be true or false, got: $allow_recovery" >&2 + exit 1 + ;; +esac if [[ ! "$release_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::stable release tag must be vMAJOR.MINOR.PATCH, got: $release_tag" >&2 @@ -18,17 +28,37 @@ cli_tag="$release_tag" npm_tag="npm-v${version}" desktop_tag="desktop-v${version}" -head_sha="$(git rev-parse HEAD^{commit})" +checkout_sha="$(git rev-parse HEAD^{commit})" main_sha="$(git ls-remote "$release_remote" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')" if [ -z "$main_sha" ]; then echo "::error::cannot resolve $release_remote/main-v2" >&2 exit 1 fi -if [ "$head_sha" != "$main_sha" ]; then - echo "::error::$cli_tag points to $head_sha, but $release_remote/main-v2 is $main_sha" >&2 + +if [ "$checkout_sha" != "$main_sha" ]; then + echo "::error::release control checkout is $checkout_sha, but $release_remote/main-v2 is $main_sha" >&2 exit 1 fi +release_sha="$checkout_sha" +if [ "$allow_recovery" = "true" ]; then + release_sha="$( + git ls-remote --tags "$release_remote" "refs/tags/$cli_tag" "refs/tags/$cli_tag^{}" | + awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }' + )" + if [ -z "$release_sha" ]; then + echo "::error::required stable release tag is missing: $cli_tag" >&2 + exit 1 + fi + git fetch --quiet --no-tags "$release_remote" refs/heads/main-v2 + git fetch --quiet --no-tags "$release_remote" "refs/tags/$cli_tag" + if ! git merge-base --is-ancestor "$release_sha" "$main_sha"; then + echo "::error::recovery tag $cli_tag points to $release_sha, which is not an ancestor of $release_remote/main-v2 ($main_sha)" >&2 + exit 1 + fi + echo "stable recovery: $cli_tag remains on main-v2 history at $release_sha; current main-v2 is $main_sha" +fi + for tag in "$cli_tag" "$npm_tag" "$desktop_tag"; do # Prefer the peeled commit for annotated tags; lightweight tags only return # the first line. Both forms are valid release refs. @@ -40,8 +70,8 @@ for tag in "$cli_tag" "$npm_tag" "$desktop_tag"; do echo "::error::required stable release tag is missing: $tag" >&2 exit 1 fi - if [ "$tag_sha" != "$head_sha" ]; then - echo "::error::$tag points to $tag_sha, expected $head_sha" >&2 + if [ "$tag_sha" != "$release_sha" ]; then + echo "::error::$tag points to $tag_sha, expected $release_sha" >&2 exit 1 fi done @@ -52,7 +82,7 @@ output_file="${GITHUB_OUTPUT:-/dev/stdout}" echo "cli_tag=$cli_tag" echo "npm_tag=$npm_tag" echo "desktop_tag=$desktop_tag" - echo "sha=$head_sha" + echo "sha=$release_sha" } >>"$output_file" -echo "stable release resolved: version=$version sha=$head_sha" +echo "stable release resolved: version=$version sha=$release_sha" diff --git a/scripts/verify-release-authorization.sh b/scripts/verify-release-authorization.sh index 1c0108cc34..01340bf5da 100755 --- a/scripts/verify-release-authorization.sh +++ b/scripts/verify-release-authorization.sh @@ -10,6 +10,7 @@ caller_event="${CALLER_EVENT_NAME:?CALLER_EVENT_NAME is required}" caller_ref="${CALLER_REF:?CALLER_REF is required}" caller_ref_protected="${CALLER_REF_PROTECTED:?CALLER_REF_PROTECTED is required}" caller_workflow_sha="${CALLER_WORKFLOW_SHA:?CALLER_WORKFLOW_SHA is required}" +caller_sha="${CALLER_SHA:?CALLER_SHA is required}" approved_cli_tag="${APPROVED_CLI_TAG:?APPROVED_CLI_TAG is required}" approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}" @@ -21,10 +22,6 @@ if [ "$caller_ref_protected" != "true" ]; then echo "::error::orchestrated release caller ref is not protected: $caller_ref" >&2 exit 1 fi -if [ "$caller_workflow_sha" != "$approved_sha" ]; then - echo "::error::caller workflow SHA is $caller_workflow_sha, expected approved SHA $approved_sha" >&2 - exit 1 -fi if [[ ! "$approved_cli_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::approved CLI tag must be vMAJOR.MINOR.PATCH, got: $approved_cli_tag" >&2 exit 1 @@ -33,9 +30,17 @@ fi case "$caller_event" in push) expected_ref="refs/tags/$approved_cli_tag" + if [ "$caller_workflow_sha" != "$approved_sha" ] || [ "$caller_sha" != "$approved_sha" ]; then + echo "::error::tag-push caller SHA/workflow SHA must equal approved SHA $approved_sha (caller=$caller_sha workflow=$caller_workflow_sha)" >&2 + exit 1 + fi ;; workflow_dispatch) expected_ref="refs/heads/main-v2" + if [ "$caller_workflow_sha" != "$caller_sha" ]; then + echo "::error::recovery caller workflow SHA is $caller_workflow_sha, expected protected main-v2 SHA $caller_sha" >&2 + exit 1 + fi ;; *) echo "::error::unsupported stable release caller event: $caller_event" >&2 diff --git a/scripts/verify-release-tag.sh b/scripts/verify-release-tag.sh index f4eb32dc6d..0abdb4373f 100755 --- a/scripts/verify-release-tag.sh +++ b/scripts/verify-release-tag.sh @@ -7,6 +7,15 @@ set -euo pipefail release_tag="${RELEASE_TAG:?RELEASE_TAG is required}" approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}" release_remote="${RELEASE_REMOTE:-origin}" +verify_checkout="${VERIFY_RELEASE_CHECKOUT:-true}" + +case "$verify_checkout" in +true | false) ;; +*) + echo "::error::VERIFY_RELEASE_CHECKOUT must be true or false, got: $verify_checkout" >&2 + exit 1 + ;; +esac if [[ ! "$approved_sha" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::approved release SHA must be a full commit SHA, got: $approved_sha" >&2 @@ -17,10 +26,12 @@ if ! git check-ref-format "refs/tags/$release_tag" >/dev/null; then exit 1 fi -head_sha="$(git rev-parse HEAD^{commit})" -if [ "$head_sha" != "$approved_sha" ]; then - echo "::error::release checkout is $head_sha, expected approved SHA $approved_sha" >&2 - exit 1 +if [ "$verify_checkout" = "true" ]; then + head_sha="$(git rev-parse HEAD^{commit})" + if [ "$head_sha" != "$approved_sha" ]; then + echo "::error::release checkout is $head_sha, expected approved SHA $approved_sha" >&2 + exit 1 + fi fi # Prefer the peeled commit for annotated tags; lightweight tags only return the diff --git a/scripts/verify-stable-release-artifacts.sh b/scripts/verify-stable-release-artifacts.sh new file mode 100755 index 0000000000..a721efb360 --- /dev/null +++ b/scripts/verify-stable-release-artifacts.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Verify that a stable orchestration produced every public release channel. +set -euo pipefail + +repository="${RELEASE_REPOSITORY:?RELEASE_REPOSITORY is required}" +version="${RELEASE_VERSION:?RELEASE_VERSION is required}" +cli_tag="${CLI_TAG:?CLI_TAG is required}" +desktop_tag="${DESKTOP_TAG:?DESKTOP_TAG is required}" +attempts="${VERIFY_ATTEMPTS:-6}" +delay="${VERIFY_DELAY_SECONDS:-10}" + +if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::RELEASE_VERSION must be stable semver, got: $version" >&2 + exit 1 +fi +if [ "$cli_tag" != "v$version" ] || [ "$desktop_tag" != "desktop-v$version" ]; then + echo "::error::release tags do not match version $version: cli=$cli_tag desktop=$desktop_tag" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-postflight.XXXXXX")" +cleanup() { + case "$tmp_dir" in + */reasonix-release-postflight.*) rm -rf -- "$tmp_dir" ;; + *) echo "refusing to clean unexpected postflight directory: $tmp_dir" >&2 ;; + esac +} +trap cleanup EXIT + +gh release view "$cli_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/cli.json" +jq -e ' + .isDraft == false and .isPrerelease == false and + ([.assets[].name] as $names | + ["SHA256SUMS", "reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz", + "reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz", + "reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip"] | + all(. as $required | $names | index($required))) +' "$tmp_dir/cli.json" >/dev/null + +gh release view "$desktop_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/desktop.json" +jq -e ' + .isDraft == false and .isPrerelease == false and + ([.assets[].name] as $names | + ($names | index("latest.json")) and + (["Reasonix-darwin-universal.dmg", "Reasonix-linux-amd64.deb", + "Reasonix-linux-amd64.tar.gz", "Reasonix-windows-amd64-installer.exe", + "Reasonix-windows-arm64-installer.exe"] | + all(. as $required | ($names | index($required)) and ($names | index($required + ".minisig"))))) +' "$tmp_dir/desktop.json" >/dev/null + +for attempt in $(seq 1 "$attempts"); do + latest="$(npm view reasonix dist-tags.latest 2>/dev/null || true)" + if [ "$latest" = "$version" ]; then + echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag npm-latest=$latest" + exit 0 + fi + echo "npm latest -> ${latest:-}, want $version (attempt $attempt/$attempts)" + if [ "$attempt" -lt "$attempts" ]; then + sleep "$delay" + fi +done + +echo "::error::npm latest did not reach $version" >&2 +exit 1