fix(release): prevent skipped stable publishers (#6629)

Problem:
The stable orchestrator reported success after approval even though the CLI, npm, and Desktop publication jobs were skipped through a transitive skipped dependency. The existing recovery dispatch also could not use a workflow fix without moving the already-published tags.

Root cause:
Each reusable publisher joined an orchestrated guard with a deliberately skipped standalone gate. The cache guard used an explicit status condition, but its descendants did not, so GitHub propagated the skipped ancestor through the dependency chain. Recovery also checked out and executed control scripts from the old release candidate.

Fix:
Add explicit success conditions to every affected publication stage, keep recovery authorization and verification on protected main-v2 while building the immutable historical tag SHA, and reject non-ancestor or mismatched tag sets. Add a stable postflight that verifies required CLI/Desktop release assets and the exact npm latest version so a no-op release cannot stay green.

Verification:
- bash scripts/release-workflows.test.sh
- go run github.com/rhysd/actionlint/cmd/[email protected] -ignore 'label "windows-11-arm" is unknown' on all release workflows
- bash -n on all changed release scripts
- verify-stable-release-artifacts.sh against public v1.17.14 artifacts
- ./scripts/cache-guard.sh
- go test ./...
- git diff --check
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-18 08:36:22 +08:00
1 parent 315f6f3053
commit e7a0f1163b
10 files changed
+258 -32

No files matched your search

+6 -2
View File
@@ -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:
+4 -1
View File
@@ -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
+47 -4
View File
@@ -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
+4 -1
View File
@@ -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
+16 -5
View File
@@ -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
+54 -2
View File
@@ -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"
+39 -9
View File
@@ -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"
+9 -4
View File
@@ -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
+15 -4
View File
@@ -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
+64
View File
@@ -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:-<unreadable>}, 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