mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
bd0733b972
commit
c45b168a87
58 files changed
+3376
-833
No files matched your search
+8
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -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]}`);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Executable
+64
@@ -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"
|
||||
@@ -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`);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""));
|
||||
});
|
||||
Executable
+43
@@ -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
@@ -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"
|
||||
Executable
+46
@@ -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
@@ -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"));
|
||||
});
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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
|
||||
Executable
+57
@@ -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" ]
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user