mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
315f6f3053
commit
e7a0f1163b
10 files changed
+258
-32
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+64
@@ -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
|
||||
Reference in new issue
Block a user