Build reusable release candidates for fast recovery

Problem:
Stable releases rebuild, resign, and rerun broad validation after publication failures or unrelated main-v2 changes, making recovery slow and fragile.

Root cause:
Build, signing, acceptance, and publication were coupled to one run. Candidate identity and evidence could not be verified across runs, platform scheduling introduced unrelated waits, and control-plane faults were found after expensive jobs started.

Fix:
Introduce immutable, provenance-bound release candidates; split preparation, promotion, recovery, and public verification; share CLI/npm binaries; decouple Desktop platform scheduling; reuse one Certum session; add a publication ledger; move release-control checks ahead of builds; route CI by affected surface; and document the new workflow.

Verification:
- node --test scripts/*.test.mjs npm/*.test.mjs
- bash scripts/validate-release-control-plane.test.sh
- bash scripts/validate-release-candidate-source.test.sh
- bash scripts/verify-release-push-ci.test.sh
- bash scripts/release-stable.test.sh
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/*.yml
- go run ./tools/repolint
- go vet ./...
- (cd desktop && go vet ./...)
- go test ./internal/agent ./internal/control ./tools/desktopinventory
- (cd desktop && go test ./...)
This commit is contained in:
SivanCola committed 2026-09-18 17:00:33 +08:00
1 parent bd0733b972
commit c45b168a87
58 files changed
+3376 -833

No files matched your search

+8 -1
View File
@@ -6,10 +6,17 @@
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-candidate.yml @SivanCola @esengine
/.github/workflows/release-promote.yml @SivanCola @esengine
/.github/workflows/release-verify.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/signpath-checkpoint.mjs @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/scripts/finalize-windows-signed-candidate.sh @SivanCola @esengine
/scripts/release-candidate.mjs @SivanCola @esengine
/scripts/resolve-release-candidate.mjs @SivanCola @esengine
/scripts/validate-release-control-plane.sh @SivanCola @esengine
/scripts/verify-stable-release-artifacts.sh @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
+35 -5
View File
@@ -39,6 +39,7 @@ jobs:
packaging: ${{ steps.filter.outputs.packaging }}
site: ${{ steps.filter.outputs.site }}
sdk: ${{ steps.filter.outputs.sdk }}
release_control: ${{ steps.filter.outputs.release_control }}
notes_only: ${{ steps.filter.outputs.notes_only }}
steps:
- uses: actions/checkout@v7
@@ -1526,24 +1527,49 @@ jobs:
echo "::endgroup::"
done
- name: release workflow contracts
release-control:
needs: changes
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.release_control != 'false') }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: Validate release workflows and executable contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "macos-15-intel" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-stable-trigger.yml \
.github/workflows/release-candidate.yml \
.github/workflows/release-promote.yml \
.github/workflows/release-verify.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
.github/workflows/macos-signing-check.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml \
.github/workflows/ci.yml
node --test scripts/desktop-windows-go-tests.test.mjs
node --test scripts/desktop-release-artifacts.test.mjs scripts/signpath-checkpoint.test.mjs scripts/ci-workflow.test.mjs scripts/notarize-desktop.test.mjs scripts/verify-windows-gui-subsystem.test.mjs scripts/windows-go-tests.test.mjs scripts/macos-go-tests.test.mjs
node --test scripts/release-candidate.test.mjs scripts/resolve-release-candidate.test.mjs \
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
scripts/desktop-windows-go-tests.test.mjs \
npm/publish.test.mjs
bash scripts/validate-release-candidate-source.test.sh
bash scripts/validate-release-control-plane.test.sh
bash scripts/verify-release-push-ci.test.sh
bash scripts/release-stable.test.sh
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
@@ -1582,7 +1608,7 @@ jobs:
# frontend unit coverage can run once in desktop-frontend without weakening
# the branch gate.
lint:
needs: [changes, lint-code, desktop-prepare, desktop-frontend]
needs: [changes, lint-code, release-control, desktop-prepare, desktop-frontend]
if: always()
runs-on: ubuntu-latest
steps:
@@ -1591,14 +1617,18 @@ jobs:
CHANGES_RESULT: ${{ needs.changes.result }}
LINT_CODE_RESULT: ${{ needs.lint-code.result }}
LINT_CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false' }}
RELEASE_CONTROL_RESULT: ${{ needs.release-control.result }}
RELEASE_CONTROL_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.release_control != 'false' }}
FRONTEND_RESULT: ${{ needs.desktop-frontend.result }}
PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
FRONTEND_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.electron != 'false' }}
run: |
test "$CHANGES_RESULT" = success
expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
lint_expected=skipped
if [ "$LINT_CODE_REQUIRED" = true ]; then lint_expected=success; fi
test "$LINT_CODE_RESULT" = "$lint_expected"
test "$RELEASE_CONTROL_RESULT" = "$(expected "$RELEASE_CONTROL_REQUIRED")"
if [ "$FRONTEND_REQUIRED" = true ]; then
test "$PREPARE_RESULT" = success
test "$FRONTEND_RESULT" = success
+7
View File
@@ -1,10 +1,17 @@
name: Deploy site
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}
on:
push:
branches: [main-v2]
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
inputs:
release_version:
description: "Optional release version that owns this deployment"
required: false
default: ""
type: string
permissions:
contents: read
+7 -6
View File
@@ -43,11 +43,14 @@ jobs:
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
source_sha="$(git merge-base origin/main-v2 HEAD)"
else
git checkout -b "$branch"
source_sha="$(git rev-parse HEAD)"
fi
git merge-base --is-ancestor "$source_sha" origin/main-v2
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
@@ -57,11 +60,9 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# The review branch may already contain an older generated Notes
# commit. Generate strictly from the public release line so a local,
# not-yet-pushed merge commit and the Notes PR itself cannot enter
# the release range.
node scripts/generate-release-notes.mjs --version "$VERSION" --to origin/main-v2
# The first preparation fixes the product range. Reruns update the
# draft without silently absorbing unrelated main-v2 merges.
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
+396
View File
@@ -0,0 +1,396 @@
name: Prepare release candidate
run-name: Prepare release candidate ${{ inputs.version || github.sha }}
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix"
required: true
type: string
push:
branches: [main-v2]
paths:
- release-notes/releases.json
concurrency:
group: release-candidate-${{ inputs.version || github.sha }}
cancel-in-progress: false
permissions:
actions: read
contents: read
jobs:
resolve:
name: validate candidate inputs before native builds
runs-on: ubuntu-latest
outputs:
version: ${{ steps.candidate.outputs.version }}
candidate_id: ${{ steps.candidate.outputs.candidate_id }}
catalog_sha256: ${{ steps.candidate.outputs.catalog_sha256 }}
reuse: ${{ steps.existing.outputs.reuse }}
permissions:
actions: read
attestations: read
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Resolve reviewed source
id: candidate
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
PUSH_SHA: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
RELEASE_CI_WAIT_SECONDS: 1800
run: |
set -euo pipefail
git fetch origin main-v2 --tags
if [ "$EVENT_NAME" = push ]; then
version="$(jq -r '.releases[0].version // empty' release-notes/releases.json)"
source_sha="$PUSH_SHA"
else
version="$INPUT_VERSION"
source_sha="$(git rev-parse origin/main-v2)"
fi
bash scripts/validate-release-candidate-source.sh "$version" "$source_sha"
bash scripts/verify-release-push-ci.sh "$source_sha"
git show "$source_sha:release-notes/releases.json" > /tmp/release-catalog.json
catalog_sha256="$(sha256sum /tmp/release-catalog.json | awk '{print $1}')"
candidate_id="$(node scripts/release-candidate.mjs id "$version" "$source_sha" "$catalog_sha256")"
node scripts/resolve-release-candidate.mjs active "$candidate_id"
{
echo "version=$version"
echo "source_sha=$source_sha"
echo "catalog_sha256=$catalog_sha256"
echo "candidate_id=$candidate_id"
} >> "$GITHUB_OUTPUT"
{
echo "### Candidate input"
echo
echo "- ID: \`$candidate_id\`"
echo "- Product: \`$source_sha\`"
echo "- Control: \`$GITHUB_SHA\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Verify release control helpers and signing configuration
env:
OFFICIAL_REPOSITORY: ${{ github.repository == 'esengine/DeepSeek-Reasonix' }}
HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
HAS_CERTUM_SIGNING: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' }}
HAS_MINISIGN: ${{ secrets.MINISIGN_PRIVATE_KEY != '' && secrets.MINISIGN_PASSWORD != '' }}
run: |
bash scripts/validate-release-control-plane.sh
if [ "$OFFICIAL_REPOSITORY" = true ]; then
for configured in "$HAS_APPLE_SIGNING" "$HAS_CERTUM_SIGNING" "$HAS_MINISIGN"; do
[ "$configured" = true ] || { echo "::error::release signing configuration is incomplete"; exit 1; }
done
fi
- name: Reuse an existing sealed candidate
id: existing
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_ID: ${{ steps.candidate.outputs.candidate_id }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: |
set -euo pipefail
resolved="$RUNNER_TEMP/existing-candidate.outputs"
GITHUB_OUTPUT="$resolved" node scripts/resolve-release-candidate.mjs resolve-optional "$CANDIDATE_ID"
output() { sed -n "s/^$1=//p" "$resolved" | tail -1; }
if [ "$(output found)" != true ]; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
exit 0
fi
record_id="$(output record_artifact_id)"
run_id="$(output producer_run_id)"
mkdir -p "$RUNNER_TEMP/existing-record"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id" > "$RUNNER_TEMP/existing-record-artifact.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id" > "$RUNNER_TEMP/existing-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id/zip" > "$RUNNER_TEMP/existing-record.zip"
unzip -q "$RUNNER_TEMP/existing-record.zip" -d "$RUNNER_TEMP/existing-record"
jq -e '
.validity.revoked == false and
((.validity.createdAt | fromdateiso8601) < (.validity.expiresAt | fromdateiso8601))
' "$RUNNER_TEMP/existing-record/record.json" >/dev/null
if ! jq -e '(.validity.expiresAt | fromdateiso8601) > now' \
"$RUNNER_TEMP/existing-record/record.json" >/dev/null; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
signer_sha="$(jq -r .head_sha "$RUNNER_TEMP/existing-run.json")"
gh attestation verify "$RUNNER_TEMP/existing-record/record.json" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
inspected="$RUNNER_TEMP/existing-inspected.outputs"
GITHUB_OUTPUT="$inspected" node scripts/resolve-release-candidate.mjs inspect "$CANDIDATE_ID" \
"$RUNNER_TEMP/existing-record/record.json" "$RUNNER_TEMP/existing-record-artifact.json" \
"$RUNNER_TEMP/existing-run.json"
payload_id="$(sed -n 's/^payload_artifact_id=//p' "$inspected" | tail -1)"
if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$payload_id" \
> "$RUNNER_TEMP/existing-payload.json" 2> "$RUNNER_TEMP/existing-payload.error"; then
if grep -Eiq 'HTTP 404|Not Found' "$RUNNER_TEMP/existing-payload.error"; then
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record exists but its payload is unavailable; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
cat "$RUNNER_TEMP/existing-payload.error" >&2
exit 1
fi
test "$(jq -r .expired "$RUNNER_TEMP/existing-payload.json")" = false || {
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "The sealed record exists but its payload expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
exit 0
}
test "$(jq -r .name "$RUNNER_TEMP/existing-payload.json")" = "release-candidate-payload-$CANDIDATE_ID"
test "$(jq -r .workflow_run.id "$RUNNER_TEMP/existing-payload.json")" = "$run_id"
echo "reuse=true" >> "$GITHUB_OUTPUT"
echo "A sealed candidate with an active exact payload already exists; native builds are skipped." >> "$GITHUB_STEP_SUMMARY"
- name: Checkout immutable product for notes validation
if: steps.existing.outputs.reuse != 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
path: product
persist-credentials: false
- name: Validate embedded notes and cache identity inputs
if: steps.existing.outputs.reuse != 'true'
working-directory: product
env:
DOCS_BUILD_VERSION: v${{ steps.candidate.outputs.version }}
DOCS_SOURCE_REVISION: ${{ github.sha }}
run: |
node scripts/release-notes.mjs validate
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
./scripts/cache-guard.sh
cli-npm:
name: build shared CLI and npm candidate
needs: resolve
if: needs.resolve.outputs.reuse != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Build each CLI binary once and package both surfaces
env:
RELEASE_SOURCE_SHA: ${{ github.sha }}
run: |
RELEASE_BUILD_TIME="$(git show -s --format=%cI "$RELEASE_SOURCE_SHA")"
export RELEASE_BUILD_TIME
node scripts/build-release-cli-candidate.mjs "${{ needs.resolve.outputs.version }}" candidate-cli
- uses: actions/upload-artifact@v7
with:
name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
path: |
candidate-cli/cli
candidate-cli/npm
if-no-files-found: error
overwrite: true
retention-days: 30
desktop:
name: build, sign, and accept Desktop candidate
needs: resolve
if: needs.resolve.outputs.reuse != 'true'
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: desktop-v${{ needs.resolve.outputs.version }}
approved_cli_tag: v${{ needs.resolve.outputs.version }}
approved_sha: ${{ github.sha }}
orchestrated: true
orchestrator: candidate
signing_preflight: true
candidate_preparation: true
secrets: inherit
seal:
name: seal reusable release candidate
needs: [resolve, cli-npm, desktop]
if: >-
always() && !cancelled() && needs.resolve.outputs.reuse != 'true' &&
needs.resolve.result == 'success' && needs.cli-npm.result == 'success' && needs.desktop.result == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
attestations: write
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
with:
node-version: "22"
- uses: actions/download-artifact@v8
with:
name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
path: payload
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-arm64
path: payload/desktop/darwin-arm64
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-amd64
path: payload/desktop/darwin-amd64
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-universal
path: payload/desktop/darwin-universal
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-amd64
path: payload/desktop/windows-amd64
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-arm64
path: payload/desktop/windows-arm64
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-linux-amd64
path: payload/desktop/linux-amd64
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-amd64
path: payload/evidence
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-arm64
path: payload/evidence
- uses: actions/download-artifact@v8
with:
name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-macos-universal-intel
path: payload/evidence
- name: Normalize acceptance receipt names
run: |
test -s payload/evidence/windows-amd64.json
test -s payload/evidence/windows-arm64.json
test -s payload/evidence/macos-universal-intel.json
- uses: actions/attest-build-provenance@v3
with:
subject-path: payload/**/*
- name: Upload immutable candidate payload
id: payload
uses: actions/upload-artifact@v7
with:
name: release-candidate-payload-${{ needs.resolve.outputs.candidate_id }}
path: payload
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Retain native acceptance evidence
id: evidence
uses: actions/upload-artifact@v7
with:
name: release-candidate-evidence-${{ needs.resolve.outputs.candidate_id }}
path: payload/evidence
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Render reviewed notes from the immutable product
run: |
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
git worktree remove "$worktree"
- name: Seal candidate record
env:
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_SOURCE_SHA: ${{ github.sha }}
RELEASE_BUILD_CONTROL_SHA: ${{ github.sha }}
RELEASE_ACCEPTANCE_CONTROL_SHA: ${{ github.sha }}
RELEASE_NOTES_SOURCE_SHA: ${{ github.sha }}
RELEASE_CATALOG_SHA256: ${{ needs.resolve.outputs.catalog_sha256 }}
RELEASE_DESKTOP_PREFIX: ${{ needs.desktop.outputs.artifact_prefix }}
RELEASE_DESKTOP_FINGERPRINT: ${{ needs.desktop.outputs.signing_fingerprint }}
RELEASE_PAYLOAD_ARTIFACT_ID: ${{ steps.payload.outputs.artifact-id }}
RELEASE_PAYLOAD_ARTIFACT_NAME: release-candidate-payload-${{ needs.resolve.outputs.candidate_id }}
RELEASE_EVIDENCE_ARTIFACT_ID: ${{ steps.evidence.outputs.artifact-id }}
RELEASE_EVIDENCE_ARTIFACT_NAME: release-candidate-evidence-${{ needs.resolve.outputs.candidate_id }}
RELEASE_WORKFLOW: .github/workflows/release-candidate.yml
RELEASE_ACCEPTANCE_JSON: >-
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
run: |
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
node scripts/release-candidate.mjs seal payload record/record.json
node scripts/release-candidate.mjs verify payload record/record.json
- uses: actions/attest-build-provenance@v3
with:
subject-path: record/record.json
- uses: actions/upload-artifact@v7
with:
name: release-candidate-record-${{ needs.resolve.outputs.candidate_id }}
path: record/record.json
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Report publish command
run: |
{
echo "### Candidate ready"
echo
echo "\`${{ needs.resolve.outputs.candidate_id }}\`"
echo
echo '```sh'
echo "./scripts/release-stable.sh '${{ needs.resolve.outputs.candidate_id }}'"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
metrics:
name: record candidate timing
needs: [resolve, cli-npm, desktop, seal]
if: ${{ always() && !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- name: Record queue, execution, and critical-path evidence
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
node scripts/ci-timings.mjs \
--run "$RUNNER_TEMP/release-run.json" \
--jobs "$RUNNER_TEMP/release-jobs.json" \
--summary "$GITHUB_STEP_SUMMARY" \
--output "$RUNNER_TEMP/candidate-timing.json" \
--title "Release candidate timing"
- uses: actions/upload-artifact@v7
with:
name: release-candidate-timing-${{ needs.resolve.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/candidate-timing.json
if-no-files-found: ignore
overwrite: true
retention-days: 90
+371 -241
View File
@@ -39,8 +39,11 @@ on:
workflow_call:
outputs:
artifact_prefix:
description: "Same-run signed artifacts produced by this invocation"
description: "Signed artifacts produced by this candidate invocation"
value: ${{ jobs.resolve.outputs.artifact_prefix }}
signing_fingerprint:
description: "Validated Certum and packaging policy fingerprint"
value: ${{ jobs.signing-contract.outputs.fingerprint }}
inputs:
channel:
description: "Release channel selected by the approved orchestrator"
@@ -84,16 +87,51 @@ on:
required: false
default: false
type: boolean
candidate_preparation:
description: "Build, sign, and accept an untagged Stable candidate without publishing"
required: false
default: false
type: boolean
signing_preflight_verified:
description: "The approved stable caller completed signing_preflight in this run"
required: false
default: false
type: boolean
preflight_artifact_prefix:
description: "Signed artifact set returned by the successful same-run preflight"
description: "Signed artifact set returned by a verified candidate preparation"
required: false
default: ""
type: string
candidate_id:
description: "Sealed release candidate identity"
required: false
default: ""
type: string
candidate_source_run_id:
description: "Trusted candidate producer run"
required: false
default: ""
type: string
candidate_source_run_attempt:
description: "Trusted candidate producer attempt"
required: false
default: ""
type: string
candidate_control_sha:
description: "Control-plane SHA that built the sealed candidate"
required: false
default: ""
type: string
candidate_signing_fingerprint:
description: "Signing policy fingerprint sealed with the candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
desktop_manual_only:
description: "Approved manual Desktop distribution: unsigned Windows, no update pointer move"
required: false
@@ -162,6 +200,7 @@ jobs:
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_SHA: ${{ github.sha }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CANDIDATE_PREPARATION: ${{ inputs.candidate_preparation }}
run: bash scripts/resolve-desktop-candidate.sh
orchestration-guard:
@@ -187,12 +226,14 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
APPROVED_CHANNEL: ${{ (inputs.orchestrator == 'candidate' || inputs.orchestrator == 'promote') && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ "${{ inputs.candidate_preparation }}" != "true" ]; then
bash scripts/verify-release-tag.sh
fi
release-gate:
name: approve standalone desktop release
@@ -255,6 +296,10 @@ jobs:
echo "::error::production_signing_smoke and signing_preflight are mutually exclusive"
exit 1
fi
if [ "${{ inputs.candidate_preparation }}" = "true" ] && { [ "${{ inputs.signing_preflight }}" != "true" ] || [ "${{ inputs.orchestrator }}" != "candidate" ]; }; then
echo "::error::candidate preparation must use the protected candidate orchestrator and signing preflight"
exit 1
fi
if [ "${{ inputs.signing_preflight_verified }}" = "true" ] && [ "${{ inputs.orchestrated }}" != "true" ]; then
echo "::error::only the approved stable orchestrator can assert signing_preflight_verified"
exit 1
@@ -266,6 +311,13 @@ jobs:
fi
if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
test "$PREFLIGHT_ARTIFACT_PREFIX" = desktop-34816299501-1-preflight
elif [ "${{ inputs.candidate_verified }}" = "true" ]; then
[[ "${{ inputs.candidate_id }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]{12}-[0-9a-f]{12}$ ]] || exit 1
[[ "${{ inputs.candidate_source_run_id }}" =~ ^[1-9][0-9]*$ ]] || exit 1
[[ "${{ inputs.candidate_source_run_attempt }}" =~ ^[1-9][0-9]*$ ]] || exit 1
[[ "${{ inputs.candidate_control_sha }}" =~ ^[0-9a-f]{40}$ ]] || exit 1
test -n "${{ inputs.candidate_signing_fingerprint }}"
test "$PREFLIGHT_ARTIFACT_PREFIX" = "desktop-${{ inputs.candidate_source_run_id }}-${{ inputs.candidate_source_run_attempt }}-preflight"
else
[[ "$PREFLIGHT_ARTIFACT_PREFIX" =~ ^desktop-${GITHUB_RUN_ID}-[1-9][0-9]*-preflight$ ]] || exit 1
fi
@@ -278,6 +330,10 @@ jobs:
run: |
go run ./cmd/signpath-contract validate
fingerprint="$(go run ./cmd/signpath-contract fingerprint)"
if [ "${{ inputs.candidate_verified }}" = "true" ] && [ "$fingerprint" != "${{ inputs.candidate_signing_fingerprint }}" ]; then
echo "::error::sealed candidate signing policy no longer matches the protected publisher"
exit 1
fi
if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
node scripts/verify-manual-desktop-producer.mjs
fingerprint=v1:48c45e7bb52e5a9d0883b917c36e8cb0f4e7d34b6703ff44019d8ef5d52ebf21
@@ -297,42 +353,10 @@ jobs:
exit 1
fi
cache-guard:
name: cache hit guard
needs: [resolve, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Cache hit guard
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
build:
# Recovery reads this exact per-platform, per-invocation name in attempt history.
name: build (${{ matrix.name }}, ${{ inputs.signing_preflight && 'preflight' || 'release' }})
needs: [resolve, cache-guard, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.cache-guard.result == 'success' && inputs.preflight_artifact_prefix == '' && !inputs.reuse_manual_artifacts }}
name: build non-Windows (${{ matrix.name }}, ${{ inputs.signing_preflight && 'preflight' || 'release' }})
needs: [resolve, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' && inputs.preflight_artifact_prefix == '' && !inputs.reuse_manual_artifacts }}
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
@@ -345,12 +369,8 @@ jobs:
- { runner: macos-15, platform: darwin/arm64, name: darwin-arm64 }
- { runner: macos-15-intel, platform: darwin/amd64, name: darwin-amd64 }
- { runner: macos-15, platform: darwin/universal, name: darwin-universal }
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64, arch: amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64, arch: arm64 }
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
runs-on: ${{ matrix.runner }}
env:
HAS_CERTUM: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' && !inputs.desktop_manual_only }}
defaults:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
@@ -381,12 +401,6 @@ jobs:
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
# Windows: NSIS provides makensis for the installer passes in
# desktop-build.sh / package-windows-desktop.sh.
- name: Install NSIS
if: runner.os == 'Windows'
run: pwsh -NoProfile -File scripts/install-nsis.ps1
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
- name: Install create-dmg
if: runner.os == 'macOS'
@@ -423,16 +437,6 @@ jobs:
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
# Official automatic-update releases require Authenticode. The guarded
# manual-only exception retains minisign without advancing updates.
- name: Require Windows Authenticode signing
if: runner.os == 'Windows' && github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only
run: |
if [ "$HAS_CERTUM" != "true" ]; then
echo "::error::Certum credentials are required for public Windows Preview and Stable releases"
exit 1
fi
- name: Build and package
env:
# macOS Developer ID + notarization path turns on only when all five
@@ -462,27 +466,11 @@ jobs:
with:
ref: ${{ github.workflow_sha }}
path: release-control
sparse-checkout: |
scripts/verify-windows-authenticode.ps1
scripts/desktop-release-artifacts.mjs
scripts/signpath-checkpoint.mjs
scripts/test-windows-installer-startup.ps1
scripts/test-windows-startup-recovery.ps1
scripts/windows-upgrade-ui-evidence.ps1
scripts/windows-acceptance-environment.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
# Exercise the exact production Electron package built for Stable: launch
# the packaged shell and require the shell -> Go service handshake before
# signing or publication.
- name: Smoke-test packaged Electron startup
if: runner.os == 'Windows'
run: |
node desktop/packaging/smoke.mjs \
desktop/build/electron/${{ matrix.name }}/app \
--service desktop/build/bin/reasonix-desktop.exe
- name: Smoke-test native macOS archive startup
if: runner.os == 'macOS' && matrix.name != 'darwin-universal'
run: |
@@ -509,24 +497,6 @@ jobs:
run: |
xvfb-run -a node desktop/packaging/smoke.mjs desktop/build/bin/app
# Native runners smoke-test both architectures before handing the exact
# unsigned payload and generated NSIS inputs to the x64 signing runner.
- name: Archive Windows signing inputs
if: runner.os == 'Windows' && env.HAS_CERTUM == 'true'
run: |
signing_archive="$(cygpath -u "$RUNNER_TEMP")/windows-signing-inputs.tar"
tar -cf "$signing_archive" desktop/build/windows/signing-payload desktop/build/windows/installer/reasonix_project.nsh
- name: Upload Windows signing inputs
if: runner.os == 'Windows' && env.HAS_CERTUM == 'true'
uses: actions/upload-artifact@v7
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-${{ matrix.name }}
path: ${{ runner.temp }}/windows-signing-inputs.tar
overwrite: true
if-no-files-found: error
retention-days: 7
- name: Upload package size report
if: ${{ always() }}
uses: actions/upload-artifact@v7
@@ -547,48 +517,7 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Bind manual Windows payload and rebuild packages
if: runner.os == 'Windows' && inputs.desktop_manual_only
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
run: |
cd desktop
go run ./cmd/sign windows-payload build/windows/signing-payload "${{ needs.resolve.outputs.version }}"
go run ./cmd/sign sign build/windows/signing-payload/reasonix-payload.json
go run ./cmd/sign verify build/windows/signing-payload/reasonix-payload.json
cd ..
scripts/package-windows-desktop.sh "${{ matrix.platform == 'windows/arm64' && 'arm64' || 'amd64' }}" desktop/build/windows/signing-payload
# Manual distribution rebuilds the installer above. Test those final
# bytes before minisign and upload; signed releases also test again after
# Authenticode in windows-runtime-acceptance.
- name: Install and smoke-test Windows installer identity
if: runner.os == 'Windows'
shell: pwsh
run: >-
./release-control/scripts/test-windows-installer-startup.ps1
-InstallerPath "dist/Reasonix-windows-${{ matrix.arch }}-installer.exe"
-ExpectedVersion "${{ needs.resolve.outputs.version }}"
-EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
- name: Upload Windows installer acceptance evidence
if: always() && runner.os == 'Windows'
uses: actions/upload-artifact@v7
with:
name: windows-installer-acceptance-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-unsigned
path: |
${{ runner.temp }}/reasonix-installer-acceptance/**/*.json
${{ runner.temp }}/reasonix-installer-acceptance/**/*.png
${{ runner.temp }}/reasonix-installer-acceptance/**/*.log
!${{ runner.temp }}/reasonix-installer-acceptance/installed/**
!${{ runner.temp }}/reasonix-installer-acceptance/**/cache/**
if-no-files-found: ignore
retention-days: 30
- name: Sign artifacts (minisign)
if: runner.os != 'Windows' || env.HAS_CERTUM != 'true'
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
@@ -596,10 +525,9 @@ jobs:
run: go run ./cmd/sign sign ../dist/*
- name: Bind signed artifacts to candidate and workflow
if: runner.os != 'Windows' || env.HAS_CERTUM != 'true'
env:
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
RELEASE_CONTROL_SHA: ${{ inputs.candidate_control_sha || github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
@@ -608,7 +536,6 @@ jobs:
run: node release-control/scripts/desktop-release-artifacts.mjs pack dist "$RUNNER_TEMP/desktop-bundle" "${{ matrix.name }}"
- uses: actions/upload-artifact@v7
if: runner.os != 'Windows' || env.HAS_CERTUM != 'true'
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-${{ matrix.name }}
path: ${{ runner.temp }}/desktop-bundle
@@ -619,22 +546,156 @@ jobs:
# Same-run handoff to the publish job only; 7 days covers debugging.
retention-days: 7
windows-build:
name: build Windows candidate (${{ matrix.arch }})
needs: [resolve, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' && inputs.preflight_artifact_prefix == '' && !inputs.reuse_manual_artifacts }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64, arch: amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64, arch: arm64 }
runs-on: ${{ matrix.runner }}
env:
HAS_CERTUM: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' && !inputs.desktop_manual_only }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: Install NSIS
run: pwsh -NoProfile -File scripts/install-nsis.ps1
- name: Require Windows Authenticode signing
if: github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only
run: |
if [ "$HAS_CERTUM" != "true" ]; then
echo "::error::Certum credentials are required for public Windows releases"
exit 1
fi
- name: Build and package
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.channel }}"
- name: Checkout protected release verifier
uses: actions/checkout@v7
with:
ref: ${{ github.workflow_sha }}
path: release-control
persist-credentials: false
- name: Smoke-test packaged Electron startup
run: |
node desktop/packaging/smoke.mjs \
desktop/build/electron/${{ matrix.name }}/app \
--service desktop/build/bin/reasonix-desktop.exe
- name: Archive Windows signing inputs
if: env.HAS_CERTUM == 'true'
run: |
signing_archive="$(cygpath -u "$RUNNER_TEMP")/windows-signing-inputs.tar"
tar -cf "$signing_archive" desktop/build/windows/signing-payload desktop/build/windows/installer/reasonix_project.nsh
- name: Upload Windows signing inputs
if: env.HAS_CERTUM == 'true'
uses: actions/upload-artifact@v7
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-${{ matrix.name }}
path: ${{ runner.temp }}/windows-signing-inputs.tar
overwrite: true
if-no-files-found: error
retention-days: 7
- name: Finalize manual Windows package
if: inputs.desktop_manual_only
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
run: |
(
cd desktop
go run ./cmd/sign windows-payload build/windows/signing-payload "${{ needs.resolve.outputs.version }}"
go run ./cmd/sign sign build/windows/signing-payload/reasonix-payload.json
go run ./cmd/sign verify build/windows/signing-payload/reasonix-payload.json
)
scripts/package-windows-desktop.sh "${{ matrix.arch }}" desktop/build/windows/signing-payload
- name: Install and smoke-test final manual Windows installer
if: inputs.desktop_manual_only
shell: pwsh
run: >-
./release-control/scripts/test-windows-installer-startup.ps1
-InstallerPath "dist/Reasonix-windows-${{ matrix.arch }}-installer.exe"
-ExpectedVersion "${{ needs.resolve.outputs.version }}"
-EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
- name: Sign manual artifacts (minisign)
if: inputs.desktop_manual_only
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- name: Bind manual artifacts to candidate and workflow
if: inputs.desktop_manual_only
env:
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
run: node release-control/scripts/desktop-release-artifacts.mjs pack dist "$RUNNER_TEMP/desktop-bundle" "${{ matrix.name }}"
- uses: actions/upload-artifact@v7
if: inputs.desktop_manual_only
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-${{ matrix.name }}
path: ${{ runner.temp }}/desktop-bundle
overwrite: true
if-no-files-found: error
retention-days: 7
- name: Upload package size report
if: always()
uses: actions/upload-artifact@v7
with:
name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
path: desktop/build/reports/${{ matrix.name }}
if-no-files-found: error
retention-days: 30
- name: Upload desktop source maps
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-sourcemaps-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
path: |
desktop/build/sourcemaps/${{ matrix.name }}
desktop/frontend/sourcemaps
if-no-files-found: error
retention-days: 30
windows-sign:
name: sign Windows (${{ matrix.arch }})
needs: [resolve, build, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' && needs.signing-contract.result == 'success' && github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only }}
name: sign Windows candidate
needs: [resolve, windows-build, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.windows-build.result == 'success' && needs.signing-contract.result == 'success' && github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only }}
runs-on: windows-2022
timeout-minutes: 45
permissions:
contents: read
actions: read
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [amd64, arm64]
# Both architectures use the proven x64 virtual-card client. Native build
# and startup testing still happen on their original architecture runners.
# Both architectures use one proven x64 virtual-card session. Native build
# and final startup acceptance still run on their original architectures.
concurrency:
group: certum-signing
cancel-in-progress: false
@@ -658,90 +719,11 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: "24"
- uses: actions/download-artifact@v8
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-windows-${{ matrix.arch }}
path: ${{ runner.temp }}/signing-inputs
- name: Restore native-tested Windows payload
run: |
signing_archive="$(cygpath -u "$RUNNER_TEMP")/signing-inputs/windows-signing-inputs.tar"
tar -xf "$signing_archive"
mkdir -p desktop/build/bin
cp -R desktop/build/windows/signing-payload signed-payload
node desktop/packaging/signing-files.mjs signed-payload --check
- name: Install NSIS
shell: pwsh
run: ./release-control/scripts/install-nsis.ps1
- name: Connect to Certum
uses: ./release-control/.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
- name: Sign complete Windows payload
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: ./release-control/scripts/sign-certum.ps1 -PayloadDirectory signed-payload
- name: Bind signed Windows payload and rebuild packages
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
run: |
cd desktop
go run ./cmd/sign windows-payload ../signed-payload "${{ needs.resolve.outputs.version }}"
go run ./cmd/sign sign ../signed-payload/reasonix-payload.json
go run ./cmd/sign verify ../signed-payload/reasonix-payload.json
cd ..
scripts/package-windows-desktop.sh "${{ matrix.arch }}" signed-payload
- name: Sign rebuilt Windows installer
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: ./release-control/scripts/sign-certum.ps1 -FilePath "dist/Reasonix-windows-${{ matrix.arch }}-installer.exe"
- name: Verify Windows Authenticode release contract
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: |
# Read the immutable candidate's declared layout, never infer it from
# files that happen to survive in the archive. Older candidates
# predate this declaration and use the historical dual-entry layout.
$portableLayout = "legacy-dual"
if (Test-Path -LiteralPath "desktop/packaging/windows-portable-layout.txt") {
$portableLayout = (Get-Content -LiteralPath "desktop/packaging/windows-portable-layout.txt" -Raw).Trim()
}
./release-control/scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/Reasonix-windows-${{ matrix.arch }}-installer.exe" `
-PortableArchivePath "dist/Reasonix-windows-${{ matrix.arch }}.zip" `
-ExpectedThumbprint $env:CERTUM_KEY_ID -RequireTrusted -PortableLayout $portableLayout
- name: Refresh signed package size report
env:
REASONIX_COMMIT: ${{ needs.resolve.outputs.sha }}
run: >-
node desktop/packaging/size-report.mjs
--platform "windows/${{ matrix.arch }}"
--version "${{ needs.resolve.outputs.version }}"
--bundle signed-payload --dist dist
--output "desktop/build/reports/windows-${{ matrix.arch }}"
- name: Upload signed package size report
uses: actions/upload-artifact@v7
with:
name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-windows-${{ matrix.arch }}
path: desktop/build/reports/windows-${{ matrix.arch }}
overwrite: true
if-no-files-found: error
retention-days: 30
- name: Sign artifacts (minisign)
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- name: Bind signed artifacts to candidate and workflow
- name: Reuse completed signed architectures from an earlier attempt
id: reuse
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
@@ -749,14 +731,132 @@ jobs:
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
run: node release-control/scripts/desktop-release-artifacts.mjs pack dist "$RUNNER_TEMP/desktop-bundle" "windows-${{ matrix.arch }}"
- uses: actions/upload-artifact@v7
RELEASE_RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
mkdir -p "$runner_temp/completed"
missing=()
for arch in amd64 arm64; do
name="${{ needs.resolve.outputs.artifact_prefix }}-windows-$arch"
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
--jq ".artifacts | map(select(.name == \"$name\" and .expired == false)) | sort_by(.id) | last | .id // empty")"
if [ -z "$artifact_id" ]; then
echo "$arch=false" >> "$GITHUB_OUTPUT"
missing+=("$arch")
continue
fi
mkdir -p "$runner_temp/completed/$arch"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$runner_temp/completed/$arch.zip"
unzip -q "$runner_temp/completed/$arch.zip" -d "$runner_temp/completed/$arch"
node release-control/scripts/desktop-release-artifacts.mjs verify \
"$runner_temp/completed/$arch" "windows-$arch"
echo "$arch=true" >> "$GITHUB_OUTPUT"
echo "Reusing signed Windows $arch bundle from artifact $artifact_id." >> "$GITHUB_STEP_SUMMARY"
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "needs_signing=false" >> "$GITHUB_OUTPUT"
else
echo "needs_signing=true" >> "$GITHUB_OUTPUT"
printf 'Architectures requiring signing: %s\n' "${missing[*]}" >> "$GITHUB_STEP_SUMMARY"
fi
- uses: actions/download-artifact@v8
if: steps.reuse.outputs.needs_signing == 'true'
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-${{ matrix.arch }}
path: ${{ runner.temp }}/desktop-bundle
name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-windows-amd64
path: ${{ runner.temp }}/signing-inputs/amd64
- uses: actions/download-artifact@v8
if: steps.reuse.outputs.needs_signing == 'true'
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-windows-arm64
path: ${{ runner.temp }}/signing-inputs/arm64
- name: Restore both native-tested Windows payloads
if: steps.reuse.outputs.needs_signing == 'true'
env:
RELEASE_RUNNER_TEMP: ${{ runner.temp }}
run: |
runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
for arch in amd64 arm64; do
[ ! -d "$runner_temp/completed/$arch" ] || continue
mkdir -p "$runner_temp/signing-work/$arch"
tar -xf "$runner_temp/signing-inputs/$arch/windows-signing-inputs.tar" -C "$runner_temp/signing-work/$arch"
cp -R "$runner_temp/signing-work/$arch/desktop/build/windows/signing-payload" "signed-payload-$arch"
node desktop/packaging/signing-files.mjs "signed-payload-$arch" --check
done
- name: Install NSIS
if: steps.reuse.outputs.needs_signing == 'true'
shell: pwsh
run: ./release-control/scripts/install-nsis.ps1
- name: Connect to Certum
if: steps.reuse.outputs.needs_signing == 'true'
uses: ./release-control/.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
- name: Finalize amd64 in the shared Certum session
if: steps.reuse.outputs.amd64 != 'true'
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
RELEASE_RUNNER_TEMP: ${{ runner.temp }}
run: |
runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
bash release-control/scripts/finalize-windows-signed-candidate.sh amd64 \
"$runner_temp/signing-work/amd64" signed-payload-amd64 dist-amd64 \
"$runner_temp/desktop-bundle-amd64" "${{ needs.resolve.outputs.version }}"
- uses: actions/upload-artifact@v7
if: steps.reuse.outputs.amd64 != 'true'
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-amd64
path: ${{ runner.temp }}/desktop-bundle-amd64
overwrite: true
if-no-files-found: error
retention-days: 7
- name: Finalize arm64 in the shared Certum session
if: steps.reuse.outputs.arm64 != 'true'
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
RELEASE_RUNNER_TEMP: ${{ runner.temp }}
run: |
runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
bash release-control/scripts/finalize-windows-signed-candidate.sh arm64 \
"$runner_temp/signing-work/arm64" signed-payload-arm64 dist-arm64 \
"$runner_temp/desktop-bundle-arm64" "${{ needs.resolve.outputs.version }}"
- uses: actions/upload-artifact@v7
if: steps.reuse.outputs.arm64 != 'true'
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-arm64
path: ${{ runner.temp }}/desktop-bundle-arm64
overwrite: true
if-no-files-found: error
retention-days: 7
- name: Upload signed package size reports
if: steps.reuse.outputs.needs_signing == 'true'
uses: actions/upload-artifact@v7
with:
name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-windows-signed
path: desktop/build/reports/windows-*
overwrite: true
if-no-files-found: error
retention-days: 30
windows-runtime-acceptance:
name: verify signed Windows installer (${{ matrix.arch }})
@@ -804,11 +904,26 @@ jobs:
!${{ runner.temp }}/reasonix-installer-acceptance/**/cache/**
if-no-files-found: ignore
retention-days: 90
- name: Record signed Windows acceptance receipt
shell: pwsh
run: |
$bundle = "$env:RUNNER_TEMP/windows-bundle/files"
$installer = @(Get-ChildItem -LiteralPath $bundle -Filter '*installer.exe' -File)
if ($installer.Count -ne 1) { throw "Expected one signed installer, found $($installer.Count)" }
@{ schema = 1; kind = "windows-${{ matrix.arch }}"; status = 'passed'; version = "${{ needs.resolve.outputs.version }}"; sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer[0].FullName).Hash.ToLowerInvariant() } |
ConvertTo-Json | Set-Content -LiteralPath "$env:RUNNER_TEMP/windows-${{ matrix.arch }}.json" -Encoding utf8
- uses: actions/upload-artifact@v7
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-receipt-windows-${{ matrix.arch }}
path: ${{ runner.temp }}/windows-${{ matrix.arch }}.json
overwrite: true
if-no-files-found: error
retention-days: 90
mac-universal-intel:
name: verify Universal candidate on Intel
needs: [resolve, build, signing-contract, cache-guard]
if: ${{ always() && !cancelled() && needs.signing-contract.result == 'success' && needs.cache-guard.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.reuse_manual_artifacts)) && inputs.preflight_artifact_prefix == '' }}
needs: [resolve, build, signing-contract]
if: ${{ always() && !cancelled() && needs.signing-contract.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.reuse_manual_artifacts)) && inputs.preflight_artifact_prefix == '' }}
runs-on: macos-15-intel
permissions:
contents: read
@@ -845,11 +960,24 @@ jobs:
codesign --verify --deep --strict "$app_dir"
node desktop/packaging/verify.mjs "$app_dir" --kind darwin-app-dir
node desktop/packaging/smoke.mjs "$app_dir"
- name: Record Intel macOS acceptance receipt
run: |
jq -n --arg version "${{ needs.resolve.outputs.version }}" \
--arg sha256 "$(shasum -a 256 "$RUNNER_TEMP/universal-bundle/files/Reasonix-darwin-universal.dmg" | awk '{print $1}')" \
'{schema: 1, kind: "macos-universal-intel", status: "passed", version: $version, sha256: $sha256}' \
> "$RUNNER_TEMP/macos-universal-intel.json"
- uses: actions/upload-artifact@v7
with:
name: ${{ needs.resolve.outputs.artifact_prefix }}-receipt-macos-universal-intel
path: ${{ runner.temp }}/macos-universal-intel.json
overwrite: true
if-no-files-found: error
retention-days: 90
publish:
name: publish release
needs: [resolve, signing-contract, cache-guard, build, windows-sign, windows-runtime-acceptance, mac-universal-intel]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified)) && ((inputs.preflight_artifact_prefix == '' && needs.mac-universal-intel.result == 'success') || (inputs.preflight_artifact_prefix != '' && needs.mac-universal-intel.result == 'skipped')) && needs.signing-contract.result == 'success' && needs.cache-guard.result == 'success' && (needs.windows-sign.result == 'success' || (needs.windows-sign.result == 'skipped' && (inputs.desktop_manual_only || inputs.preflight_artifact_prefix != ''))) && (needs.windows-sign.result != 'success' || needs.windows-runtime-acceptance.result == 'success') && !inputs.production_signing_smoke && !inputs.signing_preflight }}
needs: [resolve, signing-contract, build, windows-build, windows-sign, windows-runtime-acceptance, mac-universal-intel]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified)) && (needs.windows-build.result == 'success' || (needs.windows-build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified)) && ((inputs.preflight_artifact_prefix == '' && needs.mac-universal-intel.result == 'success') || (inputs.preflight_artifact_prefix != '' && needs.mac-universal-intel.result == 'skipped')) && needs.signing-contract.result == 'success' && (needs.windows-sign.result == 'success' || (needs.windows-sign.result == 'skipped' && (inputs.desktop_manual_only || inputs.preflight_artifact_prefix != ''))) && (needs.windows-sign.result != 'success' || needs.windows-runtime-acceptance.result == 'success') && !inputs.production_signing_smoke && !inputs.signing_preflight }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -909,12 +1037,14 @@ jobs:
- name: Verify complete signed artifact handoff
env:
RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
RELEASE_CONTROL_SHA: ${{ inputs.reuse_manual_artifacts && '09cdab3866d77c6ff0d007ee61b6aca3128ebe54' || github.workflow_sha }}
RELEASE_CONTROL_SHA: ${{ inputs.reuse_manual_artifacts && '09cdab3866d77c6ff0d007ee61b6aca3128ebe54' || inputs.candidate_control_sha || github.workflow_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
RELEASE_ARTIFACT_PREFIX: ${{ inputs.preflight_artifact_prefix || needs.resolve.outputs.artifact_prefix }}
RELEASE_PRODUCER_RUN_ID: ${{ inputs.candidate_source_run_id || github.run_id }}
RELEASE_PRODUCER_RUN_ATTEMPT: ${{ inputs.candidate_source_run_attempt || github.run_attempt }}
run: |
if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
GITHUB_RUN_ID=34816299501 GITHUB_RUN_ATTEMPT=1 node release-control/scripts/desktop-release-artifacts.mjs collect "$RUNNER_TEMP/desktop-bundles" dist
@@ -1020,8 +1150,8 @@ jobs:
attest-signing-contract:
name: record standalone Windows signing attestation
needs: [signing-contract, build, windows-sign, windows-runtime-acceptance]
if: ${{ always() && !cancelled() && inputs.signing_preflight && !inputs.orchestrated && github.repository == 'esengine/DeepSeek-Reasonix' && needs.signing-contract.result == 'success' && needs.build.result == 'success' && needs.windows-sign.result == 'success' && needs.windows-runtime-acceptance.result == 'success' }}
needs: [signing-contract, build, windows-build, windows-sign, windows-runtime-acceptance]
if: ${{ always() && !cancelled() && inputs.signing_preflight && !inputs.orchestrated && github.repository == 'esengine/DeepSeek-Reasonix' && needs.signing-contract.result == 'success' && needs.build.result == 'success' && needs.windows-build.result == 'success' && needs.windows-sign.result == 'success' && needs.windows-runtime-acceptance.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
+35 -2
View File
@@ -61,6 +61,16 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: "Same-run artifact containing a verified sealed release candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
permissions:
contents: read
@@ -94,12 +104,16 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then
echo "::error::prepared npm packages require verified candidate provenance"
exit 1
fi
release-gate:
name: approve standalone npm release
@@ -121,11 +135,14 @@ jobs:
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v7
if: ${{ !inputs.candidate_verified }}
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- if: ${{ !inputs.candidate_verified }}
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
if: ${{ !inputs.candidate_verified }}
env:
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
@@ -162,8 +179,10 @@ jobs:
git fetch --no-tags --depth=1 origin "$RECOVERY_CONTROL_SHA"
git restore --source="$RECOVERY_CONTROL_SHA" -- \
npm/publish.mjs \
npm/publish-candidate.mjs \
scripts/finalize-npm-official-release.mjs
- uses: actions/setup-go@v7
if: ${{ inputs.candidate_artifact_name == '' }}
with:
go-version-file: go.mod
cache: true
@@ -171,6 +190,12 @@ jobs:
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Download sealed release candidate
if: ${{ inputs.candidate_artifact_name != '' }}
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/release-candidate
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
@@ -193,10 +218,18 @@ jobs:
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish or recover immutable npm packages
if: ${{ inputs.candidate_artifact_name == '' }}
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Publish or recover prepared npm packages
if: ${{ inputs.candidate_artifact_name != '' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
+451
View File
@@ -0,0 +1,451 @@
name: Publish release candidate
run-name: ${{ inputs.operation }} ${{ inputs.candidate_id }}
on:
workflow_dispatch:
inputs:
candidate_id:
description: "Sealed candidate from Prepare release candidate"
required: true
type: string
operation:
description: "Publish a new identity or recover an interrupted publication"
required: true
default: publish
type: choice
options: [publish, recover]
concurrency:
group: stable-release-publication
cancel-in-progress: false
permissions:
actions: write
attestations: read
contents: write
jobs:
preflight:
name: verify sealed candidate before approval
runs-on: ubuntu-latest
permissions:
actions: read
attestations: read
contents: read
outputs:
candidate_id: ${{ steps.record.outputs.candidate_id }}
version: ${{ steps.record.outputs.version }}
source_sha: ${{ steps.record.outputs.source_sha }}
producer_run_id: ${{ steps.record.outputs.producer_run_id }}
producer_run_attempt: ${{ steps.record.outputs.producer_run_attempt }}
candidate_control_sha: ${{ steps.record.outputs.candidate_control_sha }}
signing_fingerprint: ${{ steps.record.outputs.signing_fingerprint }}
desktop_prefix: ${{ steps.record.outputs.desktop_prefix }}
approved_artifact: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Resolve candidate record artifact
id: artifact
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
run: node scripts/resolve-release-candidate.mjs resolve "${{ inputs.candidate_id }}"
- name: Download exact candidate record
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_ID: ${{ steps.artifact.outputs.record_artifact_id }}
PRODUCER_RUN_ID: ${{ steps.artifact.outputs.producer_run_id }}
run: |
mkdir -p record
gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" > /tmp/record-artifact.json
gh api "repos/${{ github.repository }}/actions/runs/$PRODUCER_RUN_ID" > /tmp/producer-run.json
gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID/zip" > /tmp/record.zip
unzip -q /tmp/record.zip -d record
test -s record/record.json
- name: Verify record provenance and resolve exact payload
id: record
env:
GH_TOKEN: ${{ github.token }}
run: |
signer_sha="$(jq -r .head_sha /tmp/producer-run.json)"
gh attestation verify record/record.json --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
node scripts/resolve-release-candidate.mjs inspect "${{ inputs.candidate_id }}" \
record/record.json /tmp/record-artifact.json /tmp/producer-run.json
- name: Download exact candidate payload
env:
GH_TOKEN: ${{ github.token }}
PAYLOAD_ID: ${{ steps.record.outputs.payload_artifact_id }}
run: |
gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID" > /tmp/payload-artifact.json
test "$(jq -r .name /tmp/payload-artifact.json)" = "${{ steps.record.outputs.payload_artifact_name }}"
test "$(jq -r .expired /tmp/payload-artifact.json)" = false
test "$(jq -r .workflow_run.id /tmp/payload-artifact.json)" = "${{ steps.record.outputs.producer_run_id }}"
mkdir -p payload
gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID/zip" > /tmp/payload.zip
unzip -q /tmp/payload.zip -d payload
- name: Verify payload provenance, bytes, source, and operation
env:
GH_TOKEN: ${{ github.token }}
OPERATION: ${{ inputs.operation }}
VERSION: ${{ steps.record.outputs.version }}
SOURCE_SHA: ${{ steps.record.outputs.source_sha }}
run: |
set -euo pipefail
while IFS= read -r -d '' file; do
gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "${{ steps.record.outputs.candidate_control_sha }}" \
--source-ref refs/heads/main-v2 --deny-self-hosted-runners >/dev/null
done < <(find payload -type f -print0)
node scripts/release-candidate.mjs verify payload record/record.json >/dev/null
git fetch origin main-v2 --tags
git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2
git show "$SOURCE_SHA:release-notes/releases.json" > /tmp/catalog.json
test "$(sha256sum /tmp/catalog.json | awk '{print $1}')" = "$(jq -r .notes.catalogSha256 record/record.json)"
tags=("v$VERSION" "npm-v$VERSION" "desktop-v$VERSION")
if [ "$OPERATION" = publish ]; then
for tag in "${tags[@]}"; do
! git show-ref --verify --quiet "refs/tags/$tag" || { echo "::error::release tag already exists: $tag"; exit 1; }
done
else
for tag in "${tags[@]}"; do
test "$(git rev-parse "$tag^{commit}")" = "$SOURCE_SHA" || { echo "::error::$tag does not match sealed candidate"; exit 1; }
done
fi
{
echo "### Sealed release approval"
echo
echo "- Candidate: \`${{ inputs.candidate_id }}\`"
echo "- Operation: \`$OPERATION\`"
echo "- Product SHA: \`$SOURCE_SHA\`"
echo "- Candidate record SHA-256: \`$(sha256sum record/record.json | awk '{print $1}')\`"
echo "- Notes SHA-256: \`$(jq -r .notes.renderedSha256 record/record.json)\`"
echo "- Signing policy: \`$(jq -r .signing.desktopFingerprint record/record.json)\`"
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render and bind reviewed release notes
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
- uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: release-notes.md
if-no-files-found: error
retention-days: 1
- uses: actions/upload-artifact@v7
with:
name: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: payload
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-arm64
path: payload/desktop/darwin-arm64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-amd64
path: payload/desktop/darwin-amd64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-darwin-universal
path: payload/desktop/darwin-universal
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-windows-amd64
path: payload/desktop/windows-amd64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-windows-arm64
path: payload/desktop/windows-arm64
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.record.outputs.desktop_prefix }}-linux-amd64
path: payload/desktop/linux-amd64
if-no-files-found: error
retention-days: 7
authorize:
name: approve sealed Stable candidate
needs: preflight
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
outputs:
version: ${{ needs.preflight.outputs.version }}
source_sha: ${{ needs.preflight.outputs.source_sha }}
steps:
- run: echo "Approved ${{ needs.preflight.outputs.candidate_id }} at ${{ needs.preflight.outputs.source_sha }}"
activate:
name: atomically activate release identity
needs: [preflight, authorize]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Create or verify all implementation tags
env:
OPERATION: ${{ inputs.operation }}
VERSION: ${{ needs.preflight.outputs.version }}
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
run: |
set -euo pipefail
tags=("v$VERSION" "npm-v$VERSION" "desktop-v$VERSION")
git fetch origin main-v2 --tags
if [ "$OPERATION" = publish ]; then
git push --atomic origin \
"$SOURCE_SHA:refs/tags/${tags[0]}" \
"$SOURCE_SHA:refs/tags/${tags[1]}" \
"$SOURCE_SHA:refs/tags/${tags[2]}"
fi
for tag in "${tags[@]}"; do
test "$(git ls-remote --tags --refs origin "refs/tags/$tag" | awk 'NR == 1 {print $1}')" = "$SOURCE_SHA"
done
cli:
name: publish CLI and Homebrew from candidate
needs: [preflight, activate]
uses: ./.github/workflows/release.yml
with:
tag: v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
candidate_verified: true
secrets: inherit
npm:
name: publish npm from candidate
needs: [preflight, activate]
permissions:
contents: read
id-token: write
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
base_version: ${{ needs.preflight.outputs.version }}
tag: npm-v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
candidate_verified: true
secrets: inherit
desktop:
name: publish Desktop from candidate
needs: [preflight, activate]
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: desktop-v${{ needs.preflight.outputs.version }}
approved_cli_tag: v${{ needs.preflight.outputs.version }}
approved_sha: ${{ needs.preflight.outputs.source_sha }}
orchestrated: true
orchestrator: promote
signing_preflight_verified: true
preflight_artifact_prefix: ${{ needs.preflight.outputs.desktop_prefix }}
candidate_id: ${{ needs.preflight.outputs.candidate_id }}
candidate_source_run_id: ${{ needs.preflight.outputs.producer_run_id }}
candidate_source_run_attempt: ${{ needs.preflight.outputs.producer_run_attempt }}
candidate_control_sha: ${{ needs.preflight.outputs.candidate_control_sha }}
candidate_signing_fingerprint: ${{ needs.preflight.outputs.signing_fingerprint }}
candidate_verified: true
secrets: inherit
postflight:
name: verify every public Stable surface
needs: [preflight, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Verify public artifacts
id: immutable
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger-core.json
CLI_TAG: v${{ needs.preflight.outputs.version }}
DESKTOP_TAG: desktop-v${{ needs.preflight.outputs.version }}
run: bash scripts/verify-stable-release-artifacts.sh
- name: Publish exact Stable release record
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.preflight.outputs.version }}
CLI_TAG: v${{ needs.preflight.outputs.version }}
RELEASE_SHA: ${{ needs.preflight.outputs.source_sha }}
run: |
node scripts/release-event.mjs generate --version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
if ! gh release download "$CLI_TAG" --pattern release-event.json --output /tmp/existing-release-event.json 2>/dev/null; then
gh release upload "$CLI_TAG" /tmp/release-event.json
else
cmp -s /tmp/release-event.json /tmp/existing-release-event.json
fi
- name: Decide whether this candidate owns the current public site
id: site
env:
OPERATION: ${{ inputs.operation }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
current="$(curl -fsSL https://dl.reasonix.io/latest/latest.json | jq -r .version)"
if [ "$current" = "v$VERSION" ]; then
echo "update=true" >> "$GITHUB_OUTPUT"
elif [ "$OPERATION" = recover ]; then
echo "update=false" >> "$GITHUB_OUTPUT"
echo "Stable pointers remain on $current; website deployment is preserved." >> "$GITHUB_STEP_SUMMARY"
else
echo "::error::Stable manifest serves $current after publishing v$VERSION"
exit 1
fi
- name: Dispatch and wait for the owned Pages deployment
id: pages
if: steps.site.outputs.update == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
baseline="$(gh run list --workflow pages.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0')"
gh workflow run pages.yml --ref main-v2 -f "release_version=$VERSION"
pages_run=""
for _ in $(seq 1 30); do
pages_run="$(gh run list --workflow pages.yml --event workflow_dispatch --branch main-v2 --limit 20 \
--json databaseId,displayTitle \
--jq "map(select(.databaseId > $baseline and .displayTitle == \"Deploy site v$VERSION\")) | sort_by(.databaseId) | last | .databaseId // empty")"
[ -z "$pages_run" ] || break
sleep 2
done
if [ -z "$pages_run" ]; then
echo "::error::public-sync-pending: the owned Pages deployment was not created"
exit 1
fi
echo "Waiting for Pages run $pages_run." >> "$GITHUB_STEP_SUMMARY"
gh run watch "$pages_run" --exit-status
- name: Verify hydrated-site inputs and public download routes
id: public_site
if: steps.site.outputs.update == 'true'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
CLI_TAG: v${{ needs.preflight.outputs.version }}
DESKTOP_TAG: desktop-v${{ needs.preflight.outputs.version }}
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger-site.json
VERIFY_HOMEPAGE: "true"
VERIFY_PUBLIC_SITE_ONLY: "true"
run: bash scripts/verify-stable-release-artifacts.sh
- name: Finalize observed publication ledger
if: ${{ always() && steps.immutable.outcome == 'success' }}
env:
UPDATE_SITE: ${{ steps.site.outputs.update }}
run: |
if [ -s /tmp/publication-ledger-site.json ]; then
node scripts/release-publication-ledger.mjs merge \
/tmp/publication-ledger-core.json /tmp/publication-ledger-site.json \
/tmp/publication-ledger.json
state=complete
elif [ "$UPDATE_SITE" = false ]; then
cp /tmp/publication-ledger-core.json /tmp/publication-ledger.json
state=immutable-complete-newer-pointer-preserved
else
cp /tmp/publication-ledger-core.json /tmp/publication-ledger.json
state=public-sync-pending
fi
jq --arg state "$state" '. + {completionState: $state}' \
/tmp/publication-ledger.json > /tmp/publication-ledger.final.json
mv /tmp/publication-ledger.final.json /tmp/publication-ledger.json
- name: Upload publication ledger
if: ${{ always() && steps.immutable.outcome == 'success' }}
uses: actions/upload-artifact@v7
with:
name: release-publication-ledger-${{ needs.preflight.outputs.candidate_id }}-${{ github.run_attempt }}
path: /tmp/publication-ledger.json
if-no-files-found: error
overwrite: true
retention-days: 90
metrics:
name: record publication timing
needs: [preflight, authorize, activate, cli, npm, desktop, postflight]
if: ${{ always() && !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- name: Record queue, execution, and critical-path evidence
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
node scripts/ci-timings.mjs \
--run "$RUNNER_TEMP/release-run.json" \
--jobs "$RUNNER_TEMP/release-jobs.json" \
--summary "$GITHUB_STEP_SUMMARY" \
--output "$RUNNER_TEMP/publication-timing.json" \
--title "Release publication timing"
- uses: actions/upload-artifact@v7
with:
name: release-publication-timing-${{ needs.preflight.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-timing.json
if-no-files-found: ignore
overwrite: true
retention-days: 90
@@ -1,42 +0,0 @@
name: Relay stable release
run-name: Relay stable release ${{ github.ref_name }}
# The tag event itself has a tag-shaped origin. Relay the immutable tag name to
# release-stable.yml on protected main-v2 so the production SignPath policy can
# allow exactly one branch name without trusting wildcard tag-like branches.
on:
push:
tags:
- 'v*'
- '!v*-*'
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected stable control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-stable.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-stable.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
tag: process.env.RELEASE_TAG,
allow_recovery: 'false',
},
});
+5 -8
View File
@@ -1,12 +1,9 @@
name: Release stable
run-name: Release stable ${{ inputs.tag || github.ref_name }}
name: Legacy release recovery
run-name: Legacy recovery ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
# approval releases every surface. After approval, a zero-publication Windows
# preflight verifies both architectures and signing stages before publication.
# Compatibility recovery for releases created before sealed candidates. New
# releases use release-candidate.yml followed by release-promote.yml. This path
# retains one GitHub environment gate and verifies all three existing tags.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
+35
View File
@@ -0,0 +1,35 @@
name: Verify release
run-name: Verify v${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix"
required: true
type: string
permissions:
contents: read
jobs:
verify:
name: verify immutable files and public pointers
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Verify public release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
VERIFY_HOMEPAGE: "true"
run: bash scripts/verify-stable-release-artifacts.sh
+58 -3
View File
@@ -51,6 +51,16 @@ on:
required: false
default: false
type: boolean
candidate_artifact_name:
description: "Same-run artifact containing a verified sealed release candidate"
required: false
default: ""
type: string
candidate_verified:
description: "Protected Stable preflight verified candidate provenance and bytes"
required: false
default: false
type: boolean
permissions:
contents: write # create the release and upload archives
@@ -146,12 +156,16 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
APPROVED_CHANNEL: ${{ inputs.orchestrator == 'promote' && 'stable' || inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
if [ -n "${{ inputs.candidate_artifact_name }}" ] && [ "${{ inputs.candidate_verified }}" != "true" ]; then
echo "::error::prepared CLI artifacts require verified candidate provenance"
exit 1
fi
release-gate:
name: approve standalone CLI release
@@ -175,11 +189,14 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
if: ${{ !inputs.candidate_verified }}
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- if: ${{ !inputs.candidate_verified }}
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
if: ${{ !inputs.candidate_verified }}
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
@@ -232,6 +249,16 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Download sealed release candidate
if: ${{ inputs.candidate_artifact_name != '' }}
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/release-candidate
- name: Verify prepared CLI checksums
if: ${{ inputs.candidate_artifact_name != '' }}
working-directory: ${{ runner.temp }}/release-candidate/cli
run: sha256sum -c SHA256SUMS
- name: Download orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
uses: actions/download-artifact@v8
@@ -278,6 +305,12 @@ jobs:
"$validation_channel" "$TAG" "${{ github.repository }}" \
"$release_json" "$checksums"
)"
if [ -n "${{ inputs.candidate_artifact_name }}" ]; then
cmp -s "$checksums" "$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" || {
echo "::error::existing CLI release differs from the sealed candidate"
exit 1
}
fi
echo "existing CLI release $TAG is complete and checksum-bound; reusing it"
elif grep -Eiq 'HTTP 404|Not Found' "$release_error"; then
decision="$(
@@ -292,7 +325,7 @@ jobs:
test "$decision" = "publish" -o "$decision" = "reuse"
echo "decision=$decision" >> "$GITHUB_OUTPUT"
- uses: goreleaser/goreleaser-action@v7
if: ${{ steps.publication.outputs.decision == 'publish' }}
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
with:
version: '~> v2'
args: release --clean
@@ -304,6 +337,28 @@ jobs:
# avoid ambiguity from the three release tags sharing one commit.
GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
- name: Publish prepared CLI archives
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != '' }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
gh release create "$TAG" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-amd64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-darwin-arm64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-linux-amd64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-linux-arm64.tar.gz" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-windows-amd64.zip" \
"$RUNNER_TEMP/release-candidate/cli/reasonix-windows-arm64.zip" \
"$RUNNER_TEMP/release-candidate/cli/SHA256SUMS" \
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
- name: Publish prepared Homebrew cask
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
+6 -1
View File
@@ -6,6 +6,8 @@ allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/certum-signing-smoke.yml
- .github/workflows/release-candidate.yml
- .github/workflows/release-promote.yml
- .github/workflows/release-stable.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
@@ -13,9 +15,12 @@ fingerprint_files:
- .github/actions/setup-certum/action.yml
- scripts/sign-certum.ps1
- scripts/install-nsis.ps1
- .github/workflows/release-candidate.yml
- .github/workflows/release-promote.yml
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-desktop.yml
- scripts/finalize-windows-signed-candidate.sh
- scripts/validate-release-control-plane.sh
- scripts/package-windows-desktop.sh
- scripts/verify-windows-authenticode.ps1
- cmd/signpath-contract/main.go
+2
View File
@@ -28,7 +28,9 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
}
want := []string{
".github/workflows/certum-signing-smoke.yml",
".github/workflows/release-candidate.yml",
".github/workflows/release-desktop.yml",
".github/workflows/release-promote.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
@@ -34,7 +34,11 @@ try {
throw error;
}
const report = await page.locator(".performance-report__body").innerText();
assert.match(report, /samples: .*workload\.js/);
// CDP sampling may legitimately return no user frames on a short profile.
// A captured result proves the archived analysis worker completed; frame
// filtering itself is covered deterministically by profileAnalysis.test.ts.
assert.match(report, /process samples: Electron only/);
assert.doesNotMatch(report, new RegExp(temp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.debugger.isAttached()), false);
await page.evaluate(() => Object.defineProperty(navigator, "clipboard", { value: { writeText: async (text) => { window.fixtureCopiedText = text; } }, configurable: true }));
await copy.click();
+36 -37
View File
@@ -60,18 +60,17 @@ func parseSignPathConfiguration(t *testing.T, name string) signPathArtifactConfi
func TestWindowsReleaseSignsPayloadBeforeRepackaging(t *testing.T) {
workflow := readTestFile(t, "../.github/workflows/release-desktop.yml")
finalizer := readTestFile(t, "../scripts/finalize-windows-signed-candidate.sh")
orderedSteps := []string{
"name: Build and package",
"name: Checkout protected release verifier",
"name: Smoke-test packaged Electron startup",
"name: Upload Windows signing inputs",
"name: Restore native-tested Windows payload",
"name: Restore both native-tested Windows payloads",
"name: Connect to Certum",
"name: Sign complete Windows payload",
"name: Bind signed Windows payload and rebuild packages",
"name: Sign rebuilt Windows installer",
"name: Verify Windows Authenticode release contract",
"name: Sign artifacts (minisign)",
"name: Finalize amd64 in the shared Certum session",
"name: Finalize arm64 in the shared Certum session",
"name: Upload signed package size reports",
}
last := -1
for _, step := range orderedSteps {
@@ -87,26 +86,37 @@ func TestWindowsReleaseSignsPayloadBeforeRepackaging(t *testing.T) {
}
for _, want := range []string{
`uses: ./release-control/.github/actions/setup-certum`,
`-ExpectedThumbprint $env:CERTUM_KEY_ID -RequireTrusted`,
`github.repository == 'esengine/DeepSeek-Reasonix'`,
`Certum credentials are required for public Windows Preview and Stable releases`,
`Certum credentials are required for public Windows releases`,
`SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match the current protected signing contract`,
`(needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified))`,
`needs.windows-sign.result == 'success'`,
`go run ./cmd/signpath-contract fingerprint`,
`go run ./cmd/sign windows-payload ../signed-payload "${{ needs.resolve.outputs.version }}"`,
`go run ./cmd/sign sign ../signed-payload/reasonix-payload.json`,
`go run ./cmd/sign verify ../signed-payload/reasonix-payload.json`,
`REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"`,
`ref: ${{ github.workflow_sha }}`,
`path: release-control`,
`node desktop/packaging/smoke.mjs`,
`./release-control/scripts/verify-windows-authenticode.ps1`,
`finalize-windows-signed-candidate.sh amd64`,
`finalize-windows-signed-candidate.sh arm64`,
} {
if !strings.Contains(workflow, want) {
t.Errorf("desktop release workflow is missing signing contract %q", want)
}
}
for _, want := range []string{
`sign-certum.ps1" -PayloadDirectory`,
`go run ./cmd/sign windows-payload`,
`go run ./cmd/sign sign`,
`go run ./cmd/sign verify`,
`REASONIX_REQUIRE_PAYLOAD_MANIFEST=1`,
`sign-certum.ps1" -FilePath "$installer"`,
`verify-windows-authenticode.ps1`,
`-ExpectedThumbprint "$CERTUM_KEY_ID"`,
`go run ./cmd/sign sign "$dist"/*`,
} {
if !strings.Contains(finalizer, want) {
t.Errorf("Windows signing finalizer is missing contract %q", want)
}
}
ciWorkflow := readTestFile(t, "../.github/workflows/ci.yml")
if !strings.Contains(ciWorkflow, `node packaging/smoke.mjs build/electron/windows-amd64/app`) {
t.Error("Windows CI must smoke the packaged Electron shell startup")
@@ -256,37 +266,26 @@ func TestWindowsPackagerRejectsMissingOrPartialRequiredPayloadManifest(t *testin
func TestProductionSigningRunsOnlyFromProtectedControlPlane(t *testing.T) {
stable := readTestFile(t, "../.github/workflows/release-stable.yml")
candidate := readTestFile(t, "../.github/workflows/release-candidate.yml")
promote := readTestFile(t, "../.github/workflows/release-promote.yml")
desktop := readTestFile(t, "../.github/workflows/release-desktop.yml")
if strings.Contains(stable, "\n push:\n") || strings.Contains(desktop, "\n push:\n") {
if strings.Contains(stable, "\n push:\n") || strings.Contains(candidate, "\n push:\n") ||
strings.Contains(promote, "\n push:\n") || strings.Contains(desktop, "\n push:\n") {
t.Fatal("production workflows must not run directly with a tag-shaped SignPath origin")
}
for _, want := range []string{
`ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}`,
`allow_recovery: 'false'`,
`signing_preflight: true`,
`signing_preflight_verified: true`,
`needs: [authorize, signpath-preflight]`,
`actions/attest-build-provenance@v3`,
`candidate_preparation: true`,
`git push --atomic origin`,
`environment: release`,
`candidate_verified: true`,
} {
if !strings.Contains(stable+"\n"+readTestFile(t, "../.github/workflows/release-stable-trigger.yml"), want) {
t.Errorf("stable relay is missing normal-release recovery guard %q", want)
if !strings.Contains(candidate+"\n"+promote, want) {
t.Errorf("sealed release control plane is missing %q", want)
}
}
for _, path := range []string{
"../.github/workflows/release-stable-trigger.yml",
} {
relay := readTestFile(t, path)
for _, want := range []string{
`actions: write`,
`CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}`,
`process.env.CONTROL_PLANE_REF !== 'main-v2'`,
`createWorkflowDispatch`,
`ref: process.env.CONTROL_PLANE_REF`,
} {
if !strings.Contains(relay, want) {
t.Errorf("%s is missing protected control-plane contract %q", path, want)
}
}
if _, err := os.Stat("../.github/workflows/release-stable-trigger.yml"); !errors.Is(err, os.ErrNotExist) {
t.Errorf("retired tag relay still exists or cannot be checked: %v", err)
}
for _, path := range []string{
+106 -105
View File
@@ -1,132 +1,133 @@
# Releasing Reasonix
Reasonix has one user-facing release line: the official `X.Y.Z` version. The
release engine keeps the proven Stable publication topology: three immutable
Git tags on one `main-v2` commit and one protected orchestrator.
Reasonix prepares immutable, accepted release candidates before it creates any
public tag. Publication consumes those exact files after one approval; recovery
observes public state and fills only missing stages.
The public identity remains compatible with existing clients:
| Surface | Immutable tag | Public result |
| --- | --- | --- |
| CLI | `vX.Y.Z` | GitHub Release and Homebrew |
| npm | `npm-vX.Y.Z` | root and platform packages; `latest`, `canary`, and `next` compatibility aliases |
| Desktop | `desktop-vX.Y.Z` | signed GitHub Release, immutable R2 directory, and `latest/latest.json` |
| npm | `npm-vX.Y.Z` | root and six platform packages; official aliases |
| Desktop | `desktop-vX.Y.Z` | signed GitHub Release, immutable R2 directory, and Stable manifest |
The three tags are implementation identities, not user-selectable channels.
They must always resolve to the same commit and may never be moved or deleted.
All three tags must identify the candidate product SHA. They are created in one
atomic push and must never be moved, deleted, or recreated. The Go SDK module
(`sdk/go`) retains its independent `sdk/go/vX.Y.Z` version line.
The Go SDK module (`sdk/go`) is versioned independently of the three product
surfaces: its tags look like `sdk/go/vX.Y.Z` (first: `sdk/go/v1.0.0`) and
point at the release commit that first shipped the corresponding Extension
Protocol major. SDK tags do not trigger product releases, do not move, and
do not change the three-tag contract above.
## Normal release
## Daily release flow
The normal developer path has one version input, one reviewed Notes PR, one
terminal command, and one environment approval:
1. Open Actions → **Prepare release** and enter `X.Y.Z`.
2. Review and merge the generated bilingual release-notes PR. If its checks
show `action_required` with no jobs, the Actions bot opened it and events
raised with `GITHUB_TOKEN` never start workflows; run
`./scripts/release-notes-pr-kick.sh X.Y.Z` to close and reopen it with
your own credentials so CI starts. The script refuses any other
zero-check shape.
3. From an authenticated maintainer checkout, run:
1. Run **Prepare release** with `X.Y.Z` and review the generated bilingual
Notes PR.
2. Merge the Notes PR after its required checks and review complete. The merge
automatically starts **Prepare release candidate**. A maintainer may also
dispatch that workflow with `version` and an optional `main-v2` ancestor.
3. Wait for the candidate workflow to build the shared CLI/npm binaries, build
and sign all Desktop platforms, run native final-package acceptance, and
seal the payload and evidence. Record the candidate ID printed in its
summary, for example `v1.39.0-0123456789ab-abcdef012345`.
4. From an authenticated maintainer checkout, run:
```sh
./scripts/release-stable.sh X.Y.Z
./scripts/release-stable.sh CANDIDATE_ID
```
4. Approve the resulting **Release stable** run once in the `release`
environment.
5. Wait for its postflight to verify CLI, npm, Desktop, R2, Homebrew, and the
changelog.
5. Review the candidate ID, full product SHA, Notes digest, signing policy,
platform receipts, and payload hashes in **Publish release candidate**.
Approve its `release` environment once.
6. Wait for CLI, npm, and Desktop publication, Stable pointer convergence, the
owned Pages deployment, hydrated download verification, and the publication
ledger.
If repository policy prevents Actions from opening the Notes PR, the workflow
still pushes `release-notes/vX.Y.Z` and prints this recoverable handoff:
The candidate is fixed when Notes are reviewed. Later `main-v2` merges do not
invalidate it. A product fix or a change to embedded Notes creates a new
candidate. Do not replace source files during packaging or claim an old binary
contains new embedded Notes.
## Candidate contract
The record binds the candidate ID, version, full product SHA, build and
acceptance control SHAs, Notes catalog and rendered-body hashes, workflow run
and attempt, exact payload artifact ID, signing fingerprint, every file size
and SHA-256, and native Windows/macOS acceptance receipts. GitHub artifact
attestations bind both the record and every payload file to the protected
candidate workflow on `main-v2`.
Candidate payloads are retained for 30 days. Records, native receipts,
publication ledgers, and timing reports are retained for 90 days. An expired
unpublished payload must be prepared again. Published files are verified from
their immutable public channels and are not rebuilt because an Actions artifact
expired. Candidate artifacts contain no credentials or real user data.
To revoke an unpublished candidate, add its exact ID to the comma- or
whitespace-separated repository variable `RELEASE_REVOKED_CANDIDATES`.
Preparation reuse and publication both fail closed for listed IDs.
The six CLI binaries are each built once. CLI archives, Homebrew checksums, and
npm platform tarballs reuse those bytes. Windows architectures build in
parallel, then share one Certum session; completed architecture bundles can be
reused by a failed-job rerun. Windows native acceptance runs in parallel after
signing. Desktop platforms do not wait for unrelated platform acceptance before
starting their own downstream work.
## Publication and recovery
Publication verifies the record attestation, exact artifact IDs, payload
attestations and hashes, Notes identity, protected source ancestry, signatures,
and acceptance receipts before requesting approval. It then atomically creates
the three tags and publishes CLI, npm, and Desktop in parallel from the sealed
payload. Tag creation no longer starts a second legacy release pipeline.
For any interrupted publication, run:
```sh
gh pr create --repo esengine/DeepSeek-Reasonix \
--base main-v2 --head release-notes/vX.Y.Z --fill
./scripts/release-stable.sh CANDIDATE_ID recover
```
Do not rerun Notes generation merely because PR creation was denied.
Recovery uses the same global publication lock and one approval. Each publisher
re-reads its external state:
## What the tag helper proves
- matching immutable content is reused;
- missing content is uploaded;
- ambiguous requests are queried before retrying;
- conflicting immutable content stops the stage;
- signing and native acceptance are not repeated;
- a newer npm, R2, Homebrew, or site pointer is never rolled back by an older
candidate recovery;
- a site-only failure reruns Pages and hydrated-site verification without
rebuilding product files.
`scripts/release-stable.sh` fails before creating any public ref unless:
The publication ledger records observed tag SHAs, every CLI and Desktop release
asset, all seven npm package identities and registry integrity values, pointer
outcomes, Stable manifest, Homebrew, changelog, and homepage state. Recovery
always queries the actual service again; the ledger is evidence, not a source of
truth for later mutations.
- the version is canonical `MAJOR.MINOR.PATCH`;
- remote `main-v2` is the commit that introduces or updates the complete,
reviewed Stable catalog record;
- exact-commit `main-v2` CI completed successfully. A commit that changes
only `release-notes/` skips the code matrix by design, so for such a
candidate the helper also requires green push CI on the nearest
first-parent ancestor that changed anything else (at most five hops);
- `vX.Y.Z`, `npm-vX.Y.Z`, and `desktop-vX.Y.Z` are all absent.
## Verification and timing
It then pushes a no-op guard for that exact `main-v2` SHA and all three
lightweight tags with one atomic Git transaction. If `main-v2` advanced while
CI was running, the complete transaction is rejected and no version tag is
consumed. A partial tag set is therefore not a normal failure mode. The
`vX.Y.Z` event starts the existing protected Stable relay; maintainers do not
dispatch child CLI, npm, or Desktop publishers.
Run **Verify release** with `X.Y.Z` for a read-only public check. It validates
the immutable tags, GitHub release contents, all npm packages and candidate
identity, the current Stable manifest when the version owns it, Homebrew,
changelog, and the browser-hydrated download DOM. For an older version, newer
public pointers are preserved and reported rather than treated as a reason to
roll them back.
## Publication and approval
Candidate and publication workflows upload JSON timing evidence and summarize
queue, runner, build, signing, acceptance, upload, site deployment, and total
wall time. Diagnostic timing failures do not invalidate a sealed candidate or a
verified publication.
The protected Stable workflow re-resolves all three tags to one SHA on
`main-v2` history, revalidates that normal candidates introduced their reviewed
Notes and passed exact-SHA push CI, and runs the cache guard before requesting
the sole human approval. `main-v2` may safely advance after the atomic tag
transaction without invalidating that candidate. After approval it performs a
no-publication SignPath preflight, then runs CLI, npm, and Desktop publishers
against the immutable candidate.
## Legacy recovery
The npm publisher advances `latest`, `canary`, and `next` to the same official
version. `canary` and `next` remain only so historical scripts continue to
install a supported build; they are not testing channels and are not advertised.
**Legacy release recovery** remains available only for releases created before
the candidate pipeline. It retains historical surface selection and recovery
guards. New releases must use candidate IDs; do not add hard-coded run IDs or
version exceptions to the new workflows.
No custom GitHub App, App private key, repository-owner setting change, manual
tag UI, or child-workflow approval is required.
Historical Preview, Canary, and RC artifacts remain readable, but those paths
are not normal publication entrypoints. npm `canary` and `next` remain
compatibility aliases for the official line.
## Recovery
For a partial Stable publication, open **Release stable** on protected
`main-v2`, enter the existing `vX.Y.Z`, select only the missing surfaces, and
approve `release` once. Recovery accepts only an immutable three-tag set that
remains on `main-v2` history. It must reuse matching public content and fail
closed on conflicting checksums, signatures, manifests, npm provenance, or R2
objects.
Never move, delete, or recreate a published tag. Ship product corrections as a
higher patch version.
## Retired prerelease paths
Normal Preview, Canary, and RC publication entrypoints are disabled. Historical
tags, Releases, package versions, changelog pages, and the final bridge endpoints
remain available for compatibility, but they do not appear in current download
navigation or release preparation.
Old CLI and Desktop channel settings resolve to the official line. Frozen
Preview endpoints continue to lead old clients to the bridge build, which can
then upgrade to the current official release.
## First release after cutover
For the first release after this change, independently prove:
- the three tags resolve to the reviewed Notes merge SHA;
- both GitHub Releases contain their complete expected assets;
- npm root and all six platform packages report that SHA and
`latest == canary == next`;
- R2 immutable and latest manifests are byte-identical and every URL works;
- Homebrew and reasonix.io show the same version;
- old bridge clients can upgrade to the official release;
- a v1.38.3 or older desktop reports `unsupported install_layout
"electron-v1" (keeping current version)` with its installation untouched,
and the reasonix.io `#start` section shows the manual full-package notice
for those clients.
The release is incomplete until every public surface reaches a terminal,
verified state.
The release is complete only when the immutable files, current public pointers,
hydrated website, publication ledger, and read-only verification all agree.
+3 -2
View File
@@ -15,8 +15,8 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| persistence | 19 | 0 | 0 | 19 |
| shell-file | 1 | 39 | 4 | 44 |
| artifact | 5 | 0 | 0 | 5 |
| ci-job | 28 | 0 | 0 | 28 |
| **all** | | | | **856** |
| ci-job | 29 | 0 | 0 | 29 |
| **all** | | | | **857** |
## Desktop commands (Go `App` methods bound to the UI)
@@ -921,5 +921,6 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `release-desktop.yml/release-gate` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | unchanged |
| `release-desktop.yml/resolve` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | version/channel resolution unchanged |
| `release-desktop.yml/signing-contract` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | payload list covers the Electron executables and native modules |
| `release-desktop.yml/windows-build` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | builds and smoke-tests unsigned x64 and ARM64 packages on native Windows runners before signing |
| `release-desktop.yml/windows-runtime-acceptance` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | installs and starts the exact signed x64 and ARM64 installers on native runners |
| `release-desktop.yml/windows-sign` | | .github/workflows/release-desktop.yml | keep-business (保留业务实现) | Certum signs native-tested x64 and ARM64 payloads and rebuilt installers on x64 |
+7
View File
@@ -6662,6 +6662,13 @@
"class": "keep-business",
"owner": "payload list covers the Electron executables and native modules"
},
{
"kind": "ci-job",
"name": "release-desktop.yml/windows-build",
"location": ".github/workflows/release-desktop.yml",
"class": "keep-business",
"owner": "builds and smoke-tests unsigned x64 and ARM64 packages on native Windows runners before signing"
},
{
"kind": "ci-job",
"name": "release-desktop.yml/windows-runtime-acceptance",
+52 -8
View File
@@ -111,11 +111,19 @@ var (
executed []string
)
type stuckStreamProvider struct{}
type stuckStreamProvider struct {
started chan struct{}
once sync.Once
}
func (stuckStreamProvider) Name() string { return "stuck-stream" }
func (*stuckStreamProvider) Name() string { return "stuck-stream" }
func (stuckStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
func (p *stuckStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
p.once.Do(func() {
if p.started != nil {
close(p.started)
}
})
return make(chan provider.Chunk), nil
}
@@ -142,8 +150,9 @@ func TestCanceledContextClosedProviderStreamReturnsCancel(t *testing.T) {
}
}
func TestCancelDuringStuckProviderStreamReturnsPromptly(t *testing.T) {
a := New(stuckStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
func TestCancelDuringStuckProviderStreamReturns(t *testing.T) {
prov := &stuckStreamProvider{started: make(chan struct{})}
a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
@@ -151,7 +160,11 @@ func TestCancelDuringStuckProviderStreamReturnsPromptly(t *testing.T) {
done <- a.Run(ctx, "wait on provider")
}()
time.Sleep(50 * time.Millisecond)
select {
case <-prov.started:
case <-time.After(5 * time.Second):
t.Fatal("provider stream did not start")
}
cancel()
select {
@@ -162,8 +175,39 @@ func TestCancelDuringStuckProviderStreamReturnsPromptly(t *testing.T) {
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run error = %v, want context cancellation", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Run did not return promptly after provider stream context cancellation")
case <-time.After(5 * time.Second):
t.Fatal("Run did not return after provider stream context cancellation")
}
}
func TestTaskToolCancelDuringStuckProviderReturns(t *testing.T) {
prov := &stuckStreamProvider{started: make(chan struct{})}
task := newTestTaskTool(t, prov, tool.NewRegistry(), "sys", "", "", nil)
ctx, cancel := context.WithCancel(testTaskContext())
done := make(chan error, 1)
go func() {
_, err := task.Execute(ctx, []byte(`{"prompt":"wait on stuck provider"}`))
done <- err
}()
select {
case <-prov.started:
case <-time.After(5 * time.Second):
t.Fatal("provider stream did not start")
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("Execute returned nil after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Execute error = %v, want context cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("TaskTool.Execute did not return after cancellation")
}
}
-27
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"testing"
"time"
"reasonix/internal/event"
"reasonix/internal/evidence"
@@ -79,32 +78,6 @@ func TestTaskToolInjectsWorkspaceContextIntoSubagentPrompt(t *testing.T) {
}
}
func TestTaskToolCancelDuringStuckProviderReturnsPromptly(t *testing.T) {
task := newTestTaskTool(t, stuckStreamProvider{}, tool.NewRegistry(), "sys", "", "", nil)
ctx, cancel := context.WithCancel(testTaskContext())
done := make(chan error, 1)
go func() {
_, err := task.Execute(ctx, []byte(`{"prompt":"wait on stuck provider"}`))
done <- err
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("Execute returned nil after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Execute error = %v, want context cancellation", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("TaskTool.Execute did not return promptly after cancellation")
}
}
func TestTaskToolSchemaExposesOnlyContinueFromForPersistence(t *testing.T) {
task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
schema := string(task.Schema())
+20
View File
@@ -0,0 +1,20 @@
import { execFileSync } from "node:child_process";
import { readdirSync } from "node:fs";
import path from "node:path";
import { publishPackages } from "./publish.mjs";
const [directory, version, candidateSha] = process.argv.slice(2);
if (!directory || !version || !candidateSha) {
throw new Error("usage: publish-candidate.mjs DIRECTORY VERSION CANDIDATE_SHA");
}
const packages = readdirSync(directory)
.filter(name => name.endsWith(".tgz"))
.sort()
.map(name => {
const tarball = path.resolve(directory, name);
const manifest = JSON.parse(execFileSync("tar", ["-xOf", tarball, "package/package.json"], { encoding: "utf8" }));
return { name: manifest.name, manifest, tarball, dir: process.cwd() };
});
publishPackages({ packages, version, candidateSha });
+3 -2
View File
@@ -116,7 +116,7 @@ function parseJSON(output, description) {
}
function readLocalPackage(entry, version, candidateSha) {
const pkg = JSON.parse(readFileSync(`${entry.dir}/package.json`, "utf8"));
const pkg = entry.manifest ?? JSON.parse(readFileSync(`${entry.dir}/package.json`, "utf8"));
if (pkg.name !== entry.name || pkg.version !== version) {
throw new Error(
`local package identity mismatch: expected ${entry.name}@${version}, got ${pkg.name}@${pkg.version}`,
@@ -230,8 +230,9 @@ function ensurePackage(
log(`publish ${entry.name}@${version} (${stagingTag})`);
try {
const publishTarget = entry.tarball ? [entry.tarball] : [];
runner(
["publish", "--access", "public", "--provenance", "--tag", stagingTag],
["publish", ...publishTarget, "--access", "public", "--provenance", "--tag", stagingTag],
{ cwd: entry.dir, inherit: true },
);
} catch (error) {
+13
View File
@@ -155,6 +155,19 @@ test("fills a partially published package set before advancing canary", (t) => {
}
});
test("publishes the exact prepared tarball instead of rebuilding a package directory", (t) => {
const fx = fixture(t);
const tarball = join(fx.packages[0].dir, "reasonix-cli-linux-x64-1.5.0-canary.42.tgz");
writeFileSync(tarball, "sealed tarball bytes");
fx.packages[0].tarball = tarball;
fx.publish();
const call = fx.calls.find(({ args }) => args[0] === "publish" && args[1] === tarball);
assert.ok(call, "npm publish must receive the sealed tarball path");
assert.equal(call.args.filter(argument => argument === tarball).length, 1);
});
test("waits through multi-minute npm registry visibility lag", (t) => {
const fx = fixture(t, "1.5.0-canary.42", { visibilityDelayReads: 45 });
+174
View File
@@ -0,0 +1,174 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import {
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, "..");
const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const SHA_RE = /^[0-9a-f]{40}$/;
export const targets = [
{ key: "darwin-arm64", npm: "darwin-arm64", goos: "darwin", goarch: "arm64" },
{ key: "darwin-amd64", npm: "darwin-x64", goos: "darwin", goarch: "amd64" },
{ key: "linux-arm64", npm: "linux-arm64", goos: "linux", goarch: "arm64" },
{ key: "linux-amd64", npm: "linux-x64", goos: "linux", goarch: "amd64" },
{ key: "windows-arm64", npm: "win32-arm64", goos: "windows", goarch: "arm64" },
{ key: "windows-amd64", npm: "win32-x64", goos: "windows", goarch: "amd64" },
];
function sha256(file) {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
export function platformPackage(target, version, candidateSha) {
return {
name: `@reasonix/cli-${target.npm}`,
version,
description: `reasonix prebuilt binary for ${target.npm}.`,
os: [target.goos === "windows" ? "win32" : target.goos],
cpu: [target.goarch === "amd64" ? "x64" : "arm64"],
files: ["bin/"],
license: "MIT",
repository: { type: "git", url: "git+https://github.com/esengine/DeepSeek-Reasonix.git" },
reasonixCandidateSha: candidateSha,
gitHead: candidateSha,
};
}
export function renderHomebrewCask(version, sums) {
const requireSum = name => {
if (!/^[0-9a-f]{64}$/.test(sums[name] ?? "")) throw new Error(`missing checksum for ${name}`);
return sums[name];
};
return `# This file was generated by Reasonix release candidate preparation. DO NOT EDIT.
cask "reasonix" do
postflight_steps do
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
end
end
version "${version}"
on_macos do
on_arm do
sha256 "${requireSum("reasonix-darwin-arm64.tar.gz")}"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-arm64.tar.gz"
end
on_intel do
sha256 "${requireSum("reasonix-darwin-amd64.tar.gz")}"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-amd64.tar.gz"
end
end
on_linux do
on_arm do
sha256 "${requireSum("reasonix-linux-arm64.tar.gz")}"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-arm64.tar.gz"
end
on_intel do
sha256 "${requireSum("reasonix-linux-amd64.tar.gz")}"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-amd64.tar.gz"
end
end
name "reasonix"
desc "Cache-first DeepSeek coding agent for the terminal."
homepage "https://github.com/esengine/DeepSeek-Reasonix"
livecheck do
skip "Auto-generated on release."
end
binary "reasonix"
end
`;
}
function run(command, args, options = {}) {
execFileSync(command, args, { stdio: "inherit", ...options });
}
function makeArchive(target, binary, cliDirectory, work) {
const stage = path.join(work, `cli-${target.key}`);
mkdirSync(stage);
for (const name of ["CHANGELOG.md", "LICENSE", "README.md", "README.zh-CN.md"]) copyFileSync(path.join(ROOT, name), path.join(stage, name));
copyFileSync(binary, path.join(stage, target.goos === "windows" ? "reasonix.exe" : "reasonix"));
const extension = target.goos === "windows" ? "zip" : "tar.gz";
const archive = path.join(cliDirectory, `reasonix-${target.key}.${extension}`);
if (target.goos === "windows") run("zip", ["-q", "-r", archive, "."], { cwd: stage });
else run("tar", ["-czf", archive, "-C", stage, "."]);
return archive;
}
function packNpm(target, binary, npmDirectory, work, version, candidateSha) {
const stage = path.join(work, `npm-${target.npm}`);
mkdirSync(path.join(stage, "bin"), { recursive: true });
copyFileSync(binary, path.join(stage, "bin", target.goos === "windows" ? "reasonix.exe" : "reasonix"));
writeFileSync(path.join(stage, "package.json"), `${JSON.stringify(platformPackage(target, version, candidateSha), null, 2)}\n`);
run("npm", ["pack", stage, "--pack-destination", npmDirectory, "--silent"]);
}
function packNpmRoot(npmDirectory, work, version, candidateSha) {
const stage = path.join(work, "npm-reasonix");
mkdirSync(stage);
cpSync(path.join(ROOT, "npm", "reasonix", "bin"), path.join(stage, "bin"), { recursive: true });
copyFileSync(path.join(ROOT, "README.md"), path.join(stage, "README.md"));
const pkg = JSON.parse(readFileSync(path.join(ROOT, "npm", "reasonix", "package.json"), "utf8"));
pkg.version = version;
pkg.reasonixCandidateSha = candidateSha;
pkg.gitHead = candidateSha;
for (const name of Object.keys(pkg.optionalDependencies)) pkg.optionalDependencies[name] = version;
writeFileSync(path.join(stage, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
run("npm", ["pack", stage, "--pack-destination", npmDirectory, "--silent"]);
}
export function buildCandidate(version, output, env = process.env) {
if (!VERSION_RE.test(version)) throw new Error("candidate version must be MAJOR.MINOR.PATCH");
const candidateSha = env.RELEASE_SOURCE_SHA || execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT, encoding: "utf8" }).trim();
if (!SHA_RE.test(candidateSha)) throw new Error("candidate source SHA must be full length");
const buildTime = env.RELEASE_BUILD_TIME;
if (!buildTime || !Number.isFinite(new Date(buildTime).valueOf())) throw new Error("RELEASE_BUILD_TIME must be an ISO timestamp");
mkdirSync(output);
const cliDirectory = path.join(output, "cli");
const npmDirectory = path.join(output, "npm");
const binaries = path.join(output, "binaries");
mkdirSync(cliDirectory); mkdirSync(npmDirectory); mkdirSync(binaries);
const work = mkdtempSync(path.join(tmpdir(), "reasonix-cli-candidate-"));
try {
for (const target of targets) {
const filename = target.goos === "windows" ? "reasonix.exe" : "reasonix";
const directory = path.join(binaries, target.key);
mkdirSync(directory);
const binary = path.join(directory, filename);
const ldflags = `-s -w -X main.version=v${version} -X main.gitCommit=${candidateSha.slice(0, 12)} -X main.buildTimeUTC=${buildTime} -X reasonix/internal/productdocs.linkedVersion=v${version} -X reasonix/internal/productdocs.linkedRevision=${candidateSha}`;
run("go", ["build", "-trimpath", "-ldflags", ldflags, "-o", binary, "./cmd/reasonix"], {
cwd: ROOT, env: { ...env, CGO_ENABLED: "0", GOOS: target.goos, GOARCH: target.goarch },
});
makeArchive(target, binary, cliDirectory, work);
packNpm(target, binary, npmDirectory, work, version, candidateSha);
}
packNpmRoot(npmDirectory, work, version, candidateSha);
const assets = targets.map(target => `reasonix-${target.key}.${target.goos === "windows" ? "zip" : "tar.gz"}`).sort();
const sums = Object.fromEntries(assets.map(name => [name, sha256(path.join(cliDirectory, name))]));
writeFileSync(path.join(cliDirectory, "SHA256SUMS"), `${assets.map(name => `${sums[name]} ${name}`).join("\n")}\n`);
writeFileSync(path.join(cliDirectory, "reasonix.rb"), renderHomebrewCask(version, sums));
} finally {
rmSync(work, { recursive: true, force: true });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [version, output] = process.argv.slice(2);
if (!version || !output) throw new Error("usage: build-release-cli-candidate.mjs VERSION OUTPUT");
buildCandidate(version, path.resolve(output));
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { platformPackage, renderHomebrewCask, targets } from "./build-release-cli-candidate.mjs";
test("platform package embeds the immutable full candidate identity", () => {
const pkg = platformPackage(targets[0], "1.2.3", "a".repeat(40));
assert.equal(pkg.name, "@reasonix/cli-darwin-arm64");
assert.equal(pkg.reasonixCandidateSha, "a".repeat(40));
assert.equal(pkg.gitHead, "a".repeat(40));
});
test("homebrew cask is checksum-bound to prepared CLI archives", () => {
const sums = Object.fromEntries(targets.filter(target => target.goos !== "windows").map(target => [`reasonix-${target.key}.tar.gz`, target.key.includes("arm64") ? "a".repeat(64) : "b".repeat(64)]));
const cask = renderHomebrewCask("1.2.3", sums);
assert.match(cask, /version "1\.2\.3"/);
assert.match(cask, /sha256 "a{64}"/);
assert.match(cask, /releases\/download\/v#\{version\}/);
});
+12 -3
View File
@@ -13,14 +13,15 @@ const ROOT_TOOLING = /^(?:Makefile|\.golangci[^/]*)$/;
const FRONTEND = /^desktop\/frontend\//;
const DESKTOP_MANIFEST = /^(?:desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)|desktop\/frontend\/(?:package\.json|pnpm-lock\.yaml|vite\.config\.[cm]?[jt]s|tsconfig[^/]*\.json))$/;
const ELECTRON = /^(?:desktop\/electron\/|desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$)/;
const PACKAGING = /^(?:desktop\/(?:packaging\/|build\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller)\b)/;
const PACKAGING = /^(?:desktop\/(?:packaging\/|build\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller|finalize-windows-signed-candidate)\b)/;
const RELEASE_CONTROL = /^(?:\.github\/workflows\/(?:release[^/]*|prepare-release-notes|pages)\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/;
const DESKTOP_GO = /^(?:desktop\/(?:[^/]+\.go|go\.(?:mod|sum)|cmd\/|internal\/)|internal\/|cmd\/|go\.(?:mod|sum)$)/;
const SDK = /^(?:sdk\/|internal\/extension\/)/;
const CI_CONTROL = /^(?:\.github\/workflows\/ci\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/;
const MEMORY_CONTROL = /^(?:\.github\/workflows\/app-memory\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/;
const MEMORY_FULL = /^(?:desktop\/frontend\/(?:bench\/app-(?:memory|browser|page-actions)[^/]*|src\/(?:App(?:Runtime)?\.tsx|app-runtime\/.*|app-shell\/.*|components\/Transcript(?:Cards)?\.tsx|lib\/(?:useController[^/]*|subscriptionScope|useNavigationSurface|navigationSurfaceTransition|keyedResource|fileResource|useWorkspaceChangesResource|mcpServerLifecycle|fileNavigationLifetime|bridge(?:BenchFixtures|HistoryFixtures)?)\.[^/]+))|\.github\/workflows\/app-memory\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/;
const FLAG_NAMES = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "notes_only"];
const FLAG_NAMES = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "release_control", "notes_only"];
function normalized(path) {
return path.replaceAll("\\", "/").replace(/^\.\//, "");
@@ -54,6 +55,14 @@ export function classifyPaths(input, { full = false } = {}) {
flags.site = true;
setReason(reasons, "site", path, "site source");
}
if (RELEASE_CONTROL.test(path)) {
flags.release_control = true;
setReason(reasons, "release_control", path, "release control plane");
if (path === ".github/workflows/pages.yml") {
flags.site = true;
setReason(reasons, "site", path, "site deployment control");
}
}
if (SDK.test(path)) {
flags.sdk = true;
setReason(reasons, "sdk", path, "SDK or generated protocol source");
@@ -73,7 +82,7 @@ export function classifyPaths(input, { full = false } = {}) {
setReason(reasons, flag, path, "memory workflow or shared routing contract");
}
}
if (!ROOT_UNRELATED.test(path) && !ROOT_DOC.test(path)) {
if (!RELEASE_CONTROL.test(path) && !ROOT_UNRELATED.test(path) && !ROOT_DOC.test(path)) {
flags.code = true;
setReason(reasons, "code", path, "root module input");
}
+18 -2
View File
@@ -126,7 +126,7 @@ test("invalid diff identities fail instead of producing a skip", () => {
test("CLI entry runs from paths with URL-significant characters", t => {
const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-entry-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const expectedNames = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "notes_only"];
const expectedNames = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "release_control", "notes_only"];
for (const directory of ["ordinary", "with space", "中文", "hash#directory", "literal%20directory"]) {
const target = path.join(root, directory, "ci-paths.mjs");
mkdirSync(path.dirname(target), { recursive: true });
@@ -148,7 +148,7 @@ test("CLI writes GitHub output and module import stays side-effect free", t => {
const stdout = execFileSync(process.execPath, [target, "--full", "--github-output", outputPath], { encoding: "utf8" });
assert.equal(stdout, "");
const output = readFileSync(outputPath, "utf8");
assert.equal(output.trim().split("\n").length, 13);
assert.equal(output.trim().split("\n").length, 14);
assert.match(output, /^desktop=true$/m);
assert.match(output, /^notes_only=false$/m);
@@ -180,3 +180,19 @@ test("CI routing changes exercise their owning workflow surfaces", () => {
assert.equal(memoryOnly.packaging, false);
assert.equal(memoryOnly.sdk, false);
});
test("release control changes run focused contracts without selecting product suites", () => {
for (const path of [
".github/workflows/release-candidate.yml",
".github/workflows/release-promote.yml",
".github/workflows/pages.yml",
"scripts/release-candidate.mjs",
"scripts/desktop-release-artifacts.test.mjs",
"npm/publish-candidate.mjs",
]) {
const flags = classifyPaths([path]).flags;
assert.equal(flags.release_control, true, path);
assert.equal(flags.code, false, path);
assert.equal(flags.desktop, false, path);
}
});
+15 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { readFileSync, appendFileSync } from "node:fs";
import { readFileSync, appendFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
@@ -16,6 +16,17 @@ function format(ms) {
}
function stageKind(job, step) {
if (step === "Resolve reviewed source") return "release control preflight";
if (step === "Build each CLI binary once and package both surfaces") return "shared CLI/npm build";
if (step === "Build and package") return "Desktop build";
if (/^Finalize (amd64|arm64) in the shared Certum session$/.test(step)) return "Windows signing";
if (/Install, launch, migrate, restart, and preserve legacy session/.test(step)) return "Windows acceptance";
if (step === "Run universal DMG smoke on Intel hardware") return "macOS acceptance";
if (step === "Seal candidate record") return "candidate sealing";
if (step === "Verify payload provenance, bytes, source, and operation") return "candidate verification";
if (step === "Create or verify all implementation tags") return "release activation";
if (step === "Verify public artifacts") return "public verification";
if (step === "Dispatch and wait for the owned Pages deployment") return "site deployment";
if (/Install (frontend|memory) dependencies/.test(step)) return "dependency install";
if (step === "Install browser runtimes") return "browser setup";
if (/Build (stable|canary|memory) frontend/.test(step)) return "frontend build";
@@ -88,7 +99,9 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.a
const options = args(process.argv.slice(2));
const run = JSON.parse(readFileSync(options.run, "utf8"));
const jobs = JSON.parse(readFileSync(options.jobs, "utf8"));
const markdown = timingMarkdown(timingReport(run, jobs), options.title);
const report = timingReport(run, jobs);
const markdown = timingMarkdown(report, options.title);
if (options.output) writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`);
if (options.summary) appendFileSync(options.summary, markdown);
else process.stdout.write(markdown);
} catch (error) {
+7 -4
View File
@@ -17,16 +17,19 @@ test("timing report separates queue, execution, wall time and selected stages",
{ name: "desktop-windows-go", created_at: stamp(3), started_at: stamp(4), completed_at: stamp(24), conclusion: "success", steps: [
{ name: "test (Windows desktop and update helper)", started_at: stamp(8), completed_at: stamp(23), conclusion: "success" },
] },
{ name: "sign Windows candidate", created_at: stamp(3), started_at: stamp(4), completed_at: stamp(24), conclusion: "success", steps: [
{ name: "Finalize amd64 in the shared Certum session", started_at: stamp(8), completed_at: stamp(23), conclusion: "success" },
] },
{ name: "skipped", created_at: stamp(1), conclusion: "skipped", steps: [] },
] };
const report = timingReport(run, jobs);
assert.equal(report.workflowElapsed, 32_000);
assert.equal(report.runnerSum, 60_000);
assert.equal(report.queueSum, 16_000);
assert.deepEqual(report.stages.map(stage => stage.kind), ["browser group", "frontend build", "dependency install", "Go test"]);
assert.equal(report.runnerSum, 80_000);
assert.equal(report.queueSum, 17_000);
assert.deepEqual(report.stages.map(stage => stage.kind), ["browser group", "frontend build", "dependency install", "Go test", "Windows signing"]);
const markdown = timingMarkdown(report, "Measured CI timing");
assert.match(markdown, /Total workflow wait: \*\*0m 32s\*\*/);
assert.match(markdown, /Sum of runner execution: \*\*1m 00s\*\*/);
assert.match(markdown, /Sum of runner execution: \*\*1m 20s\*\*/);
assert.match(markdown, /Step durations exclude job queue time/);
});
+32 -23
View File
@@ -24,8 +24,15 @@ function shellStep(body, name) {
}
const ci = workflow("ci");
const release = workflow("release-desktop");
const promote = workflow("release-promote");
const appMemory = workflow("app-memory");
test("release candidate verification cannot mutate repository contents before approval", () => {
assert.match(job(promote, "preflight"), /permissions:\n actions: read\n attestations: read\n contents: read/);
assert.match(job(promote, "authorize"), /environment: release[\s\S]*permissions:\n contents: read/);
assert.match(job(promote, "activate"), /permissions:\n contents: write/);
});
test("cancelled CI stops expensive workers but keeps result aggregation", () => {
for (const name of ["test", "windows-control", "windows-isolated", "race", "sdk", "desktop-prepare",
"desktop-frontend", "desktop-browser-group", "desktop-go", "desktop-go-race", "desktop-macos",
@@ -117,7 +124,7 @@ test("Certum signing survives skipped ancestor gates but requires successful inp
// which otherwise propagates a skipped standalone/orchestrator ancestor.
assert.match(body, /if:.*always\(\)/);
const context = {
needs: { resolve: { result: "success" }, build: { result: "success" }, "signing-contract": { result: "success" } },
needs: { resolve: { result: "success" }, "windows-build": { result: "success" }, "signing-contract": { result: "success" } },
github: { repository: "esengine/DeepSeek-Reasonix" },
inputs: { desktop_manual_only: false },
};
@@ -233,16 +240,18 @@ test("required lint aggregates code lint and the deduplicated frontend suite", (
const body = job(ci, "lint");
const script = shellStep(body, "Verify lint and frontend validation jobs");
const success = { CHANGES_RESULT: "success", LINT_CODE_RESULT: "success", LINT_CODE_REQUIRED: "true",
RELEASE_CONTROL_RESULT: "success", RELEASE_CONTROL_REQUIRED: "true",
PREPARE_RESULT: "success", FRONTEND_RESULT: "success", FRONTEND_REQUIRED: "true" };
const run = env => spawnSync("bash", ["-e", "-c", script], { env: { ...process.env, ...env } }).status;
assert.equal(run(success), 0);
for (const key of ["CHANGES_RESULT", "LINT_CODE_RESULT", "PREPARE_RESULT", "FRONTEND_RESULT"])
for (const key of ["CHANGES_RESULT", "LINT_CODE_RESULT", "RELEASE_CONTROL_RESULT", "PREPARE_RESULT", "FRONTEND_RESULT"])
for (const value of ["failure", "cancelled", "skipped", ""]) assert.notEqual(run({ ...success, [key]: value }), 0, `${key}=${value}`);
assert.equal(run({ ...success, LINT_CODE_REQUIRED: "false", LINT_CODE_RESULT: "skipped",
RELEASE_CONTROL_REQUIRED: "false", RELEASE_CONTROL_RESULT: "skipped",
FRONTEND_REQUIRED: "false", PREPARE_RESULT: "skipped", FRONTEND_RESULT: "skipped" }), 0);
assert.equal(run({ ...success, FRONTEND_REQUIRED: "false", PREPARE_RESULT: "success", FRONTEND_RESULT: "skipped" }), 0);
assert.doesNotMatch(job(ci, "lint-code"), /test:motion/);
assert.match(body, /needs: \[changes, lint-code, desktop-prepare, desktop-frontend\]/);
assert.match(body, /needs: \[changes, lint-code, release-control, desktop-prepare, desktop-frontend\]/);
});
test("required root aggregate covers the jobs the per-OS test legs do not", () => {
@@ -301,11 +310,11 @@ test("every ci job is reachable from a required aggregate", () => {
test("reuse skips only build work and still gates every publisher on validation", () => {
const context = {
inputs: { preflight_artifact_prefix: "desktop-123-1-preflight", orchestrated: true, signing_preflight_verified: true, signing_preflight: false, production_signing_smoke: false },
needs: { resolve: { result: "success" }, "cache-guard": { result: "success" }, "signing-contract": { result: "success" }, "mac-universal-intel": { result: "skipped" }, "windows-sign": { result: "skipped" }, "windows-runtime-acceptance": { result: "skipped" }, build: { result: "skipped" } },
needs: { resolve: { result: "success" }, "signing-contract": { result: "success" }, "mac-universal-intel": { result: "skipped" }, "windows-build": { result: "skipped" }, "windows-sign": { result: "skipped" }, "windows-runtime-acceptance": { result: "skipped" }, build: { result: "skipped" } },
};
assert.equal(condition(job(release, "build"), context), false);
assert.equal(condition(job(release, "publish"), context), true);
for (const key of ["resolve", "cache-guard", "signing-contract", "mac-universal-intel", "windows-sign", "build"]) {
for (const key of ["resolve", "signing-contract", "mac-universal-intel", "windows-build", "windows-sign", "build"]) {
for (const result of ["failure", "cancelled"]) {
const changed = structuredClone(context);
changed.needs[key].result = result;
@@ -323,6 +332,7 @@ test("reuse skips only build work and still gates every publisher on validation"
assert.equal(condition(job(release, "build"), fresh), true);
assert.equal(condition(job(release, "publish"), fresh), false);
fresh.needs.build.result = "success";
fresh.needs["windows-build"].result = "success";
fresh.needs["mac-universal-intel"].result = "success";
assert.equal(condition(job(release, "publish"), fresh), false, "unsigned Windows bundles cannot publish");
fresh.needs["windows-sign"].result = "success";
@@ -335,25 +345,24 @@ test("Certum signing preserves native builds and gates publication and attestati
const packageJob = job(ci, "desktop-windows-package");
assert.match(packageJob, /test-windows-installer-startup\.ps1/);
assert.match(packageJob, /ExpectedVersion v0\.0\.0-ci/);
const windowsBuild = job(release, "windows-build");
const signer = job(release, "windows-sign");
const releaseBuild = job(release, "build");
assert.match(releaseBuild, /Install and smoke-test Windows installer identity/);
assert.match(releaseBuild, /runner: windows-11-arm, platform: windows\/arm64/);
assert.match(releaseBuild, /scripts\/test-windows-installer-startup\.ps1/);
assert.match(releaseBuild, /scripts\/test-windows-startup-recovery\.ps1/);
assert.match(releaseBuild, /scripts\/windows-acceptance-environment\.ps1/);
assert.match(releaseBuild, /\.\/release-control\/scripts\/test-windows-installer-startup\.ps1/);
const acceptance = releaseBuild.indexOf('name: Install and smoke-test Windows installer identity');
assert.ok(acceptance > releaseBuild.lastIndexOf('scripts/package-windows-desktop.sh'), 'test the final manual installer after rebuilding');
assert.ok(acceptance < releaseBuild.indexOf('name: Sign artifacts (minisign)'), 'acceptance precedes artifact signing and publication');
assert.match(windowsBuild, /runner: windows-latest, platform: windows\/amd64/);
assert.match(windowsBuild, /runner: windows-11-arm, platform: windows\/arm64/);
assert.match(windowsBuild, /Smoke-test packaged Electron startup/);
assert.match(windowsBuild, /Upload Windows signing inputs/);
assert.match(job(ci, 'test'), /test-windows-installer-startup\.test\.ps1/);
assert.match(signer, /needs: \[resolve, windows-build, signing-contract\]/);
assert.match(signer, /runs-on: windows-2022/);
assert.match(signer, /arch: \[amd64, arm64\]/);
assert.match(signer, /max-parallel: 1/);
assert.match(signer, /ref: \$\{\{ github.workflow_sha \}\}/);
assert.ok(signer.indexOf("-PayloadDirectory signed-payload") < signer.indexOf("scripts/package-windows-desktop.sh"));
assert.ok(signer.indexOf("scripts/package-windows-desktop.sh") < signer.indexOf("-FilePath"));
assert.ok(signer.indexOf("-ExpectedThumbprint") < signer.indexOf("Sign artifacts (minisign)"));
assert.equal(signer.match(/setup-certum/g)?.length, 1, "both architectures share one Certum session");
assert.match(signer, /Finalize amd64 in the shared Certum session/);
assert.match(signer, /Finalize arm64 in the shared Certum session/);
assert.equal(signer.match(/finalize-windows-signed-candidate\.sh/g)?.length, 2);
assert.ok(signer.indexOf("Finalize amd64 in the shared Certum session")
< signer.indexOf("name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-amd64"));
assert.ok(signer.indexOf("Finalize arm64 in the shared Certum session")
< signer.indexOf("name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-arm64"));
assert.ok(!release.includes("secrets.SIGNPATH_API_TOKEN"));
const runtimeAcceptance = job(release, "windows-runtime-acceptance");
assert.match(runtimeAcceptance, /runner: windows-latest, arch: amd64/);
@@ -366,9 +375,9 @@ test("Certum signing preserves native builds and gates publication and attestati
assert.match(attestation, /verified-contract\.json/);
assert.match(attestation, /gh variable set/);
const context = { github: { repository: "esengine/DeepSeek-Reasonix" }, inputs: { signing_preflight: true, orchestrated: false },
needs: { "signing-contract": { result: "success" }, build: { result: "success" }, "windows-sign": { result: "success" }, "windows-runtime-acceptance": { result: "success" } } };
needs: { "signing-contract": { result: "success" }, build: { result: "success" }, "windows-build": { result: "success" }, "windows-sign": { result: "success" }, "windows-runtime-acceptance": { result: "success" } } };
assert.equal(condition(attestation, context), true);
for (const key of ["windows-sign", "windows-runtime-acceptance"]) {
for (const key of ["windows-build", "windows-sign", "windows-runtime-acceptance"]) {
for (const result of ["failure", "cancelled", "skipped"]) {
assert.equal(condition(attestation, { ...context, needs: { ...context.needs, [key]: { result } } }), false);
}
@@ -457,7 +466,7 @@ test("Desktop race uses every verified partition and one shared cache writer", (
});
test("installer evidence excludes running payloads and cache files on every publisher", () => {
for (const body of [job(ci, "desktop-windows-package"), job(release, "build"), job(release, "windows-runtime-acceptance")]) {
for (const body of [job(ci, "desktop-windows-package"), job(release, "windows-runtime-acceptance")]) {
const upload = body.match(/name: Upload (?:signed )?Windows installer acceptance evidence\n([\s\S]*?)(?=\n - |$)/)?.[1];
assert.ok(upload);
for (const extension of ["json", "png", "log"])
+32 -17
View File
@@ -16,8 +16,10 @@ export function releaseIdentity(env) {
return [key, env[variable]];
}));
const match = /^desktop-([1-9][0-9]*)-([1-9][0-9]*)-(preflight|release)$/.exec(identity.prefix);
if (!match || match[1] !== env.GITHUB_RUN_ID || !/^[1-9][0-9]*$/.test(env.GITHUB_RUN_ATTEMPT ?? "")
|| BigInt(match[2]) > BigInt(env.GITHUB_RUN_ATTEMPT)) throw new Error("artifact set is not from this run or a completed attempt");
const producerRun = env.RELEASE_PRODUCER_RUN_ID ?? env.GITHUB_RUN_ID;
const producerAttempt = env.RELEASE_PRODUCER_RUN_ATTEMPT ?? env.GITHUB_RUN_ATTEMPT;
if (!match || match[1] !== producerRun || !/^[1-9][0-9]*$/.test(producerAttempt ?? "")
|| BigInt(match[2]) > BigInt(producerAttempt)) throw new Error("artifact set is not from the verified producer run or a completed attempt");
if (![identity.sourceSHA, identity.controlSHA].every(sha => /^[a-f0-9]{40}$/.test(sha))) throw new Error("invalid release SHA");
return identity;
}
@@ -46,6 +48,30 @@ function validAttempt(attempt, identity, currentAttempt) {
&& BigInt(attempt) >= BigInt(identity.prefix.split("-")[2]) && BigInt(attempt) <= BigInt(currentAttempt);
}
export function verifyBundle(bundle, platform, identity, currentAttempt) {
if (!platforms.includes(platform)) throw new Error("invalid release platform");
if (!lstatSync(bundle).isDirectory() || lstatSync(bundle).isSymbolicLink()
|| JSON.stringify(readdirSync(bundle).sort()) !== JSON.stringify(["files", "identity.json"])) {
throw new Error("invalid bundle layout");
}
if (!lstatSync(path.join(bundle, "identity.json")).isFile()
|| lstatSync(path.join(bundle, "identity.json")).isSymbolicLink()
|| !lstatSync(path.join(bundle, "files")).isDirectory()
|| lstatSync(path.join(bundle, "files")).isSymbolicLink()) {
throw new Error("invalid bundle entries");
}
const manifest = JSON.parse(readFileSync(path.join(bundle, "identity.json"), "utf8"));
if (manifest.schema !== 1 || manifest.platform !== platform) throw new Error("invalid bundle identity");
if (!validAttempt(manifest.buildAttempt, identity, currentAttempt)) throw new Error("invalid producer attempt");
for (const [key, value] of Object.entries(identity)) {
if (manifest[key] !== value) throw new Error(`artifact identity mismatch: ${key}`);
}
const files = entries(path.join(bundle, "files"));
if (JSON.stringify(files) !== JSON.stringify(manifest.files)) throw new Error(`artifact digest mismatch: ${platform}`);
requireSignatures(files);
return { manifest, files };
}
export function pack(source, target, platform, identity, buildAttempt = identity.prefix.split("-")[2]) {
if (!platforms.includes(platform)) throw new Error("invalid release platform");
if (!validAttempt(buildAttempt, identity, buildAttempt)) throw new Error("invalid producer attempt");
@@ -63,19 +89,7 @@ export function collect(source, target, identity, currentAttempt = identity.pref
const copies = new Map();
for (const platform of platforms) {
const bundle = path.join(source, `${identity.prefix}-${platform}`);
if (!lstatSync(bundle).isDirectory() || lstatSync(bundle).isSymbolicLink()
|| JSON.stringify(readdirSync(bundle).sort()) !== JSON.stringify(["files", "identity.json"])) throw new Error("invalid bundle layout");
if (!lstatSync(path.join(bundle, "identity.json")).isFile() || lstatSync(path.join(bundle, "identity.json")).isSymbolicLink()
|| !lstatSync(path.join(bundle, "files")).isDirectory() || lstatSync(path.join(bundle, "files")).isSymbolicLink()) throw new Error("invalid bundle entries");
const manifest = JSON.parse(readFileSync(path.join(bundle, "identity.json"), "utf8"));
if (manifest.schema !== 1 || manifest.platform !== platform) throw new Error("invalid bundle identity");
if (!validAttempt(manifest.buildAttempt, identity, currentAttempt)) throw new Error("invalid producer attempt");
for (const [key, value] of Object.entries(identity)) {
if (manifest[key] !== value) throw new Error(`artifact identity mismatch: ${key}`);
}
const files = entries(path.join(bundle, "files"));
if (JSON.stringify(files) !== JSON.stringify(manifest.files)) throw new Error(`artifact digest mismatch: ${platform}`);
requireSignatures(files);
const { files } = verifyBundle(bundle, platform, identity, currentAttempt);
for (const { name } of files) {
if (copies.has(name)) throw new Error(`duplicate artifact across platforms: ${name}`);
copies.set(name, path.join(bundle, "files", name));
@@ -90,6 +104,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
const [command, source, target, platform] = process.argv.slice(2);
const identity = releaseIdentity(process.env);
if (command === "pack") pack(source, target, platform, identity, process.env.GITHUB_RUN_ATTEMPT);
else if (command === "collect") collect(source, target, identity, process.env.GITHUB_RUN_ATTEMPT);
else throw new Error("usage: desktop-release-artifacts.mjs pack|collect SOURCE TARGET [PLATFORM]");
else if (command === "collect") collect(source, target, identity, process.env.RELEASE_PRODUCER_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT);
else if (command === "verify") verifyBundle(source, target, identity, process.env.GITHUB_RUN_ATTEMPT);
else throw new Error("usage: desktop-release-artifacts.mjs pack|collect|verify SOURCE TARGET [PLATFORM]");
}
+22 -1
View File
@@ -3,7 +3,7 @@ import { cpSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, syml
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { collect, pack, platforms, releaseIdentity } from "./desktop-release-artifacts.mjs";
import { collect, pack, platforms, releaseIdentity, verifyBundle } from "./desktop-release-artifacts.mjs";
const env = {
RELEASE_SOURCE_SHA: "a".repeat(40), RELEASE_CONTROL_SHA: "b".repeat(40),
@@ -58,6 +58,19 @@ test("another run, future attempt and missing identity cannot be reused", () =>
}
});
test("a sealed candidate may be collected in a later publisher run", t => {
const f = fixture(t);
const reused = releaseIdentity({
...env,
GITHUB_RUN_ID: "999",
GITHUB_RUN_ATTEMPT: "1",
RELEASE_PRODUCER_RUN_ID: "123",
RELEASE_PRODUCER_RUN_ATTEMPT: "2",
});
collect(f.bundles, f.target, reused, "2");
assert.equal(readFileSync(path.join(f.target, `${platforms[0]}.zip`), "utf8"), `signed:${platforms[0]}`);
});
for (const field of ["sourceSHA", "controlSHA", "tag", "version", "channel", "signingFingerprint", "prefix"]) {
test(`reject mismatched ${field} before staging publication`, t => {
const f = fixture(t);
@@ -99,6 +112,14 @@ test("a failed platform can be replaced on retry while successful platforms reta
assert.equal(readFileSync(path.join(f.target, `${platforms[0]}.zip`), "utf8"), `signed:${platforms[0]}`);
});
test("a later attempt can verify and reuse one completed platform bundle", t => {
const f = fixture(t);
const bundle = path.join(f.bundles, `${identity.prefix}-windows-amd64`);
assert.equal(verifyBundle(bundle, "windows-amd64", identity, "2").manifest.buildAttempt, "1");
writeFileSync(path.join(bundle, "files", "windows-amd64.zip"), "tampered");
assert.throws(() => verifyBundle(bundle, "windows-amd64", identity, "2"), /digest mismatch/);
});
test("platform bundles cannot overwrite each other's filenames", t => {
const f = fixture(t);
const second = path.join(f.bundles, `${identity.prefix}-${platforms[1]}`);
+3 -1
View File
@@ -43,5 +43,7 @@ test("CI runs all groups separately and retains the aggregate and native probe",
const aggregate = source.match(/\n desktop-windows-go:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]*:|$)/)?.[1];
assert.match(aggregate, /needs: \[changes, desktop-prepare, desktop-windows-go-group\]/);
assert.match(aggregate, /test "\$GROUP_RESULT" = success/);
assert.match(source, /node --test scripts\/desktop-windows-go-tests.test.mjs/);
const releaseControl = source.match(/\n release-control:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]*:|$)/)?.[1];
assert.ok(releaseControl, "release-control job must still exist");
assert.match(releaseControl, /node --test[\s\S]*?scripts\/desktop-windows-go-tests\.test\.mjs/);
});
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
arch="${1:?usage: finalize-windows-signed-candidate.sh ARCH SIGNING_WORK PAYLOAD DIST BUNDLE VERSION}"
signing_work="${2:?missing signing work directory}"
signed_payload="${3:?missing signed payload directory}"
dist="${4:?missing output dist directory}"
bundle="${5:?missing output bundle directory}"
version="${6:?missing release version}"
case "$arch" in
amd64 | arm64) ;;
*) echo "unsupported Windows architecture: $arch" >&2; exit 1 ;;
esac
product_root="$(pwd)"
control_root="$(cd "$(dirname "$0")/.." && pwd)"
case "$signing_work" in /*) ;; *) signing_work="$product_root/$signing_work" ;; esac
case "$signed_payload" in /*) ;; *) signed_payload="$product_root/$signed_payload" ;; esac
case "$dist" in /*) ;; *) dist="$product_root/$dist" ;; esac
case "$bundle" in /*) ;; *) bundle="$product_root/$bundle" ;; esac
for variable in CERTUM_KEY_ID MINISIGN_PRIVATE_KEY MINISIGN_PASSWORD RELEASE_SOURCE_SHA RELEASE_CONTROL_SHA RELEASE_TAG RELEASE_VERSION RELEASE_CHANNEL RELEASE_SIGNING_FINGERPRINT RELEASE_ARTIFACT_PREFIX; do
[ -n "${!variable:-}" ] || { echo "missing $variable" >&2; exit 1; }
done
pwsh -NoProfile -File "$control_root/scripts/sign-certum.ps1" -PayloadDirectory "$signed_payload"
rm -rf "$product_root/desktop/build/windows" "$dist" "$bundle"
mkdir -p "$product_root/desktop/build/windows/installer"
cp "$signing_work/desktop/build/windows/installer/reasonix_project.nsh" \
"$product_root/desktop/build/windows/installer/"
(
cd "$product_root/desktop"
go run ./cmd/sign windows-payload "$signed_payload" "$version"
go run ./cmd/sign sign "$signed_payload/reasonix-payload.json"
go run ./cmd/sign verify "$signed_payload/reasonix-payload.json"
)
REASONIX_REQUIRE_PAYLOAD_MANIFEST=1 \
"$product_root/scripts/package-windows-desktop.sh" "$arch" "$signed_payload"
mv "$product_root/dist" "$dist"
installer="$dist/Reasonix-windows-$arch-installer.exe"
portable="$dist/Reasonix-windows-$arch.zip"
pwsh -NoProfile -File "$control_root/scripts/sign-certum.ps1" -FilePath "$installer"
portable_layout="legacy-dual"
if [ -f "$product_root/desktop/packaging/windows-portable-layout.txt" ]; then
portable_layout="$(tr -d '\r\n' < "$product_root/desktop/packaging/windows-portable-layout.txt")"
fi
pwsh -NoProfile -File "$control_root/scripts/verify-windows-authenticode.ps1" \
-PayloadDirectory "$signed_payload" -InstallerPath "$installer" \
-PortableArchivePath "$portable" -ExpectedThumbprint "$CERTUM_KEY_ID" \
-RequireTrusted -PortableLayout "$portable_layout"
node "$product_root/desktop/packaging/size-report.mjs" \
--platform "windows/$arch" --version "$version" \
--bundle "$signed_payload" --dist "$dist" \
--output "$product_root/desktop/build/reports/windows-$arch"
(
cd "$product_root/desktop"
go run ./cmd/sign sign "$dist"/*
)
node "$control_root/scripts/desktop-release-artifacts.mjs" pack "$dist" "$bundle" "windows-$arch"
+3 -1
View File
@@ -55,7 +55,9 @@ test("every darwin-specific package is inside the pull-request group", () => {
test("CI runs the darwin group on pull requests and the full sweep on pushes", () => {
const source = readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8");
assert.match(source, /run: node scripts\/macos-go-tests\.mjs darwin\n/);
assert.match(source, /node --test[^\n]*scripts\/macos-go-tests\.test\.mjs/);
const releaseControl = source.match(/\n release-control:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]*:|$)/)?.[1];
assert.ok(releaseControl, "release-control job must still exist");
assert.match(releaseControl, /node --test[\s\S]*?scripts\/macos-go-tests\.test\.mjs/);
const enabled = (name, event, run) => {
const step = source.split(` - name: ${name}\n`)[1]?.split(/\n - /)[0];
assert.ok(step, `${name} must still exist`);
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const VERSION_RE = /version "(\d+)\.(\d+)\.(\d+)"/;
function compareVersion(left, right) {
const a = left.map(Number); const b = right.map(Number);
for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
return 0;
}
export function decideCaskUpdate(existing, candidate) {
if (existing === candidate) return "reuse";
const current = existing.match(VERSION_RE);
const next = candidate.match(VERSION_RE);
if (!next) throw new Error("candidate cask has no canonical version");
if (current && compareVersion(next.slice(1), current.slice(1)) < 0) throw new Error("refusing to move Homebrew cask backwards");
if (current && compareVersion(next.slice(1), current.slice(1)) === 0) throw new Error("existing Homebrew version has conflicting content");
return "publish";
}
async function request(url, token, options = {}) {
const response = await fetch(url, {
...options,
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", ...options.headers },
});
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
return response.json();
}
async function publish(file) {
const token = process.env.HOMEBREW_TAP_TOKEN;
if (!token) throw new Error("HOMEBREW_TAP_TOKEN is required");
const candidate = readFileSync(file, "utf8");
const api = "https://api.github.com/repos/esengine/homebrew-reasonix/contents/Casks/reasonix.rb";
const current = await request(api, token);
const existing = Buffer.from(current.content, "base64").toString("utf8");
if (decideCaskUpdate(existing, candidate) === "reuse") return;
await request(api, token, {
method: "PUT",
body: JSON.stringify({
message: `chore: update Reasonix to ${candidate.match(VERSION_RE).slice(1).join(".")}`,
content: Buffer.from(candidate).toString("base64"),
sha: current.sha,
branch: "main",
committer: { name: "reasonix", email: "[email protected]" },
}),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
if (!process.argv[2]) throw new Error("usage: publish-homebrew-cask.mjs CASK_FILE");
await publish(process.argv[2]);
}
+12
View File
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideCaskUpdate } from "./publish-homebrew-cask.mjs";
const cask = version => `cask "reasonix" do\n version "${version}"\nend\n`;
test("reuses an identical cask", () => assert.equal(decideCaskUpdate(cask("1.2.3"), cask("1.2.3")), "reuse"));
test("publishes a newer cask", () => assert.equal(decideCaskUpdate(cask("1.2.3"), cask("1.2.4")), "publish"));
test("rejects downgrade and same-version conflict", () => {
assert.throws(() => decideCaskUpdate(cask("2.0.0"), cask("1.9.9")), /backwards/);
assert.throws(() => decideCaskUpdate(`${cask("1.2.3")}# old`, cask("1.2.3")), /conflicting/);
});
+280
View File
@@ -0,0 +1,280 @@
import { createHash } from "node:crypto";
import {
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const SHA_RE = /^[0-9a-f]{40}$/;
const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const CANDIDATE_RE = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-[0-9a-f]{12}-[0-9a-f]{12}$/;
export const desktopPlatforms = [
"darwin-arm64", "darwin-amd64", "darwin-universal",
"windows-amd64", "windows-arm64", "linux-amd64",
];
export const npmPackages = [
"reasonix", "reasonix-cli-darwin-arm64", "reasonix-cli-darwin-x64",
"reasonix-cli-linux-arm64", "reasonix-cli-linux-x64",
"reasonix-cli-win32-arm64", "reasonix-cli-win32-x64",
];
function sha256(data) {
return createHash("sha256").update(data).digest("hex");
}
function fileEntry(root, file) {
const absolute = path.join(root, file);
const stat = lstatSync(absolute);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) {
throw new Error(`candidate contains an invalid file: ${file}`);
}
return { path: file.split(path.sep).join("/"), size: stat.size, sha256: sha256(readFileSync(absolute)) };
}
function walk(root, directory = root) {
const files = [];
for (const name of readdirSync(directory).sort()) {
const absolute = path.join(directory, name);
const stat = lstatSync(absolute);
if (stat.isSymbolicLink()) throw new Error(`candidate contains a symlink: ${path.relative(root, absolute)}`);
if (stat.isDirectory()) files.push(...walk(root, absolute));
else files.push(fileEntry(root, path.relative(root, absolute)));
}
return files;
}
function requireIdentity(metadata) {
if (!VERSION_RE.test(metadata.version ?? "")) throw new Error("invalid candidate version");
for (const key of ["sourceSHA", "buildControlSHA", "acceptanceControlSHA", "notesSourceSHA"]) {
if (!SHA_RE.test(metadata[key] ?? "")) throw new Error(`invalid ${key}`);
}
if (!/^[0-9a-f]{64}$/.test(metadata.catalogSha256 ?? "")) throw new Error("invalid catalogSha256");
if (!/^[0-9a-f]{64}$/.test(metadata.renderedNotesSha256 ?? "")) throw new Error("invalid renderedNotesSha256");
if (!/^[1-9][0-9]*$/.test(String(metadata.runId ?? ""))) throw new Error("invalid runId");
if (!/^[1-9][0-9]*$/.test(String(metadata.runAttempt ?? ""))) throw new Error("invalid runAttempt");
if (!/^[1-9][0-9]*$/.test(String(metadata.payloadArtifactId ?? ""))) throw new Error("invalid payloadArtifactId");
if (!/^[1-9][0-9]*$/.test(String(metadata.evidenceArtifactId ?? ""))) throw new Error("invalid evidenceArtifactId");
if (metadata.payloadArtifactName !== `release-candidate-payload-${metadata.candidateId ?? candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256)}`) {
throw new Error("invalid payloadArtifactName");
}
if (metadata.evidenceArtifactName !== `release-candidate-evidence-${metadata.candidateId ?? candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256)}`) {
throw new Error("invalid evidenceArtifactName");
}
if (metadata.repository !== "esengine/DeepSeek-Reasonix") throw new Error("untrusted candidate repository");
if (metadata.workflow !== ".github/workflows/release-candidate.yml") throw new Error("untrusted candidate workflow");
}
export function candidateId(version, sourceSHA, catalogSha256) {
if (!VERSION_RE.test(version) || !SHA_RE.test(sourceSHA) || !/^[0-9a-f]{64}$/.test(catalogSha256)) {
throw new Error("cannot derive candidate id from invalid identity");
}
return `v${version}-${sourceSHA.slice(0, 12)}-${catalogSha256.slice(0, 12)}`;
}
function requirePayloadLayout(root, files) {
const names = new Set(files.map(file => file.path));
for (const platform of desktopPlatforms) {
const prefix = `desktop/${platform}/`;
if (![...names].some(name => name.startsWith(prefix) && name.endsWith("/identity.json"))) {
throw new Error(`candidate is missing Desktop bundle: ${platform}`);
}
}
const cli = [
"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", "SHA256SUMS",
"reasonix.rb",
];
for (const name of cli) if (!names.has(`cli/${name}`)) throw new Error(`candidate is missing CLI asset: ${name}`);
const tarballs = [...names].filter(name => name.startsWith("npm/") && name.endsWith(".tgz"));
if (tarballs.length !== npmPackages.length) throw new Error(`candidate has ${tarballs.length} npm packages; expected ${npmPackages.length}`);
for (const name of npmPackages) {
if (!tarballs.some(file => path.basename(file).startsWith(`${name}-`))) throw new Error(`candidate is missing npm package: ${name}`);
}
}
function desktopIdentity(payloadRoot, platform) {
const identityPath = path.join(payloadRoot, "desktop", platform, "identity.json");
const identity = JSON.parse(readFileSync(identityPath, "utf8"));
if (identity.schema !== 1 || identity.platform !== platform) {
throw new Error(`candidate has an invalid Desktop identity: ${platform}`);
}
return identity;
}
function requireDesktopIdentities(payloadRoot, metadata) {
for (const platform of desktopPlatforms) {
const identity = desktopIdentity(payloadRoot, platform);
const expected = {
sourceSHA: metadata.sourceSHA,
controlSHA: metadata.buildControlSHA,
tag: `desktop-v${metadata.version}`,
version: `v${metadata.version}`,
channel: "stable",
signingFingerprint: metadata.desktopFingerprint,
prefix: metadata.desktopPrefix,
};
for (const [key, value] of Object.entries(expected)) {
if (identity[key] !== value) {
throw new Error(`candidate Desktop identity mismatch for ${platform}: ${key}`);
}
}
}
}
function requireAcceptanceReceipt(payloadRoot, files, metadata, item) {
const evidence = files.find(file => file.path === item.evidencePath);
if (item.status !== "passed" || !evidence) {
throw new Error(`candidate acceptance evidence is missing: ${item.kind}`);
}
const receipt = JSON.parse(readFileSync(path.join(payloadRoot, item.evidencePath), "utf8"));
if (receipt.schema !== 1 || receipt.kind !== item.kind || receipt.status !== "passed"
|| receipt.version !== `v${metadata.version}` || !/^[0-9a-f]{64}$/.test(receipt.sha256 ?? "")) {
throw new Error(`candidate acceptance receipt is invalid: ${item.kind}`);
}
const platform = item.kind === "macos-universal-intel" ? "darwin-universal" : item.kind;
const identity = desktopIdentity(payloadRoot, platform);
const artifact = identity.files.find(file => file.sha256 === receipt.sha256);
if (!artifact) throw new Error(`candidate acceptance receipt does not name a sealed file: ${item.kind}`);
return { ...item, evidenceSha256: evidence.sha256, artifactSha256: receipt.sha256 };
}
export function sealCandidate(payloadRoot, metadata) {
requireIdentity(metadata);
const files = walk(payloadRoot);
requirePayloadLayout(payloadRoot, files);
requireDesktopIdentities(payloadRoot, metadata);
const id = candidateId(metadata.version, metadata.sourceSHA, metadata.catalogSha256);
const created = new Date(metadata.createdAt);
const expires = new Date(metadata.expiresAt);
if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created) {
throw new Error("invalid candidate validity window");
}
const acceptance = metadata.acceptance.map(item => requireAcceptanceReceipt(payloadRoot, files, metadata, item));
return {
schema: 1,
candidateId: id,
policyVersion: 1,
version: metadata.version,
sourceSHA: metadata.sourceSHA,
control: { buildSHA: metadata.buildControlSHA, acceptanceSHA: metadata.acceptanceControlSHA },
notes: {
sourceSHA: metadata.notesSourceSHA,
catalogSha256: metadata.catalogSha256,
renderedSha256: metadata.renderedNotesSha256,
},
source: {
repository: metadata.repository,
workflow: metadata.workflow,
runId: String(metadata.runId),
runAttempt: String(metadata.runAttempt),
desktopPrefix: metadata.desktopPrefix,
payloadArtifactId: String(metadata.payloadArtifactId),
payloadArtifactName: metadata.payloadArtifactName,
evidenceArtifactId: String(metadata.evidenceArtifactId),
evidenceArtifactName: metadata.evidenceArtifactName,
},
signing: { desktopFingerprint: metadata.desktopFingerprint },
acceptance,
validity: { createdAt: created.toISOString(), expiresAt: expires.toISOString(), revoked: false },
files,
};
}
export function verifyCandidate(payloadRoot, record, now = new Date()) {
if (record.schema !== 1 || record.policyVersion !== 1) throw new Error("unsupported candidate schema");
requireIdentity({
version: record.version,
sourceSHA: record.sourceSHA,
buildControlSHA: record.control?.buildSHA,
acceptanceControlSHA: record.control?.acceptanceSHA,
notesSourceSHA: record.notes?.sourceSHA,
catalogSha256: record.notes?.catalogSha256,
renderedNotesSha256: record.notes?.renderedSha256,
repository: record.source?.repository,
workflow: record.source?.workflow,
runId: record.source?.runId,
runAttempt: record.source?.runAttempt,
payloadArtifactId: record.source?.payloadArtifactId,
payloadArtifactName: record.source?.payloadArtifactName,
evidenceArtifactId: record.source?.evidenceArtifactId,
evidenceArtifactName: record.source?.evidenceArtifactName,
candidateId: record.candidateId,
});
if (!CANDIDATE_RE.test(record.candidateId) || record.candidateId !== candidateId(record.version, record.sourceSHA, record.notes.catalogSha256)) {
throw new Error("candidate id does not match its immutable inputs");
}
if (record.validity?.revoked !== false) throw new Error("candidate is revoked");
if (new Date(record.validity?.expiresAt).valueOf() <= now.valueOf()) throw new Error("candidate has expired");
const actual = walk(payloadRoot);
requirePayloadLayout(payloadRoot, actual);
if (JSON.stringify(actual) !== JSON.stringify(record.files)) throw new Error("candidate payload digest mismatch");
requireDesktopIdentities(payloadRoot, {
version: record.version,
sourceSHA: record.sourceSHA,
buildControlSHA: record.control.buildSHA,
desktopFingerprint: record.signing.desktopFingerprint,
desktopPrefix: record.source.desktopPrefix,
});
const passed = new Set((record.acceptance ?? []).filter(item => item.status === "passed").map(item => item.kind));
for (const required of ["windows-amd64", "windows-arm64", "macos-universal-intel"]) {
if (!passed.has(required)) throw new Error(`candidate is missing acceptance evidence: ${required}`);
}
for (const item of record.acceptance) {
const evidence = actual.find(file => file.path === item.evidencePath);
if (!evidence || evidence.sha256 !== item.evidenceSha256) throw new Error(`candidate acceptance receipt mismatch: ${item.kind}`);
const validated = requireAcceptanceReceipt(payloadRoot, actual, {
version: record.version,
}, item);
if (validated.artifactSha256 !== item.artifactSha256) {
throw new Error(`candidate acceptance artifact mismatch: ${item.kind}`);
}
}
return record;
}
function parseMetadata(env) {
const acceptance = JSON.parse(env.RELEASE_ACCEPTANCE_JSON ?? "[]");
return {
version: env.RELEASE_VERSION,
sourceSHA: env.RELEASE_SOURCE_SHA,
buildControlSHA: env.RELEASE_BUILD_CONTROL_SHA,
acceptanceControlSHA: env.RELEASE_ACCEPTANCE_CONTROL_SHA,
notesSourceSHA: env.RELEASE_NOTES_SOURCE_SHA,
catalogSha256: env.RELEASE_CATALOG_SHA256,
renderedNotesSha256: env.RELEASE_RENDERED_NOTES_SHA256,
repository: env.GITHUB_REPOSITORY,
workflow: env.RELEASE_WORKFLOW,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
payloadArtifactId: env.RELEASE_PAYLOAD_ARTIFACT_ID,
payloadArtifactName: env.RELEASE_PAYLOAD_ARTIFACT_NAME,
evidenceArtifactId: env.RELEASE_EVIDENCE_ARTIFACT_ID,
evidenceArtifactName: env.RELEASE_EVIDENCE_ARTIFACT_NAME,
desktopPrefix: env.RELEASE_DESKTOP_PREFIX,
desktopFingerprint: env.RELEASE_DESKTOP_FINGERPRINT,
createdAt: env.RELEASE_CREATED_AT,
expiresAt: env.RELEASE_EXPIRES_AT,
acceptance,
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [command, payload, recordPath, catalogSha256] = process.argv.slice(2);
if (command === "seal") {
mkdirSync(path.dirname(recordPath), { recursive: true });
writeFileSync(recordPath, `${JSON.stringify(sealCandidate(payload, parseMetadata(process.env)), null, 2)}\n`);
} else if (command === "verify") {
verifyCandidate(payload, JSON.parse(readFileSync(recordPath, "utf8")));
process.stdout.write(`${JSON.stringify(JSON.parse(readFileSync(recordPath, "utf8")))}\n`);
} else if (command === "id") {
process.stdout.write(`${candidateId(payload, recordPath, catalogSha256)}\n`);
} else {
throw new Error("usage: release-candidate.mjs seal|verify PAYLOAD RECORD | id VERSION SOURCE_SHA CATALOG_SHA256");
}
}
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { candidateId, desktopPlatforms, npmPackages, sealCandidate, verifyCandidate } from "./release-candidate.mjs";
function fixture(t) {
const root = mkdtempSync(path.join(tmpdir(), "reasonix-candidate-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const platform of desktopPlatforms) {
const dir = path.join(root, "desktop", platform, "files");
mkdirSync(dir, { recursive: true });
const artifact = `${platform}.zip`;
writeFileSync(path.join(dir, artifact), platform);
const sha256 = createHash("sha256").update(platform).digest("hex");
writeFileSync(path.join(root, "desktop", platform, "identity.json"), JSON.stringify({
schema: 1,
platform,
sourceSHA: "a".repeat(40),
controlSHA: "b".repeat(40),
tag: "desktop-v1.2.3",
version: "v1.2.3",
channel: "stable",
signingFingerprint: "v1:fingerprint",
prefix: "desktop-123-1-preflight",
files: [{ name: artifact, size: Buffer.byteLength(platform), sha256 }],
}));
}
const cli = path.join(root, "cli");
mkdirSync(cli);
for (const name of ["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", "SHA256SUMS", "reasonix.rb"]) {
writeFileSync(path.join(cli, name), name);
}
const npm = path.join(root, "npm");
mkdirSync(npm);
for (const name of npmPackages) writeFileSync(path.join(npm, `${name}-1.2.3.tgz`), name);
const evidence = path.join(root, "evidence");
mkdirSync(evidence);
for (const kind of ["windows-amd64", "windows-arm64", "macos-universal-intel"]) {
const platform = kind === "macos-universal-intel" ? "darwin-universal" : kind;
writeFileSync(path.join(evidence, `${kind}.json`), JSON.stringify({
schema: 1,
kind,
status: "passed",
version: "v1.2.3",
sha256: createHash("sha256").update(platform).digest("hex"),
}));
}
return root;
}
const metadata = {
version: "1.2.3", sourceSHA: "a".repeat(40), buildControlSHA: "b".repeat(40),
acceptanceControlSHA: "c".repeat(40), notesSourceSHA: "a".repeat(40),
catalogSha256: "d".repeat(64), renderedNotesSha256: "e".repeat(64),
repository: "esengine/DeepSeek-Reasonix", workflow: ".github/workflows/release-candidate.yml",
runId: "123", runAttempt: "1", desktopPrefix: "desktop-123-1-preflight",
payloadArtifactId: "456", payloadArtifactName: `release-candidate-payload-${candidateId("1.2.3", "a".repeat(40), "d".repeat(64))}`,
evidenceArtifactId: "457", evidenceArtifactName: `release-candidate-evidence-${candidateId("1.2.3", "a".repeat(40), "d".repeat(64))}`,
desktopFingerprint: "v1:fingerprint", createdAt: "2026-01-01T00:00:00.000Z",
expiresAt: "2026-02-01T00:00:00.000Z",
acceptance: ["windows-amd64", "windows-arm64", "macos-universal-intel"].map(kind => ({ kind, status: "passed", evidencePath: `evidence/${kind}.json` })),
};
test("seals and verifies a complete immutable candidate", t => {
const root = fixture(t);
const record = sealCandidate(root, metadata);
assert.equal(record.candidateId, candidateId("1.2.3", "a".repeat(40), "d".repeat(64)));
assert.doesNotThrow(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")));
});
for (const mutation of ["payload", "expired", "revoked", "wrong-source", "missing-acceptance"]) {
test(`rejects ${mutation} candidate reuse`, t => {
const root = fixture(t);
const record = sealCandidate(root, metadata);
if (mutation === "payload") writeFileSync(path.join(root, "cli", "SHA256SUMS"), "changed");
if (mutation === "expired") record.validity.expiresAt = "2025-01-01T00:00:00.000Z";
if (mutation === "revoked") record.validity.revoked = true;
if (mutation === "wrong-source") record.source.repository = "fork/Reasonix";
if (mutation === "missing-acceptance") record.acceptance.pop();
assert.throws(() => verifyCandidate(root, record, new Date("2026-01-02T00:00:00Z")));
});
}
test("rejects a candidate missing a platform before sealing", t => {
const root = fixture(t);
rmSync(path.join(root, "desktop", desktopPlatforms[0]), { recursive: true });
assert.throws(() => sealCandidate(root, metadata), /missing Desktop bundle/);
});
test("rejects acceptance evidence that names bytes outside the sealed platform bundle", t => {
const root = fixture(t);
const receipt = path.join(root, "evidence", "windows-amd64.json");
const value = JSON.parse(readFileSync(receipt, "utf8"));
value.sha256 = "f".repeat(64);
writeFileSync(receipt, JSON.stringify(value));
assert.throws(() => sealCandidate(root, metadata), /does not name a sealed file/);
});
test("candidate id command accepts the catalog digest as its third argument", () => {
const output = execFileSync(process.execPath, [
fileURLToPath(new URL("./release-candidate.mjs", import.meta.url)),
"id", "1.2.3", "a".repeat(40), "d".repeat(64),
], { encoding: "utf8" }).trim();
assert.equal(output, candidateId("1.2.3", "a".repeat(40), "d".repeat(64)));
});
+141
View File
@@ -0,0 +1,141 @@
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const SHA_RE = /^[0-9a-f]{40}$/;
export const npmPackageNames = [
"reasonix",
"@reasonix/cli-darwin-arm64",
"@reasonix/cli-darwin-x64",
"@reasonix/cli-linux-arm64",
"@reasonix/cli-linux-x64",
"@reasonix/cli-win32-arm64",
"@reasonix/cli-win32-x64",
];
function compareStable(a, b) {
if (!VERSION_RE.test(a) || !VERSION_RE.test(b)) throw new Error("invalid stable version in publication observation");
const aa = a.split(".").map(Number);
const bb = b.split(".").map(Number);
for (let index = 0; index < aa.length; index += 1) {
if (aa[index] !== bb[index]) return aa[index] > bb[index] ? 1 : -1;
}
return 0;
}
function requireIdentity(version, sourceSHA, operation) {
if (!VERSION_RE.test(version)) throw new Error("invalid publication ledger version");
if (!SHA_RE.test(sourceSHA)) throw new Error("invalid publication ledger source SHA");
if (!["publish", "recover"].includes(operation)) throw new Error("invalid publication operation");
}
function releaseAssets(release, surface) {
if (release?.isDraft !== false || release?.isPrerelease !== false || !Array.isArray(release.assets)) {
throw new Error(`${surface} release is not a public final release`);
}
return release.assets.map(asset => ({
name: asset.name,
size: asset.size,
digest: asset.digest || null,
state: "identity-verified",
})).sort((a, b) => a.name.localeCompare(b.name));
}
export function createCoreLedger({ version, sourceSHA, operation, cliRelease, desktopRelease, npmPackages, observedAt = new Date().toISOString() }) {
requireIdentity(version, sourceSHA, operation);
if (!Array.isArray(npmPackages) || npmPackages.length !== npmPackageNames.length) {
throw new Error("publication ledger requires all npm packages");
}
const packages = npmPackages.map(item => {
if (!npmPackageNames.includes(item.name) || item.version !== version || !item.integrity) {
throw new Error(`invalid npm publication observation: ${item.name ?? "<unknown>"}`);
}
if ((item.reasonixCandidateSha && item.reasonixCandidateSha !== sourceSHA)
|| (item.gitHead && item.gitHead !== sourceSHA)
|| (!item.reasonixCandidateSha && !item.gitHead)) {
throw new Error(`npm package does not match the candidate: ${item.name}`);
}
const pointerComparison = compareStable(item.latest, version);
if (pointerComparison < 0 || (operation === "publish" && pointerComparison !== 0)) {
throw new Error(`npm latest is inconsistent for ${item.name}: ${item.latest}`);
}
return {
name: item.name,
version: item.version,
integrity: item.integrity,
latest: item.latest,
state: "identity-verified",
pointerState: pointerComparison === 0 ? "public-entry-updated" : "newer-entry-preserved",
};
}).sort((a, b) => a.name.localeCompare(b.name));
if (new Set(packages.map(item => item.name)).size !== npmPackageNames.length) {
throw new Error("publication ledger contains duplicate npm packages");
}
for (const name of npmPackageNames) {
if (!packages.some(item => item.name === name)) throw new Error(`publication ledger is missing npm package: ${name}`);
}
return {
schema: 1,
version,
sourceSHA,
operation,
observedAt,
surfaces: {
tags: {
state: "identity-verified",
items: [`v${version}`, `npm-v${version}`, `desktop-v${version}`].map(name => ({ name, sha: sourceSHA })),
},
cli: { state: "identity-verified", assets: releaseAssets(cliRelease, "CLI") },
npm: { state: "identity-verified", packages },
desktop: { state: "identity-verified", assets: releaseAssets(desktopRelease, "Desktop") },
},
};
}
export function createSiteLedger({ version, sourceSHA, operation, manifest, observedAt = new Date().toISOString() }) {
requireIdentity(version, sourceSHA, operation);
if (manifest?.version !== `v${version}`) throw new Error("Stable manifest does not match the publication ledger");
return {
schema: 1,
version,
sourceSHA,
operation,
observedAt,
surfaces: {
stableManifest: { state: "public-entry-updated", version: manifest.version },
homepage: { state: "public-entry-updated", version: `v${version}` },
changelog: { state: "public-entry-updated", version: `v${version}` },
homebrew: { state: "public-entry-updated", version },
},
};
}
export function mergeLedgers(core, site, observedAt = new Date().toISOString()) {
if (core.schema !== 1 || site.schema !== 1 || core.version !== site.version
|| core.sourceSHA !== site.sourceSHA || core.operation !== site.operation) {
throw new Error("publication ledger fragments do not describe one release");
}
return { ...core, observedAt, surfaces: { ...core.surfaces, ...site.surfaces } };
}
function read(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [command, ...args] = process.argv.slice(2);
if (command === "core" && args.length === 7) {
const [version, sourceSHA, operation, cliPath, desktopPath, npmPath, output] = args;
writeFileSync(output, `${JSON.stringify(createCoreLedger({ version, sourceSHA, operation, cliRelease: read(cliPath), desktopRelease: read(desktopPath), npmPackages: read(npmPath) }), null, 2)}\n`);
} else if (command === "site" && args.length === 5) {
const [version, sourceSHA, operation, manifestPath, output] = args;
writeFileSync(output, `${JSON.stringify(createSiteLedger({ version, sourceSHA, operation, manifest: read(manifestPath) }), null, 2)}\n`);
} else if (command === "merge" && args.length === 3) {
const [corePath, sitePath, output] = args;
writeFileSync(output, `${JSON.stringify(mergeLedgers(read(corePath), read(sitePath)), null, 2)}\n`);
} else {
throw new Error("usage: release-publication-ledger.mjs core VERSION SHA OPERATION CLI DESKTOP NPM OUTPUT | site VERSION SHA OPERATION MANIFEST OUTPUT | merge CORE SITE OUTPUT");
}
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createCoreLedger, createSiteLedger, mergeLedgers, npmPackageNames } from "./release-publication-ledger.mjs";
const sha = "a".repeat(40);
const release = { isDraft: false, isPrerelease: false, assets: [{ name: "asset.zip", size: 42, digest: "sha256:abc" }] };
const packages = latest => npmPackageNames.map(name => ({
name,
version: "1.2.3",
reasonixCandidateSha: sha,
gitHead: sha,
integrity: "sha512-example",
latest,
}));
test("records immutable files and exact current pointers", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.2.3"),
});
assert.equal(core.surfaces.tags.items.length, 3);
assert.ok(core.surfaces.npm.packages.every(item => item.pointerState === "public-entry-updated"));
assert.equal(core.surfaces.cli.assets[0].digest, "sha256:abc");
});
test("recovery preserves newer public pointers", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "recover",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.3.0"),
});
assert.ok(core.surfaces.npm.packages.every(item => item.pointerState === "newer-entry-preserved"));
assert.throws(() => createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.3.0"),
}), /npm latest is inconsistent/);
});
test("site evidence merges only for the same candidate", () => {
const core = createCoreLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish",
cliRelease: release, desktopRelease: release, npmPackages: packages("1.2.3"),
});
const site = createSiteLedger({
version: "1.2.3", sourceSHA: sha, operation: "publish", manifest: { version: "v1.2.3" },
});
assert.equal(mergeLedgers(core, site).surfaces.homepage.state, "public-entry-updated");
assert.throws(() => mergeLedgers(core, { ...site, sourceSHA: "b".repeat(40) }), /one release/);
});
+16 -48
View File
@@ -1,58 +1,26 @@
#!/usr/bin/env bash
# Validate the exact remote main-v2 candidate, then push the three immutable
# Stable tags atomically. The v* tag activates the protected Stable relay.
# Dispatch publication or recovery for a previously sealed release candidate.
# Tag creation belongs to the approved protected workflow, after provenance and
# artifact bytes have been verified.
set -euo pipefail
if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "usage: scripts/release-stable.sh MAJOR.MINOR.PATCH" >&2
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || [[ ! "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-[0-9a-f]{12}-[0-9a-f]{12}$ ]]; then
echo "usage: scripts/release-stable.sh CANDIDATE_ID [publish|recover]" >&2
exit 2
fi
version="$1"
remote="${RELEASE_REMOTE:-origin}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
candidate_id="$1"
operation="${2:-publish}"
case "$operation" in publish | recover) ;; *) echo "operation must be publish or recover" >&2; exit 2 ;; esac
repository="${RELEASE_REPOSITORY:-esengine/DeepSeek-Reasonix}"
for command in git gh jq node; do
command -v "$command" >/dev/null || {
echo "required command is unavailable: $command" >&2
exit 2
}
for command in gh jq; do
command -v "$command" >/dev/null || { echo "required command is unavailable: $command" >&2; exit 2; }
done
candidate="$(git ls-remote --heads "$remote" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')"
if [[ ! "$candidate" =~ ^[0-9a-f]{40}$ ]]; then
echo "cannot resolve $remote/main-v2" >&2
exit 1
fi
git fetch --quiet --no-tags "$remote" refs/heads/main-v2
bash "$script_dir/validate-stable-candidate.sh" "$version" "$candidate"
payload="$(jq -cn --arg ref main-v2 --arg candidate_id "$candidate_id" --arg operation "$operation" \
'{ref: $ref, inputs: {candidate_id: $candidate_id, operation: $operation}}')"
gh api -X POST "repos/$repository/actions/workflows/release-promote.yml/dispatches" --input - <<<"$payload"
tags=("v$version" "npm-v$version" "desktop-v$version")
for tag in "${tags[@]}"; do
if git ls-remote --exit-code --tags --refs "$remote" "refs/tags/$tag" >/dev/null 2>&1; then
echo "release tag already exists and will not be moved: $tag" >&2
exit 1
fi
done
bash "$script_dir/verify-release-push-ci.sh" "$candidate"
# Include a no-op main-v2 update in the same atomic transaction. If main-v2
# advanced while CI was running, this refspec becomes a non-fast-forward update
# and the server rejects every tag instead of burning an unreleasable version.
git push --atomic "$remote" \
"$candidate:refs/heads/main-v2" \
"$candidate:refs/tags/${tags[0]}" \
"$candidate:refs/tags/${tags[1]}" \
"$candidate:refs/tags/${tags[2]}"
for tag in "${tags[@]}"; do
remote_sha="$(git ls-remote --tags --refs "$remote" "refs/tags/$tag" | awk 'NR == 1 { print $1 }')"
if [ "$remote_sha" != "$candidate" ]; then
echo "$tag resolved to ${remote_sha:-missing}; expected $candidate" >&2
exit 1
fi
done
echo "Stable tags pushed atomically at $candidate: ${tags[*]}"
echo "Release stable will request one release-environment approval."
echo "Dispatched $operation for $candidate_id on protected main-v2."
echo "The workflow verifies the sealed candidate, then requests one release approval."
+22 -166
View File
@@ -2,182 +2,38 @@
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
release_script="$repo_root/scripts/release-stable.sh"
test_root="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-stable-tag-test.XXXXXX")"
root="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-stable-dispatch-test.XXXXXX")"
cleanup() {
case "$test_root" in
*/reasonix-stable-tag-test.*) rm -rf -- "$test_root" ;;
*) echo "refusing to clean unexpected test directory: $test_root" >&2 ;;
esac
case "$root" in */reasonix-stable-dispatch-test.*) rm -rf -- "$root" ;; *) exit 1 ;; esac
}
trap cleanup EXIT
mkdir -p "$test_root/bin"
cat >"$test_root/bin/gh" <<'EOF'
mkdir -p "$root/bin"
cat >"$root/bin/gh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ -n "${FAKE_ADVANCE_WORK:-}" ] && [ ! -e "$FAKE_ADVANCE_WORK/.advanced-by-fake-gh" ]; then
: >"$FAKE_ADVANCE_WORK/.advanced-by-fake-gh"
git -C "$FAKE_ADVANCE_WORK" add .advanced-by-fake-gh
git -C "$FAKE_ADVANCE_WORK" commit -q -m "advance main during CI"
git -C "$FAKE_ADVANCE_WORK" push -q origin main-v2
fi
# Answer for the commit the verifier asked about: the candidate itself, or a
# release-notes-only candidate's code ancestor.
sha="$FAKE_CANDIDATE"
for i in "$@"; do
if [ "${prev:-}" = "--commit" ]; then sha="$i"; fi
prev="$i"
done
conclusion="${FAKE_GH_CONCLUSION:-success}"
if [ -n "${FAKE_GH_FAIL_SHA:-}" ] && [ "$sha" = "$FAKE_GH_FAIL_SHA" ]; then conclusion=failure; fi
if [ "$conclusion" = pending ]; then
printf '[{"headSha":"%s","status":"in_progress","conclusion":""}]\n' "$sha"
else
printf '[{"headSha":"%s","status":"completed","conclusion":"%s"}]\n' "$sha" "$conclusion"
fi
body="$(cat)"
test "$1" = api
test "$2" = -X
test "$3" = POST
test "$4" = "repos/esengine/DeepSeek-Reasonix/actions/workflows/release-promote.yml/dispatches"
jq -e '.ref == "main-v2" and .inputs.candidate_id == "v1.2.3-aaaaaaaaaaaa-bbbbbbbbbbbb" and (.inputs.operation == "publish" or .inputs.operation == "recover")' <<<"$body" >/dev/null
EOF
chmod +x "$test_root/bin/gh"
chmod +x "$root/bin/gh"
make_remote() {
local name="$1"
local work="$test_root/$name-work"
local remote="$test_root/$name.git"
git init -q --bare "$remote"
git init -q -b main-v2 "$work"
git -C "$work" config user.name test
git -C "$work" config user.email [email protected]
mkdir -p "$work/release-notes"
jq '.releases |= map(select(.version != "1.19.2"))' \
"$repo_root/release-notes/releases.json" >"$work/release-notes/releases.json"
git -C "$work" add release-notes/releases.json
git -C "$work" commit -q -m base
cp "$repo_root/release-notes/releases.json" "$work/release-notes/releases.json"
git -C "$work" add release-notes/releases.json
git -C "$work" commit -q -m "reviewed release notes"
git -C "$work" remote add origin "$remote"
git -C "$work" push -q origin main-v2
printf '%s\n' "$work"
}
candidate=v1.2.3-aaaaaaaaaaaa-bbbbbbbbbbbb
PATH="$root/bin:$PATH" "$repo_root/scripts/release-stable.sh" "$candidate"
PATH="$root/bin:$PATH" "$repo_root/scripts/release-stable.sh" "$candidate" recover
success_work="$(make_remote success)"
success_sha="$(git -C "$success_work" rev-parse HEAD)"
(
cd "$success_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$success_sha" \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_REMOTE=origin \
"$release_script" 1.19.2
)
for tag in v1.19.2 npm-v1.19.2 desktop-v1.19.2; do
[ "$(git ls-remote --tags --refs "$test_root/success.git" "refs/tags/$tag" | awk 'NR == 1 { print $1 }')" = "$success_sha" ]
for invalid in 1.2.3 v1.2.3-short v1.2.3-aaaaaaaaaaaa-bbbbbbbbbbbz; do
if PATH="$root/bin:$PATH" "$repo_root/scripts/release-stable.sh" "$invalid" >/dev/null 2>&1; then
echo "invalid candidate unexpectedly dispatched: $invalid" >&2
exit 1
fi
done
# Advancing main after the atomic tag transaction must not invalidate the
# immutable Notes candidate selected by the protected relay.
git clone -q -b main-v2 "$test_root/success.git" "$test_root/success-control"
git -C "$test_root/success-control" config user.name test
git -C "$test_root/success-control" config user.email [email protected]
git -C "$test_root/success-control" commit --allow-empty -q -m "advance after tags"
git -C "$test_root/success-control" push -q origin main-v2
(
cd "$test_root/success-control"
GITHUB_OUTPUT="$test_root/success-control.out" RELEASE_REMOTE=origin \
RELEASE_TAG=v1.19.2 ALLOW_STABLE_RECOVERY=false \
"$repo_root/scripts/resolve-stable-release.sh"
)
grep -Eq '^sha='"$success_sha"'$' "$test_root/success-control.out"
(
cd "$success_work"
GITHUB_OUTPUT="$test_root/stale-control.out" RELEASE_REMOTE=origin \
RELEASE_TAG=v1.19.2 ALLOW_STABLE_RECOVERY=false \
"$repo_root/scripts/resolve-stable-release.sh"
)
grep -Eq '^sha='"$success_sha"'$' "$test_root/stale-control.out"
failure_work="$(make_remote failure)"
failure_sha="$(git -C "$failure_work" rev-parse HEAD)"
if (
cd "$failure_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$failure_sha" FAKE_GH_CONCLUSION=failure \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_REMOTE=origin \
"$release_script" 1.19.2
); then
echo "failed CI unexpectedly created release tags" >&2
exit 1
fi
[ -z "$(git ls-remote --tags --refs "$test_root/failure.git" 'refs/tags/*')" ]
# A later code commit is not the reviewed Notes merge and must never become the
# release candidate merely because it contains the older reviewed record.
stale_notes_work="$(make_remote stale-notes)"
git -C "$stale_notes_work" commit --allow-empty -q -m "later product change"
git -C "$stale_notes_work" push -q origin main-v2
stale_notes_sha="$(git -C "$stale_notes_work" rev-parse HEAD)"
if (
cd "$stale_notes_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$stale_notes_sha" \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_REMOTE=origin \
"$release_script" 1.19.2
); then
echo "candidate after the reviewed Notes commit unexpectedly created release tags" >&2
exit 1
fi
[ -z "$(git ls-remote --tags --refs "$test_root/stale-notes.git" 'refs/tags/*')" ]
# If main advances while exact-SHA CI is being checked, the no-op main-v2
# refspec makes the atomic push reject all three tags.
race_work="$(make_remote race)"
race_sha="$(git -C "$race_work" rev-parse HEAD)"
git clone -q -b main-v2 "$test_root/race.git" "$test_root/race-advance"
git -C "$test_root/race-advance" config user.name test
git -C "$test_root/race-advance" config user.email [email protected]
if (
cd "$race_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$race_sha" \
FAKE_ADVANCE_WORK="$test_root/race-advance" RELEASE_CI_WAIT_SECONDS=0 \
RELEASE_REMOTE=origin "$release_script" 1.19.2
); then
echo "main-v2 advance during CI unexpectedly created release tags" >&2
exit 1
fi
[ -z "$(git ls-remote --tags --refs "$test_root/race.git" 'refs/tags/*')" ]
[ "$(git ls-remote "$test_root/race.git" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')" != "$race_sha" ]
occupied_work="$(make_remote occupied)"
occupied_sha="$(git -C "$occupied_work" rev-parse HEAD)"
git -C "$occupied_work" tag v1.19.2
git -C "$occupied_work" push -q origin v1.19.2
if (
cd "$occupied_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$occupied_sha" \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_REMOTE=origin \
"$release_script" 1.19.2
); then
echo "occupied release identity unexpectedly passed" >&2
exit 1
fi
[ -z "$(git ls-remote --tags --refs "$test_root/occupied.git" refs/tags/npm-v1.19.2 refs/tags/desktop-v1.19.2)" ]
if "$release_script" 1.19.2-preview.1 >/dev/null 2>&1; then
echo "Preview version unexpectedly passed the Stable tag helper" >&2
if PATH="$root/bin:$PATH" "$repo_root/scripts/release-stable.sh" "$candidate" retry >/dev/null 2>&1; then
echo "invalid operation unexpectedly dispatched" >&2
exit 1
fi
# The reviewed Notes commit changes only release-notes/, so its own push run
# skips the code matrix. The candidate's code is its first parent's; if that
# ancestor's push CI failed, the release must not be tagged.
ancestor_work="$(make_remote ancestor)"
ancestor_sha="$(git -C "$ancestor_work" rev-parse HEAD)"
ancestor_code_sha="$(git -C "$ancestor_work" rev-parse HEAD^)"
if (
cd "$ancestor_work"
PATH="$test_root/bin:$PATH" FAKE_CANDIDATE="$ancestor_sha" FAKE_GH_FAIL_SHA="$ancestor_code_sha" \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_REMOTE=origin \
"$release_script" 1.19.2
); then
echo "release-notes-only candidate with red code ancestor unexpectedly created release tags" >&2
exit 1
fi
[ -z "$(git ls-remote --tags --refs "$test_root/ancestor.git" 'refs/tags/*')" ]
echo "stable release tag helper tests: PASS"
echo "stable candidate dispatch tests: PASS"
+88 -54
View File
@@ -53,16 +53,40 @@ assert 'args+=(--latest=false)' in manual_publish
assert 'name: Sign artifacts (minisign)' in desktop
PY
[ "$(grep -Ec '^ environment: release$' "$repo_root/.github/workflows/release-stable.yml")" = "1" ]
relay="$repo_root/.github/workflows/release-stable-trigger.yml"
grep -Eq 'actions: write' "$relay"
grep -Eq "CONTROL_PLANE_REF.*default_branch" "$relay"
grep -Eq "CONTROL_PLANE_REF !== 'main-v2'" "$relay"
grep -Eq 'createWorkflowDispatch' "$relay"
grep -Eq 'ref: process\.env\.CONTROL_PLANE_REF' "$relay"
grep -Eq "workflow_id: 'release-stable\.yml'" \
"$repo_root/.github/workflows/release-stable-trigger.yml"
grep -Eq "allow_recovery: 'false'" \
"$repo_root/.github/workflows/release-stable-trigger.yml"
test ! -e "$repo_root/.github/workflows/release-stable-trigger.yml"
candidate="$repo_root/.github/workflows/release-candidate.yml"
promote="$repo_root/.github/workflows/release-promote.yml"
verify="$repo_root/.github/workflows/release-verify.yml"
for workflow in "$candidate" "$promote" "$verify"; do test -s "$workflow"; done
candidate_dispatch="$(sed -n '/workflow_dispatch:/,/^ push:/p' "$candidate")"
[ "$(grep -Ec '^ [a-z_]+:$' <<<"$candidate_dispatch")" = "1" ]
! grep -Fq 'inputs.source_sha' "$candidate"
! grep -Fq 'pull_request_target' "$candidate"
grep -Fq 'branches: [main-v2]' "$candidate"
grep -Fq -- '- release-notes/releases.json' "$candidate"
grep -Fq 'actions/attest-build-provenance@v3' "$candidate"
grep -Fq 'bash scripts/validate-release-control-plane.sh' "$candidate"
grep -Fq 'retention-days: 30' "$candidate"
grep -Fq 'retention-days: 90' "$candidate"
grep -Fq 'name: release-candidate-evidence-${{ needs.resolve.outputs.candidate_id }}' "$candidate"
grep -Fq 'RELEASE_EVIDENCE_ARTIFACT_ID: ${{ steps.evidence.outputs.artifact-id }}' "$candidate"
grep -Fq 'gh attestation verify' "$promote"
grep -Fq 'git push --atomic origin' "$promote"
[ "$(grep -Ec '^ environment: release$' "$promote")" = "1" ]
grep -Fq 'group: stable-release-publication' "$promote"
grep -Fq 'VERIFY_PUBLIC_SITE_ONLY: "true"' "$promote"
grep -Fq 'release-publication-ledger-' "$promote"
grep -Fq 'release-publication-timing-' "$promote"
grep -Fq 'release-candidate-timing-' "$candidate"
grep -Fq 'immutable-complete-newer-pointer-preserved' "$promote"
grep -Fq 'RELEASE_REVOKED_CANDIDATES' "$candidate"
grep -Fq 'PUSH_SHA: ${{ github.sha }}' "$candidate"
grep -Fq 'node scripts/resolve-release-candidate.mjs active "$candidate_id"' "$candidate"
grep -Fq 'The sealed record exists but its payload expired; preparing fresh files.' "$candidate"
grep -Fq 'RELEASE_REVOKED_CANDIDATES' "$promote"
grep -Fq 'run: bash scripts/verify-stable-release-artifacts.sh' "$verify"
grep -Fq 'RELEASE_OPERATION: recover' "$verify"
grep -Fq -- '--dump-dom' "$repo_root/scripts/verify-stable-release-artifacts.sh"
grep -Eq 'ALLOW_STABLE_RECOVERY:.*inputs\.allow_recovery' \
"$repo_root/.github/workflows/release-stable.yml"
grep -Fq 'bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"' \
@@ -73,13 +97,19 @@ grep -Fq 'if: ${{ !inputs.allow_recovery }}' \
"$repo_root/.github/workflows/release-stable.yml"
test -x "$repo_root/scripts/validate-stable-candidate.sh"
test -x "$repo_root/scripts/verify-release-push-ci.sh"
test -x "$repo_root/scripts/validate-release-control-plane.sh"
test -x "$repo_root/scripts/finalize-windows-signed-candidate.sh"
for retired in release-preview.yml release-cli-trigger.yml release-desktop-trigger.yml; do
test ! -e "$repo_root/.github/workflows/$retired"
done
! sed -n '/^on:/,/^permissions:/p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Eq 'push:|npm-v\*-\*'
npm_events="$(sed -n '/^on:/,/^permissions:/p' "$repo_root/.github/workflows/release-npm.yml")"
! grep -Eq 'push:|npm-v\*-\*' <<<"$npm_events"
grep -Eq '^name: Prepare release$' "$repo_root/.github/workflows/prepare-release-notes.yml"
[ "$(sed -n '/workflow_dispatch:/,/permissions:/p' "$repo_root/.github/workflows/prepare-release-notes.yml" | grep -Ec '^ [a-z_]+:$')" = "1" ]
[ "$(grep -Fc 'git merge --no-edit origin/main-v2' "$repo_root/.github/workflows/prepare-release-notes.yml")" = "0" ]
grep -Fq 'RELEASE_NOTES_SOURCE_SHA=$source_sha' "$repo_root/.github/workflows/prepare-release-notes.yml"
grep -Fq -- '--to "$RELEASE_NOTES_SOURCE_SHA"' "$repo_root/.github/workflows/prepare-release-notes.yml"
notes_dispatch="$(sed -n '/workflow_dispatch:/,/permissions:/p' "$repo_root/.github/workflows/prepare-release-notes.yml")"
[ "$(grep -Ec '^ [a-z_]+:$' <<<"$notes_dispatch")" = "1" ]
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' \
"$repo_root/.github/workflows/prepare-release-notes.yml"
if grep -Eq '^ push:$' "$repo_root/.github/workflows/release-stable.yml" ||
@@ -94,8 +124,14 @@ 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-\{1\}\.yml' "$repo_root/.github/workflows/$workflow"
done
for workflow in release.yml release-npm.yml; do
grep -Eq "needs\.cache-guard\.result == 'success'" "$repo_root/.github/workflows/$workflow"
done
if grep -Eq '^ cache-guard:|needs\.cache-guard' "$repo_root/.github/workflows/release-desktop.yml"; then
echo "Desktop must reuse the orchestrator cache/docs evidence instead of executing candidate guard scripts twice" >&2
exit 1
fi
grep -Eq 'options: \[stable\]' "$repo_root/.github/workflows/release.yml"
grep -A6 -E '^ channel:' "$repo_root/.github/workflows/release.yml" |
grep -Eq 'default: stable'
@@ -139,8 +175,8 @@ git -C "$test_root/product-checkout" check-ignore -q release-control/
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 'options: \[stable\]' "$repo_root/.github/workflows/release-desktop.yml"
if sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-desktop.yml" |
grep -Eqi 'preview|canary'; then
desktop_dispatch="$(sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-desktop.yml")"
if grep -Eqi 'preview|canary' <<<"$desktop_dispatch"; then
echo "Standalone Desktop dispatch must not expose a Preview or Canary choice" >&2
exit 1
fi
@@ -148,8 +184,10 @@ grep -Eq '^ resolve:$' "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq 'sha:.*steps\.candidate\.outputs\.sha' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'bash scripts/resolve-desktop-candidate.sh' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'name: Smoke-test packaged Electron startup' "$repo_root/.github/workflows/release-desktop.yml"
sed -n '/name: Smoke-test packaged Electron startup/,/name: Upload Windows signing inputs/p' \
"$repo_root/.github/workflows/release-desktop.yml" | grep -Fq "if: runner.os == 'Windows'"
grep -Eq '^ windows-build:$' "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq '^ windows-sign:$' "$repo_root/.github/workflows/release-desktop.yml"
sed -n '/^ windows-sign:/,/^ windows-runtime-acceptance:/p' \
"$repo_root/.github/workflows/release-desktop.yml" | grep -Fq 'needs: [resolve, windows-build, signing-contract]'
grep -Fq 'desktop/build/electron/${{ matrix.name }}/app' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'node desktop/packaging/smoke.mjs' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq -- '--service desktop/build/bin/reasonix-desktop.exe' "$repo_root/.github/workflows/release-desktop.yml"
@@ -198,31 +236,29 @@ signing_upload_line="$(grep -n -m1 'name: Upload Windows signing inputs' "$repo_
[ "$(grep -Fc 'IN_ORCHESTRATOR: ${{ inputs.orchestrator }}' "$repo_root/.github/workflows/release-desktop.yml")" = "3" ]
[ "$(grep -Fc 'name: Revalidate immutable Desktop candidate' "$repo_root/.github/workflows/release-desktop.yml")" = "2" ]
[ "$(grep -Fc 'ref: ${{ needs.resolve.outputs.sha }}' "$repo_root/.github/workflows/release-desktop.yml")" -ge 4 ]
[ "$(grep -Ec '^ path: release-control$' "$repo_root/.github/workflows/release-desktop.yml")" = "5" ]
[ "$(grep -Ec '^ path: release-control$' "$repo_root/.github/workflows/release-desktop.yml")" -ge "5" ]
grep -Fq 'name: Checkout protected release verifier' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'scripts/desktop-release-artifacts.mjs' "$repo_root/.github/workflows/release-desktop.yml"
if grep -Fq 'test-webview2-native-smoke.ps1' "$repo_root/.github/workflows/release-desktop.yml"; then
echo "Desktop release must smoke the packaged Electron shell, not the Wails/WebView2 harness" >&2
exit 1
fi
grep -Fq './release-control/scripts/verify-windows-authenticode.ps1' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'finalize-windows-signed-candidate.sh' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'verify-windows-authenticode.ps1' "$repo_root/scripts/finalize-windows-signed-candidate.sh"
[ "$(grep -Fc 'ref: ${{ github.workflow_sha }}' "$repo_root/.github/workflows/release-desktop.yml")" -ge 2 ]
[ "$(grep -Fc 'bash release-control/scripts/resolve-desktop-candidate.sh' "$repo_root/.github/workflows/release-desktop.yml")" = "2" ]
[ "$(grep -Fc 'RELEASE_TAG: ${{ inputs.approved_cli_tag }}' "$repo_root/.github/workflows/release-desktop.yml")" = "3" ]
if sed -n '/^ mirror:/,$p' "$repo_root/.github/workflows/release-desktop.yml" |
grep -Fq 'RELEASE_TAG: ${{ inputs.tag }}'; then
desktop_mirror="$(sed -n '/^ mirror:/,$p' "$repo_root/.github/workflows/release-desktop.yml")"
if grep -Fq 'RELEASE_TAG: ${{ inputs.tag }}' <<<"$desktop_mirror"; then
echo "Desktop mirror must revalidate the orchestrator-approved CLI tag" >&2
exit 1
fi
if sed -n '/name: publish release/,/name: mirror to R2/p' \
"$repo_root/.github/workflows/release-desktop.yml" |
grep -Eq 'bash scripts/(resolve-desktop-candidate|validate-desktop-release-manifest|publish-desktop-github-release)\.sh'; then
desktop_publish="$(sed -n '/name: publish release/,/name: mirror to R2/p' "$repo_root/.github/workflows/release-desktop.yml")"
if grep -Eq 'bash scripts/(resolve-desktop-candidate|validate-desktop-release-manifest|publish-desktop-github-release)\.sh' <<<"$desktop_publish"; then
echo "Desktop publish job uses candidate-controlled release scripts" >&2
exit 1
fi
if sed -n '/name: mirror to R2/,$p' \
"$repo_root/.github/workflows/release-desktop.yml" |
grep -Eq 'bash scripts/(resolve-desktop-candidate|validate-desktop-release-manifest|verify-desktop-release-directory|decide-desktop-pointer-update)\.sh'; then
if grep -Eq 'bash scripts/(resolve-desktop-candidate|validate-desktop-release-manifest|verify-desktop-release-directory|decide-desktop-pointer-update)\.sh' <<<"$desktop_mirror"; then
echo "Desktop mirror job uses candidate-controlled release scripts" >&2
exit 1
fi
@@ -239,28 +275,24 @@ grep -Eq 'IN_SIGNING_PREFLIGHT:.*inputs\.signing_preflight' \
"$repo_root/.github/workflows/release-desktop.yml"
grep -Fq "group: release-npm-\${{ inputs.channel || 'next' }}" \
"$repo_root/.github/workflows/release-npm.yml"
sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Eq 'default: stable'
if sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Eq '^ - canary$'; then
npm_dispatch="$(sed -n '/^ workflow_dispatch:/,/^ workflow_call:/p' "$repo_root/.github/workflows/release-npm.yml")"
grep -Eq 'default: stable' <<<"$npm_dispatch"
if grep -Eq '^ - canary$' <<<"$npm_dispatch"; then
echo "Standalone npm dispatch must not expose public Canary publication" >&2
exit 1
fi
grep -Fq 'if: ${{ !inputs.orchestrated }}' "$repo_root/.github/workflows/release-npm.yml"
grep -Fq 'Publish or recover immutable npm packages' "$repo_root/.github/workflows/release-npm.yml"
if sed -n '/^ cache-guard:/,/^ npm:/p' \
"$repo_root/.github/workflows/release-npm.yml" |
grep -Fq 'RECOVERY_CONTROL_SHA'; then
npm_cache_guard="$(sed -n '/^ cache-guard:/,/^ npm:/p' "$repo_root/.github/workflows/release-npm.yml")"
if grep -Fq 'RECOVERY_CONTROL_SHA' <<<"$npm_cache_guard"; then
echo "npm recovery control plane must load in the publisher job after candidate checkout" >&2
exit 1
fi
sed -n '/^ npm:/,$p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Fq 'RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}'
sed -n '/^ npm:/,$p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Fq 'git restore --source="$RECOVERY_CONTROL_SHA"'
npm_job="$(sed -n '/^ npm:/,$p' "$repo_root/.github/workflows/release-npm.yml")"
grep -Fq 'RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}' <<<"$npm_job"
grep -Fq 'git restore --source="$RECOVERY_CONTROL_SHA"' <<<"$npm_job"
for recovery_script in npm/publish.mjs scripts/finalize-npm-official-release.mjs; do
sed -n '/^ npm:/,$p' "$repo_root/.github/workflows/release-npm.yml" |
grep -Fq "$recovery_script"
grep -Fq "$recovery_script" <<<"$npm_job"
done
# Orchestrated Stable recovery needs the same protected publisher repair as a
# standalone run; the immutable product checkout must not select the old helper.
@@ -282,9 +314,9 @@ grep -Eq "needs\.build\.result == 'success'.*!inputs\.production_signing_smoke.*
"$repo_root/.github/workflows/release-desktop.yml"
! grep -Eq 'signpath/github-action-submit-signing-request|secrets.SIGNPATH_API_TOKEN|complete-signpath-request\.ps1' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'uses: ./release-control/.github/actions/setup-certum' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'scripts/sign-certum.ps1 -PayloadDirectory signed-payload' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'scripts/sign-certum.ps1 -FilePath' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq -- '-ExpectedThumbprint $env:CERTUM_KEY_ID -RequireTrusted' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'sign-certum.ps1" -PayloadDirectory' "$repo_root/scripts/finalize-windows-signed-candidate.sh"
grep -Fq 'sign-certum.ps1" -FilePath' "$repo_root/scripts/finalize-windows-signed-candidate.sh"
grep -Fq -- '-ExpectedThumbprint "$CERTUM_KEY_ID"' "$repo_root/scripts/finalize-windows-signed-candidate.sh"
grep -Eq '^ signpath-preflight:$' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'signing_preflight: true' "$repo_root/.github/workflows/release-stable.yml"
grep -Eq 'signing_preflight_verified: true' "$repo_root/.github/workflows/release-stable.yml"
@@ -393,12 +425,13 @@ grep -Eq 'name: Decide whether CLI artifacts need publication' "$cli_release_wor
grep -Fq 'path: release-control' "$cli_release_workflow"
grep -Fq 'ref: ${{ github.workflow_sha }}' "$cli_release_workflow"
grep -Fq 'release-control/scripts/decide-cli-release-publication.sh' "$cli_release_workflow"
if sed -n '/^ goreleaser:/,$p' "$cli_release_workflow" |
grep -Fq 'bash scripts/decide-cli-release-publication.sh'; then
cli_goreleaser="$(sed -n '/^ goreleaser:/,$p' "$cli_release_workflow")"
if grep -Fq 'bash scripts/decide-cli-release-publication.sh' <<<"$cli_goreleaser"; then
echo "CLI recovery uses candidate-controlled publication policy" >&2
exit 1
fi
grep -Fq "if: \${{ steps.publication.outputs.decision == 'publish' }}" "$cli_release_workflow"
grep -Fq "steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == ''" "$cli_release_workflow"
grep -Fq "steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name != ''" "$cli_release_workflow"
grep -Fq 'immutable CLI release metadata for $TAG already exists with different content' "$cli_release_workflow"
grep -Fq 'cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json' "$cli_release_workflow"
grep -Eq 'internal CLI release .*Stable and Preview pointers remain unchanged' "$cli_release_workflow"
@@ -1246,7 +1279,7 @@ grep -Fq 'branch="release-notes/v${VERSION}"' "$prepare_notes"
grep -Fq 'GitHub Actions could not open the PR; the reviewed branch is preserved.' "$prepare_notes"
grep -Fq 'gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill' "$prepare_notes"
grep -Eq 'GITHUB_STEP_SUMMARY' "$prepare_notes"
grep -Fq 'node scripts/generate-release-notes.mjs --version "$VERSION" --to origin/main-v2' "$prepare_notes"
grep -Fq 'node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"' "$prepare_notes"
grep -Fq 'thinking: { type: "disabled" }' "$generate_notes"
desktop_candidate_resolver="$repo_root/scripts/resolve-desktop-candidate.sh"
@@ -1410,7 +1443,7 @@ git clone -q "$test_root/remote.git" "$test_root/repo"
echo "stale recovery workflow unexpectedly passed release authorization" >&2
exit 1
fi
grep -Eq 'recovery caller workflow SHA is' "$test_root/stale-workflow.log"
grep -Eq 'protected 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' \
@@ -1673,9 +1706,9 @@ bash "$repo_root/scripts/release-stable.test.sh"
bash "$repo_root/scripts/check-cache-impact.test.sh"
bash "$repo_root/scripts/check-docs-impact.test.sh"
# Every current publisher must gate on the same compiled docs identity, and
# each build path must stamp that identity into its shipped binary.
for workflow in release.yml release-npm.yml release-desktop.yml; do
# Each build orchestrator must gate on the compiled docs identity once. The
# Desktop child consumes the candidate evidence instead of rerunning the guard.
for workflow in release.yml release-npm.yml release-candidate.yml; do
grep -Fq 'bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION"' \
"$repo_root/.github/workflows/$workflow"
done
@@ -1684,9 +1717,10 @@ grep -Fq 'reasonix/internal/productdocs.linkedRevision={{ .Commit }}' "$repo_roo
# The Homebrew cask must keep stripping quarantine from the unsigned CLI, but
# through Homebrew's current postflight_steps stanza, never the deprecated
# `postflight do` that GoReleaser's hooks field renders.
sed -n '/^homebrew_casks:/,/^release:/p' "$repo_root/.goreleaser.yaml" | grep -Fq 'postflight_steps do'
sed -n '/^homebrew_casks:/,/^release:/p' "$repo_root/.goreleaser.yaml" | grep -Fq 'com.apple.quarantine'
! sed -n '/^homebrew_casks:/,/^release:/p' "$repo_root/.goreleaser.yaml" | grep -Eq '^\s+hooks:|^\s+post:'
homebrew_config="$(sed -n '/^homebrew_casks:/,/^release:/p' "$repo_root/.goreleaser.yaml")"
grep -Fq 'postflight_steps do' <<<"$homebrew_config"
grep -Fq 'com.apple.quarantine' <<<"$homebrew_config"
! grep -Eq '^\s+hooks:|^\s+post:' <<<"$homebrew_config"
grep -Fq 'reasonix/internal/productdocs.linkedVersion=${binaryVersion}' "$repo_root/npm/build.mjs"
grep -Fq 'product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION' \
"$repo_root/scripts/desktop-build.sh"
+25 -6
View File
@@ -9,6 +9,7 @@ channel="${RELEASE_CHANNEL:?RELEASE_CHANNEL is required}"
tag="${RELEASE_TAG:?RELEASE_TAG is required}"
orchestrated="${IN_ORCHESTRATED:-false}"
orchestrator="${IN_ORCHESTRATOR:-}"
candidate_preparation="${CANDIDATE_PREPARATION:-false}"
approved_sha="${APPROVED_SHA:-}"
caller_event="${CALLER_EVENT_NAME:-}"
caller_ref="${CALLER_REF:-}"
@@ -28,17 +29,28 @@ true | false) ;;
esac
if [ "$orchestrated" = "true" ]; then
case "$orchestrator" in
stable | preview) ;;
stable | preview | candidate | promote) ;;
*)
echo "::error::IN_ORCHESTRATOR must be stable or preview for an orchestrated Desktop release, got: $orchestrator" >&2
exit 2
;;
esac
if [ "$orchestrator" != "$channel" ]; then
if [ "$orchestrator" != "$channel" ] && [ "$orchestrator" != candidate ] && [ "$orchestrator" != promote ]; then
echo "::error::the $orchestrator orchestrator cannot authorize a Desktop $channel candidate" >&2
exit 1
fi
fi
case "$candidate_preparation" in
true | false) ;;
*)
echo "::error::CANDIDATE_PREPARATION must be true or false, got: $candidate_preparation" >&2
exit 2
;;
esac
if [ "$candidate_preparation" = true ] && { [ "$orchestrated" != true ] || [ "$orchestrator" != candidate ] || [ "$channel" != stable ]; }; then
echo "::error::candidate preparation requires the protected candidate orchestrator" >&2
exit 1
fi
case "$require_current_main" in
true | false) ;;
*)
@@ -113,10 +125,17 @@ if ! git merge-base --is-ancestor "$candidate" "$main_sha"; then
fi
if [ "$channel" = "stable" ]; then
tag_sha="$(git rev-parse "$tag^{commit}")"
if [ "$tag_sha" != "$candidate" ]; then
echo "::error::$tag points to $tag_sha, expected Desktop candidate $candidate" >&2
exit 1
if [ "$candidate_preparation" = true ]; then
if git show-ref --verify --quiet "refs/tags/$tag"; then
echo "::error::candidate preparation refuses an existing release tag: $tag" >&2
exit 1
fi
else
tag_sha="$(git rev-parse "$tag^{commit}")"
if [ "$tag_sha" != "$candidate" ]; then
echo "::error::$tag points to $tag_sha, expected Desktop candidate $candidate" >&2
exit 1
fi
fi
elif git show-ref --verify --quiet "refs/tags/$tag"; then
echo "::error::Desktop Preview uses an immutable asset directory, not a Git tag: $tag" >&2
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync } from "node:fs";
import { appendFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ID_RE = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-[0-9a-f]{12}-[0-9a-f]{12}$/;
export function requireNotRevoked(candidateId, value = "") {
const revoked = new Set(String(value).split(/[\s,]+/).filter(Boolean));
if (revoked.has(candidateId)) throw new Error(`release candidate is revoked: ${candidateId}`);
}
export function selectRecordArtifact(artifacts, candidateId, required = true) {
if (!ID_RE.test(candidateId)) throw new Error("invalid release candidate id");
const name = `release-candidate-record-${candidateId}`;
const matches = artifacts
.filter(item => item.name === name && item.expired === false)
.sort((a, b) => Number(b.id) - Number(a.id));
if (matches.length === 0) {
if (!required) return null;
throw new Error(`active candidate record artifact not found: ${candidateId}`);
}
return matches[0];
}
export function validateCandidateRun(run, recordArtifact, repository) {
if (run.id !== recordArtifact.workflow_run?.id) throw new Error("candidate artifact run identity mismatch");
if (run.repository?.full_name !== repository || run.path !== ".github/workflows/release-candidate.yml") {
throw new Error("candidate artifact was not produced by the protected candidate workflow");
}
if (run.head_branch !== "main-v2" || !["workflow_dispatch", "push"].includes(run.event) || run.status !== "completed" || run.conclusion !== "success") {
throw new Error("candidate producer run is not a successful protected main-v2 dispatch");
}
}
export function inspectRecord(record, candidateId, recordArtifact, run, now = new Date()) {
if (record.candidateId !== candidateId) throw new Error("candidate record identity mismatch");
if (record.source?.runId !== String(run.id) || record.source?.runAttempt !== String(run.run_attempt)) {
throw new Error("candidate record producer mismatch");
}
if (record.control?.buildSHA !== run.head_sha) throw new Error("candidate control SHA mismatch");
if (!/^[1-9][0-9]*$/.test(record.source?.payloadArtifactId ?? "")) throw new Error("candidate payload artifact id is invalid");
if (record.source?.payloadArtifactName !== `release-candidate-payload-${candidateId}`) throw new Error("candidate payload artifact name mismatch");
if (!/^[1-9][0-9]*$/.test(record.source?.evidenceArtifactId ?? "")) throw new Error("candidate evidence artifact id is invalid");
if (record.source?.evidenceArtifactName !== `release-candidate-evidence-${candidateId}`) throw new Error("candidate evidence artifact name mismatch");
if (String(recordArtifact.workflow_run.id) !== record.source.runId) throw new Error("record artifact belongs to another run");
if (record.validity?.revoked !== false) throw new Error("candidate record is revoked");
const created = new Date(record.validity?.createdAt);
const expires = new Date(record.validity?.expiresAt);
if (!Number.isFinite(created.valueOf()) || !Number.isFinite(expires.valueOf()) || expires <= created || expires <= now) {
throw new Error("candidate record has expired or has an invalid validity window");
}
return {
candidateId,
version: record.version,
sourceSHA: record.sourceSHA,
candidateControlSHA: record.control.buildSHA,
signingFingerprint: record.signing.desktopFingerprint,
producerRunId: record.source.runId,
producerRunAttempt: record.source.runAttempt,
desktopPrefix: record.source.desktopPrefix,
payloadArtifactId: record.source.payloadArtifactId,
payloadArtifactName: record.source.payloadArtifactName,
evidenceArtifactId: record.source.evidenceArtifactId,
evidenceArtifactName: record.source.evidenceArtifactName,
};
}
async function githubJSON(url, token) {
const response = await fetch(`https://api.github.com${url}`, {
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
});
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${url}`);
return response.json();
}
function outputs(values) {
if (!process.env.GITHUB_OUTPUT) return process.stdout.write(`${JSON.stringify(values)}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
}
async function resolve(candidateId, required = true) {
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GH_TOKEN;
if (!repository || !token) throw new Error("GITHUB_REPOSITORY and GH_TOKEN are required");
requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
const artifactData = await githubJSON(`/repos/${repository}/actions/artifacts?name=${encodeURIComponent(`release-candidate-record-${candidateId}`)}&per_page=100`, token);
const artifact = selectRecordArtifact(artifactData.artifacts ?? [], candidateId, required);
if (!artifact) {
outputs({ found: false });
return;
}
const run = await githubJSON(`/repos/${repository}/actions/runs/${artifact.workflow_run.id}`, token);
validateCandidateRun(run, artifact, repository);
outputs({ found: true, record_artifact_id: artifact.id, producer_run_id: run.id, producer_run_attempt: run.run_attempt });
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [command, candidateId, recordPath, artifactPath, runPath] = process.argv.slice(2);
if (command === "active") requireNotRevoked(candidateId, process.env.RELEASE_REVOKED_CANDIDATES);
else if (command === "resolve") await resolve(candidateId);
else if (command === "resolve-optional") await resolve(candidateId, false);
else if (command === "inspect") {
const result = inspectRecord(
JSON.parse(readFileSync(recordPath, "utf8")), candidateId,
JSON.parse(readFileSync(artifactPath, "utf8")), JSON.parse(readFileSync(runPath, "utf8")),
);
outputs(Object.fromEntries(Object.entries(result).map(([key, value]) => [key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`), value])));
} else throw new Error("usage: resolve-release-candidate.mjs active ID | resolve ID | resolve-optional ID | inspect ID RECORD ARTIFACT RUN");
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { inspectRecord, requireNotRevoked, selectRecordArtifact, validateCandidateRun } from "./resolve-release-candidate.mjs";
const id = `v1.2.3-${"a".repeat(12)}-${"b".repeat(12)}`;
const artifact = { id: 22, name: `release-candidate-record-${id}`, expired: false, workflow_run: { id: 11 } };
const run = { id: 11, run_attempt: 2, repository: { full_name: "esengine/DeepSeek-Reasonix" }, path: ".github/workflows/release-candidate.yml", head_branch: "main-v2", head_sha: "c".repeat(40), event: "workflow_dispatch", status: "completed", conclusion: "success" };
const record = { candidateId: id, version: "1.2.3", sourceSHA: "a".repeat(40), control: { buildSHA: run.head_sha }, signing: { desktopFingerprint: "v1:example" }, validity: { createdAt: "2026-01-01T00:00:00Z", expiresAt: "2027-01-01T00:00:00Z", revoked: false }, source: { runId: "11", runAttempt: "2", desktopPrefix: "desktop-11-2-preflight", payloadArtifactId: "33", payloadArtifactName: `release-candidate-payload-${id}`, evidenceArtifactId: "34", evidenceArtifactName: `release-candidate-evidence-${id}` } };
test("selects the newest active exact-name record", () => {
assert.equal(selectRecordArtifact([{ ...artifact, id: 20 }, artifact, { ...artifact, id: 30, expired: true }], id).id, 22);
assert.equal(selectRecordArtifact([], id, false), null);
assert.throws(() => selectRecordArtifact([], id), /not found/);
});
test("accepts a successful protected producer and exact record", () => {
assert.doesNotThrow(() => validateCandidateRun(run, artifact, "esengine/DeepSeek-Reasonix"));
const inspected = inspectRecord(record, id, artifact, run);
assert.equal(inspected.payloadArtifactId, "33");
assert.equal(inspected.evidenceArtifactId, "34");
});
test("accepts automatic preparation after the reviewed Notes PR merges", () => {
assert.doesNotThrow(() => validateCandidateRun({ ...run, event: "push" }, artifact, "esengine/DeepSeek-Reasonix"));
});
for (const change of [
{ path: ".github/workflows/other.yml" }, { head_branch: "topic" }, { event: "pull_request" },
{ conclusion: "failure" }, { repository: { full_name: "fork/Reasonix" } },
]) {
test(`rejects untrusted producer ${JSON.stringify(change)}`, () => {
assert.throws(() => validateCandidateRun({ ...run, ...change }, artifact, "esengine/DeepSeek-Reasonix"));
});
}
test("rejects a payload artifact substituted after sealing", () => {
assert.throws(() => inspectRecord({ ...record, source: { ...record.source, payloadArtifactId: "" } }, id, artifact, run));
});
test("rejects expired or record-revoked candidates before payload reuse", () => {
assert.throws(() => inspectRecord(record, id, artifact, run, new Date("2027-01-02T00:00:00Z")), /expired/);
assert.throws(() => inspectRecord({ ...record, validity: { ...record.validity, revoked: true } }, id, artifact, run,
new Date("2026-01-02T00:00:00Z")), /revoked/);
});
test("rejects a candidate on the repository revocation list", () => {
assert.throws(() => requireNotRevoked(id, `v9.9.9-${"c".repeat(12)}-${"d".repeat(12)}, ${id}`), /is revoked/);
assert.doesNotThrow(() => requireNotRevoked(id, ""));
});
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Validate an untagged Stable candidate without requiring it to remain the tip
# of main-v2 or to be the commit that introduced its reviewed release notes.
set -euo pipefail
if [ "$#" -ne 2 ] || [[ ! "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || [[ ! "$2" =~ ^[0-9a-f]{40}$ ]]; then
echo "usage: validate-release-candidate-source.sh MAJOR.MINOR.PATCH FULL_COMMIT_SHA" >&2
exit 2
fi
version="$1"
candidate="$2"
remote="${RELEASE_REMOTE:-origin}"
git fetch --quiet "$remote" main-v2 --tags
main_sha="$(git rev-parse "$remote/main-v2^{commit}")"
git cat-file -e "$candidate^{commit}" 2>/dev/null || {
echo "candidate commit is unavailable: $candidate" >&2
exit 1
}
git merge-base --is-ancestor "$candidate" "$main_sha" || {
echo "candidate $candidate is not on main-v2 history at $main_sha" >&2
exit 1
}
for tag in "v$version" "npm-v$version" "desktop-v$version"; do
if git show-ref --verify --quiet "refs/tags/$tag"; then
echo "candidate preparation refuses existing release tag: $tag" >&2
exit 1
fi
done
catalog="$(mktemp "${TMPDIR:-/tmp}/reasonix-candidate-catalog.XXXXXX")"
trap 'rm -f -- "$catalog"' EXIT
git show "$candidate:release-notes/releases.json" >"$catalog"
jq -e --arg version "$version" '
[.releases[] | select(.version == $version and .channel == "stable" and .status == "reviewed")] | length == 1
' "$catalog" >/dev/null || {
echo "candidate must contain exactly one reviewed Stable release record for $version" >&2
exit 1
}
echo "candidate source verified: version=$version sha=$candidate main=$main_sha"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
root="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-candidate-source-test.XXXXXX")"
cleanup() {
case "$root" in */reasonix-candidate-source-test.*) rm -rf -- "$root" ;; *) exit 1 ;; esac
}
trap cleanup EXIT
remote="$root/remote.git"
work="$root/work"
git init -q --bare "$remote"
git init -q -b main-v2 "$work"
git -C "$work" config user.name test
git -C "$work" config user.email [email protected]
mkdir -p "$work/release-notes"
printf '{"releases":[]}\n' >"$work/release-notes/releases.json"
git -C "$work" add .
git -C "$work" commit -q -m base
cat >"$work/release-notes/releases.json" <<'JSON'
{"releases":[{"version":"1.2.3","channel":"stable","status":"reviewed"}]}
JSON
git -C "$work" add .
git -C "$work" commit -q -m notes
candidate="$(git -C "$work" rev-parse HEAD)"
git -C "$work" commit --allow-empty -q -m "unrelated later merge"
git -C "$work" remote add origin "$remote"
git -C "$work" push -q origin main-v2
(
cd "$work"
RELEASE_REMOTE=origin "$repo_root/scripts/validate-release-candidate-source.sh" 1.2.3 "$candidate"
)
git -C "$work" tag v1.2.3 "$candidate"
git -C "$work" push -q origin v1.2.3
if (
cd "$work"
RELEASE_REMOTE=origin "$repo_root/scripts/validate-release-candidate-source.sh" 1.2.3 "$candidate"
); then
echo "existing release identity unexpectedly passed candidate preparation" >&2
exit 1
fi
echo "release candidate source tests: PASS"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
root="${1:-$(git rev-parse --show-toplevel)}"
required=(
.github/actions/setup-certum/action.yml
.github/workflows/release-candidate.yml
.github/workflows/release-promote.yml
.github/workflows/release-desktop.yml
.signpath/contracts/release-signing.yml
npm/publish-candidate.mjs
scripts/build-release-cli-candidate.mjs
scripts/desktop-release-artifacts.mjs
scripts/finalize-windows-signed-candidate.sh
scripts/install-nsis.ps1
scripts/package-windows-desktop.sh
scripts/release-candidate.mjs
scripts/resolve-release-candidate.mjs
scripts/sign-certum.ps1
scripts/verify-windows-authenticode.ps1
)
for file in "${required[@]}"; do
if [ ! -s "$root/$file" ]; then
echo "missing release control file: $file" >&2
exit 1
fi
done
for file in \
scripts/finalize-windows-signed-candidate.sh \
scripts/package-windows-desktop.sh; do
if [ ! -x "$root/$file" ]; then
echo "release control helper is not executable: $file" >&2
exit 1
fi
done
node --check "$root/scripts/release-candidate.mjs"
node --check "$root/scripts/resolve-release-candidate.mjs"
node --check "$root/scripts/build-release-cli-candidate.mjs"
node --check "$root/npm/publish-candidate.mjs"
bash -n "$root/scripts/finalize-windows-signed-candidate.sh"
bash -n "$root/scripts/package-windows-desktop.sh"
echo "release control preflight: PASS"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
"$repo_root/scripts/validate-release-control-plane.sh" "$repo_root"
fixture="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-control-test.XXXXXX")"
cleanup() {
case "$fixture" in */reasonix-release-control-test.*) rm -rf -- "$fixture" ;; *) exit 1 ;; esac
}
trap cleanup EXIT
while IFS= read -r file; do
mkdir -p "$fixture/$(dirname "$file")"
cp "$repo_root/$file" "$fixture/$file"
done < <(awk '
/^required=\(/ { collecting = 1; next }
collecting && /^\)/ { exit }
collecting { sub(/^[[:space:]]*/, ""); print }
' "$repo_root/scripts/validate-release-control-plane.sh")
chmod +x "$fixture/scripts/finalize-windows-signed-candidate.sh" "$fixture/scripts/package-windows-desktop.sh"
truncate -s 0 "$fixture/scripts/sign-certum.ps1"
if "$repo_root/scripts/validate-release-control-plane.sh" "$fixture" >"$fixture/missing.log" 2>&1; then
echo "incomplete release control checkout unexpectedly passed" >&2
exit 1
fi
grep -Fq 'missing release control file: scripts/sign-certum.ps1' "$fixture/missing.log"
echo "release control preflight tests: PASS"
@@ -25,5 +25,5 @@ test("artifact recovery installs smoke dependencies and retains collection ident
assert.ok(intel.indexOf("pnpm --dir desktop install --frozen-lockfile") < intel.indexOf("node desktop/packaging/smoke.mjs"));
assert.ok(intel.includes("needs.signing-contract.result == 'success'"));
assert.ok(workflow.includes("GITHUB_RUN_ID=34816299501 GITHUB_RUN_ATTEMPT=1 node release-control/scripts/desktop-release-artifacts.mjs collect"));
assert.ok(workflow.includes("'09cdab3866d77c6ff0d007ee61b6aca3128ebe54' || github.workflow_sha"));
assert.ok(workflow.includes("inputs.reuse_manual_artifacts && '09cdab3866d77c6ff0d007ee61b6aca3128ebe54' || inputs.candidate_control_sha || github.workflow_sha"));
});
+2 -2
View File
@@ -50,10 +50,10 @@ push)
exit 1
fi
;;
workflow_dispatch)
workflow_dispatch | pull_request_target)
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
echo "::error::protected caller workflow SHA is $caller_workflow_sha, expected main-v2 SHA $caller_sha" >&2
exit 1
fi
;;
+4 -2
View File
@@ -62,8 +62,6 @@ notes_only_commit() {
! grep -qvE '^release-notes/' <<<"$files"
}
wait_for_success "$candidate"
echo "successful main-v2 push CI verified: $candidate"
sha="$candidate"
hops=0
while notes_only_commit "$sha"; do
@@ -77,3 +75,7 @@ while notes_only_commit "$sha"; do
wait_for_success "$sha"
echo "successful main-v2 push CI verified for code ancestor: $sha"
done
if [ "$sha" = "$candidate" ]; then
wait_for_success "$candidate"
echo "successful main-v2 push CI verified: $candidate"
fi
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
test_root="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-push-ci-test.XXXXXX")"
trap 'rm -rf -- "$test_root"' EXIT
git -C "$test_root" init -q
git -C "$test_root" config user.name test
git -C "$test_root" config user.email [email protected]
printf 'code\n' >"$test_root/product.txt"
git -C "$test_root" add product.txt
git -C "$test_root" commit -qm code
code_sha="$(git -C "$test_root" rev-parse HEAD)"
mkdir -p "$test_root/release-notes"
printf '{}\n' >"$test_root/release-notes/releases.json"
git -C "$test_root" add release-notes/releases.json
git -C "$test_root" commit -qm notes
notes_sha="$(git -C "$test_root" rev-parse HEAD)"
mkdir -p "$test_root/bin"
cat >"$test_root/bin/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
sha=""
while [ "$#" -gt 0 ]; do
if [ "$1" = --commit ]; then sha="$2"; shift 2; else shift; fi
done
[ -n "$sha" ]
printf '%s\n' "$sha" >>"$GH_QUERY_LOG"
printf '[{"headSha":"%s","status":"completed","conclusion":"success"}]\n' "$sha"
SH
chmod +x "$test_root/bin/gh"
run_verify() {
local sha="$1"
: >"$test_root/queries"
(
cd "$test_root"
PATH="$test_root/bin:$PATH" GH_QUERY_LOG="$test_root/queries" \
RELEASE_CI_WAIT_SECONDS=0 RELEASE_CI_POLL_SECONDS=1 \
bash "$repo_root/scripts/verify-release-push-ci.sh" "$sha"
)
}
run_verify "$notes_sha"
[ "$(cat "$test_root/queries")" = "$code_sha" ]
run_verify "$code_sha"
[ "$(cat "$test_root/queries")" = "$code_sha" ]
printf 'changed\n' >>"$test_root/product.txt"
git -C "$test_root" add product.txt
git -C "$test_root" commit -qm mixed
mixed_sha="$(git -C "$test_root" rev-parse HEAD)"
run_verify "$mixed_sha"
[ "$(cat "$test_root/queries")" = "$mixed_sha" ]
+129 -9
View File
@@ -9,6 +9,10 @@ desktop_tag="${DESKTOP_TAG:?DESKTOP_TAG is required}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
attempts="${VERIFY_ATTEMPTS:-6}"
delay="${VERIFY_DELAY_SECONDS:-10}"
verify_homepage="${VERIFY_HOMEPAGE:-false}"
site_only="${VERIFY_PUBLIC_SITE_ONLY:-false}"
operation="${RELEASE_OPERATION:-publish}"
ledger_output="${RELEASE_LEDGER_OUTPUT:-}"
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
@@ -18,6 +22,16 @@ if [ "$cli_tag" != "v$version" ] || [ "$desktop_tag" != "desktop-v$version" ]; t
echo "::error::release tags do not match version $version: cli=$cli_tag desktop=$desktop_tag" >&2
exit 1
fi
case "$operation" in publish | recover) ;; *) echo "::error::RELEASE_OPERATION must be publish or recover" >&2; exit 1 ;; esac
release_git_url="https://github.com/${repository}.git"
cli_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$cli_tag" | awk 'NR == 1 {print $1}')"
npm_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/npm-v$version" | awk 'NR == 1 {print $1}')"
desktop_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$desktop_tag" | awk 'NR == 1 {print $1}')"
if [ -z "$cli_sha" ] || [ "$cli_sha" != "$npm_sha" ] || [ "$cli_sha" != "$desktop_sha" ]; then
echo "::error::release tags are missing or do not identify one immutable commit" >&2
exit 1
fi
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-postflight.XXXXXX")"
cleanup() {
@@ -28,6 +42,52 @@ cleanup() {
}
trap cleanup EXIT
verify_site() {
local manifest="$tmp_dir/desktop-pointer.json"
local homepage="$tmp_dir/homepage.html"
local changelog="$tmp_dir/changelog.html"
local cask="$tmp_dir/reasonix.rb"
curl -fsSL https://dl.reasonix.io/latest/latest.json >"$manifest"
jq -e --arg version "v$version" '
.version == $version and
([.platforms[], (.native_packages // {})[], (.downloads // {})[]] |
all(.url | type == "string" and startswith("https://dl.reasonix.io/desktop-" + $version + "/")))
' "$manifest" >/dev/null
curl -fsSL "https://reasonix.io/?download=desktop&release-postflight=v$version" >"$homepage"
! grep -Eq 'href="[^"]*(tag|download)/desktop-v[0-9]+\.[0-9]+\.[0-9]+' "$homepage"
curl -fsSL "https://reasonix.io/changelog/v$version/" >"$changelog"
grep -Fq "v$version" "$changelog"
curl -fsSL https://raw.githubusercontent.com/esengine/homebrew-reasonix/main/Casks/reasonix.rb >"$cask"
grep -Eq "version ['\"]$version['\"]" "$cask"
local browser="${CHROME_BIN:-}"
if [ -z "$browser" ]; then
for candidate in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$candidate" >/dev/null 2>&1; then browser="$candidate"; break; fi
done
fi
[ -n "$browser" ] || { echo "::error::a Chromium browser is required for hydrated homepage verification" >&2; return 1; }
"$browser" --headless=new --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://reasonix.io/?download=desktop&release-postflight=v$version" \
>"$tmp_dir/homepage-hydrated.html"
grep -Fq "data-release-version=\"desktop\">v$version<" "$tmp_dir/homepage-hydrated.html"
while IFS= read -r asset; do
url="$(jq -er --arg asset "$asset" '
[.platforms[], (.native_packages // {})[], (.downloads // {})[]]
| map(select(.url | endswith("/" + $asset)))
| if length == 1 then .[0].url else error("visible asset is missing or ambiguous") end
' "$manifest")"
grep -Fq "$url" "$tmp_dir/homepage-hydrated.html"
done < <(grep -Eo 'data-desktop-asset="[^"]+"' "$homepage" | cut -d '"' -f 2 | sort -u)
}
if [ "$site_only" = "true" ]; then
verify_site
node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \
"$tmp_dir/desktop-pointer.json" "${ledger_output:-$tmp_dir/site-ledger.json}"
echo "public site release inputs OK: v$version"
exit 0
fi
gh release view "$cli_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/cli.json"
jq -e '
.isDraft == false and .isPrerelease == false and
@@ -66,17 +126,77 @@ if [ "${DESKTOP_MANUAL_ONLY:-false}" = "true" ]; then
gh release view "$desktop_tag" --repo "$repository" --json body --jq .body | grep -F 'manual-download only'
fi
npm_names=(
"reasonix"
"@reasonix/cli-darwin-arm64"
"@reasonix/cli-darwin-x64"
"@reasonix/cli-linux-arm64"
"@reasonix/cli-linux-x64"
"@reasonix/cli-win32-arm64"
"@reasonix/cli-win32-x64"
)
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"
rm -rf "$tmp_dir/npm"
mkdir -p "$tmp_dir/npm"
visible=true
for index in "${!npm_names[@]}"; do
package="${npm_names[$index]}"
raw="$tmp_dir/npm/$index.raw.json"
if ! npm view "$package@$version" name version reasonixCandidateSha gitHead dist.integrity dist-tags.latest --json >"$raw" 2>/dev/null; then
visible=false
continue
fi
jq --arg name "$package" '
{
name: (.name // $name),
version,
reasonixCandidateSha,
gitHead,
integrity: (."dist.integrity" // .dist.integrity),
latest: (."dist-tags.latest" // ."dist-tags".latest)
}
' "$raw" >"$tmp_dir/npm/$index.json"
jq -e --arg name "$package" --arg version "$version" --arg sha "$cli_sha" '
.name == $name and .version == $version and
((.reasonixCandidateSha == null or .reasonixCandidateSha == $sha) and
(.gitHead == null or .gitHead == $sha) and
((.reasonixCandidateSha // .gitHead) == $sha)) and
(.integrity | type == "string" and length > 0) and
(.latest | type == "string" and length > 0)
' "$tmp_dir/npm/$index.json" >/dev/null || {
echo "::error::npm package identity differs from the release candidate: $package@$version" >&2
exit 1
}
done
if [ "$visible" = "true" ]; then
jq -s '.' "$tmp_dir"/npm/[0-9].json >"$tmp_dir/npm.json"
if node "$script_dir/release-publication-ledger.mjs" core "$version" "$cli_sha" "$operation" \
"$tmp_dir/cli.json" "$tmp_dir/desktop.json" "$tmp_dir/npm.json" "$tmp_dir/core-ledger.json"; then
if [ "$verify_homepage" = "true" ]; then
pointer_version="$(curl -fsSL https://dl.reasonix.io/latest/latest.json | jq -r .version)"
if [ "$pointer_version" = "v$version" ]; then
verify_site
node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \
"$tmp_dir/desktop-pointer.json" "$tmp_dir/site-ledger.json"
node "$script_dir/release-publication-ledger.mjs" merge "$tmp_dir/core-ledger.json" \
"$tmp_dir/site-ledger.json" "${ledger_output:-$tmp_dir/publication-ledger.json}"
elif [ "$operation" = "recover" ]; then
echo "Stable site remains on newer version $pointer_version; recovered immutable v$version files only."
[ -z "$ledger_output" ] || cp "$tmp_dir/core-ledger.json" "$ledger_output"
else
echo "::error::Stable manifest serves $pointer_version, want v$version" >&2
exit 1
fi
elif [ -n "$ledger_output" ]; then
cp "$tmp_dir/core-ledger.json" "$ledger_output"
fi
echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag npm-packages=${#npm_names[@]}"
exit 0
fi
fi
echo "npm publication has not converged (attempt $attempt/$attempts)"
if [ "$attempt" -lt "$attempts" ]; then sleep "$delay"; fi
done
echo "::error::npm latest did not reach $version" >&2
echo "::error::npm package set or public pointers did not converge for $version" >&2
exit 1
+1
View File
@@ -34,6 +34,7 @@ var ciJobs = map[string]struct {
"release-desktop.yml/signing-contract": {classKeepBusiness, "payload list covers the Electron executables and native modules"},
"release-desktop.yml/cache-guard": {classKeepBusiness, "unchanged"},
"release-desktop.yml/build": {classKeepBusiness, "desktop-build.sh packages the Electron app with the same NSIS/nfpm/signing steps"},
"release-desktop.yml/windows-build": {classKeepBusiness, "builds and smoke-tests unsigned x64 and ARM64 packages on native Windows runners before signing"},
"release-desktop.yml/windows-sign": {classKeepBusiness, "Certum signs native-tested x64 and ARM64 payloads and rebuilt installers on x64"},
"release-desktop.yml/windows-runtime-acceptance": {classKeepBusiness, "installs and starts the exact signed x64 and ARM64 installers on native runners"},
"release-desktop.yml/mac-universal-intel": {classKeepBusiness, "validates the exact universal DMG from the build matrix on an Intel runner"},