diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 711bae4e0d..1c6dcbd861 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13067973b9..db8eed7c77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/actionlint@v1.7.7 \ -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 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fb8245c76b..a216b2a382 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/.github/workflows/prepare-release-notes.yml b/.github/workflows/prepare-release-notes.yml index 994c60748c..67e042d40b 100644 --- a/.github/workflows/prepare-release-notes.yml +++ b/.github/workflows/prepare-release-notes.yml @@ -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 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000000..881d618f8e --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -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 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 1311fda520..6d2de0bfd4 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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/nfpm@v2.46.3 - # 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/action-setup@v6.1.0 + 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 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 7d666bcc8c..39cfc4db53 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -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: diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml new file mode 100644 index 0000000000..95f71fb6f8 --- /dev/null +++ b/.github/workflows/release-promote.yml @@ -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 diff --git a/.github/workflows/release-stable-trigger.yml b/.github/workflows/release-stable-trigger.yml deleted file mode 100644 index 497a274780..0000000000 --- a/.github/workflows/release-stable-trigger.yml +++ /dev/null @@ -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', - }, - }); diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index c0263f3b4a..50920246fe 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -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 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml new file mode 100644 index 0000000000..c367ecbcd3 --- /dev/null +++ b/.github/workflows/release-verify.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4766a2d19e..a02d0b3e74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.signpath/contracts/release-signing.yml b/.signpath/contracts/release-signing.yml index 554de0d58b..c0001f7477 100644 --- a/.signpath/contracts/release-signing.yml +++ b/.signpath/contracts/release-signing.yml @@ -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 diff --git a/cmd/signpath-contract/main_test.go b/cmd/signpath-contract/main_test.go index 238f1a1c90..953f3704f8 100644 --- a/cmd/signpath-contract/main_test.go +++ b/cmd/signpath-contract/main_test.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) { diff --git a/desktop/electron/scripts/performance-smoke.mjs b/desktop/electron/scripts/performance-smoke.mjs index 2e71a53246..091612ba3e 100644 --- a/desktop/electron/scripts/performance-smoke.mjs +++ b/desktop/electron/scripts/performance-smoke.mjs @@ -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(); diff --git a/desktop/windows_signing_packaging_test.go b/desktop/windows_signing_packaging_test.go index e886f2120b..fb10d447c8 100644 --- a/desktop/windows_signing_packaging_test.go +++ b/desktop/windows_signing_packaging_test.go @@ -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{ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 56f6482f09..69bebd189e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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. diff --git a/docs/desktop-migration/INVENTORY.md b/docs/desktop-migration/INVENTORY.md index c871d4d0aa..e44ce5b6b1 100644 --- a/docs/desktop-migration/INVENTORY.md +++ b/docs/desktop-migration/INVENTORY.md @@ -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 | diff --git a/docs/desktop-migration/inventory.json b/docs/desktop-migration/inventory.json index 8ebb0927ad..af6e19b7d1 100644 --- a/docs/desktop-migration/inventory.json +++ b/docs/desktop-migration/inventory.json @@ -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", diff --git a/internal/agent/cancel_test.go b/internal/agent/cancel_test.go index 2ebefcc6d8..bab74d09e8 100644 --- a/internal/agent/cancel_test.go +++ b/internal/agent/cancel_test.go @@ -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") } } diff --git a/internal/agent/task_test.go b/internal/agent/task_test.go index 8dabe38002..958d032280 100644 --- a/internal/agent/task_test.go +++ b/internal/agent/task_test.go @@ -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()) diff --git a/npm/publish-candidate.mjs b/npm/publish-candidate.mjs new file mode 100644 index 0000000000..5c668332d8 --- /dev/null +++ b/npm/publish-candidate.mjs @@ -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 }); diff --git a/npm/publish.mjs b/npm/publish.mjs index ee4bdc5f0d..001387ee55 100644 --- a/npm/publish.mjs +++ b/npm/publish.mjs @@ -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) { diff --git a/npm/publish.test.mjs b/npm/publish.test.mjs index fcbbe02800..101a72aa3f 100644 --- a/npm/publish.test.mjs +++ b/npm/publish.test.mjs @@ -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 }); diff --git a/scripts/build-release-cli-candidate.mjs b/scripts/build-release-cli-candidate.mjs new file mode 100644 index 0000000000..fdf8607205 --- /dev/null +++ b/scripts/build-release-cli-candidate.mjs @@ -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)); +} diff --git a/scripts/build-release-cli-candidate.test.mjs b/scripts/build-release-cli-candidate.test.mjs new file mode 100644 index 0000000000..65e3911278 --- /dev/null +++ b/scripts/build-release-cli-candidate.test.mjs @@ -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\}/); +}); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs index 92d56507db..dd4b184f3c 100644 --- a/scripts/ci-paths.mjs +++ b/scripts/ci-paths.mjs @@ -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"); } diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs index 2e39a1abf4..abd0545841 100644 --- a/scripts/ci-paths.test.mjs +++ b/scripts/ci-paths.test.mjs @@ -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); + } +}); diff --git a/scripts/ci-timings.mjs b/scripts/ci-timings.mjs index 13066f1e2a..54442364ac 100644 --- a/scripts/ci-timings.mjs +++ b/scripts/ci-timings.mjs @@ -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) { diff --git a/scripts/ci-timings.test.mjs b/scripts/ci-timings.test.mjs index 86a01a5e5f..5c193b0a75 100644 --- a/scripts/ci-timings.test.mjs +++ b/scripts/ci-timings.test.mjs @@ -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/); }); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index d4848426b2..3518f32d73 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -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"]) diff --git a/scripts/desktop-release-artifacts.mjs b/scripts/desktop-release-artifacts.mjs index 44c833c96a..6b5124a1dd 100644 --- a/scripts/desktop-release-artifacts.mjs +++ b/scripts/desktop-release-artifacts.mjs @@ -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]"); } diff --git a/scripts/desktop-release-artifacts.test.mjs b/scripts/desktop-release-artifacts.test.mjs index 9fc54d4dd2..604bab4530 100644 --- a/scripts/desktop-release-artifacts.test.mjs +++ b/scripts/desktop-release-artifacts.test.mjs @@ -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]}`); diff --git a/scripts/desktop-windows-go-tests.test.mjs b/scripts/desktop-windows-go-tests.test.mjs index f45fae446c..f023844536 100644 --- a/scripts/desktop-windows-go-tests.test.mjs +++ b/scripts/desktop-windows-go-tests.test.mjs @@ -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/); }); diff --git a/scripts/finalize-windows-signed-candidate.sh b/scripts/finalize-windows-signed-candidate.sh new file mode 100755 index 0000000000..ab24a02bd7 --- /dev/null +++ b/scripts/finalize-windows-signed-candidate.sh @@ -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" diff --git a/scripts/macos-go-tests.test.mjs b/scripts/macos-go-tests.test.mjs index 8462c8b96a..a6bf33b07c 100644 --- a/scripts/macos-go-tests.test.mjs +++ b/scripts/macos-go-tests.test.mjs @@ -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`); diff --git a/scripts/publish-homebrew-cask.mjs b/scripts/publish-homebrew-cask.mjs new file mode 100644 index 0000000000..b4f2ee4281 --- /dev/null +++ b/scripts/publish-homebrew-cask.mjs @@ -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: "reasonix@deepseek.com" }, + }), + }); +} + +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]); +} diff --git a/scripts/publish-homebrew-cask.test.mjs b/scripts/publish-homebrew-cask.test.mjs new file mode 100644 index 0000000000..c0f87d3054 --- /dev/null +++ b/scripts/publish-homebrew-cask.test.mjs @@ -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/); +}); diff --git a/scripts/release-candidate.mjs b/scripts/release-candidate.mjs new file mode 100644 index 0000000000..3ec915eb82 --- /dev/null +++ b/scripts/release-candidate.mjs @@ -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"); + } +} diff --git a/scripts/release-candidate.test.mjs b/scripts/release-candidate.test.mjs new file mode 100644 index 0000000000..0ae69b06e1 --- /dev/null +++ b/scripts/release-candidate.test.mjs @@ -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))); +}); diff --git a/scripts/release-publication-ledger.mjs b/scripts/release-publication-ledger.mjs new file mode 100644 index 0000000000..1500503592 --- /dev/null +++ b/scripts/release-publication-ledger.mjs @@ -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 ?? ""}`); + } + 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"); + } +} diff --git a/scripts/release-publication-ledger.test.mjs b/scripts/release-publication-ledger.test.mjs new file mode 100644 index 0000000000..108ede43fb --- /dev/null +++ b/scripts/release-publication-ledger.test.mjs @@ -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/); +}); diff --git a/scripts/release-stable.sh b/scripts/release-stable.sh index be706b6e0b..57c3206980 100755 --- a/scripts/release-stable.sh +++ b/scripts/release-stable.sh @@ -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." diff --git a/scripts/release-stable.test.sh b/scripts/release-stable.test.sh index 9c82bfb682..e6788b80ba 100755 --- a/scripts/release-stable.test.sh +++ b/scripts/release-stable.test.sh @@ -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 test@example.invalid - 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 test@example.invalid -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 test@example.invalid -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" diff --git a/scripts/release-workflows.test.sh b/scripts/release-workflows.test.sh index 54c3e5e9fb..7988fd94cf 100755 --- a/scripts/release-workflows.test.sh +++ b/scripts/release-workflows.test.sh @@ -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" diff --git a/scripts/resolve-desktop-candidate.sh b/scripts/resolve-desktop-candidate.sh index 420599b306..13438c8153 100755 --- a/scripts/resolve-desktop-candidate.sh +++ b/scripts/resolve-desktop-candidate.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 diff --git a/scripts/resolve-release-candidate.mjs b/scripts/resolve-release-candidate.mjs new file mode 100644 index 0000000000..dad105b298 --- /dev/null +++ b/scripts/resolve-release-candidate.mjs @@ -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"); +} diff --git a/scripts/resolve-release-candidate.test.mjs b/scripts/resolve-release-candidate.test.mjs new file mode 100644 index 0000000000..b092718bb9 --- /dev/null +++ b/scripts/resolve-release-candidate.test.mjs @@ -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, "")); +}); diff --git a/scripts/validate-release-candidate-source.sh b/scripts/validate-release-candidate-source.sh new file mode 100755 index 0000000000..96270903d6 --- /dev/null +++ b/scripts/validate-release-candidate-source.sh @@ -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" diff --git a/scripts/validate-release-candidate-source.test.sh b/scripts/validate-release-candidate-source.test.sh new file mode 100755 index 0000000000..320210be07 --- /dev/null +++ b/scripts/validate-release-candidate-source.test.sh @@ -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 test@example.invalid +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" diff --git a/scripts/validate-release-control-plane.sh b/scripts/validate-release-control-plane.sh new file mode 100755 index 0000000000..5d45b9efeb --- /dev/null +++ b/scripts/validate-release-control-plane.sh @@ -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" diff --git a/scripts/validate-release-control-plane.test.sh b/scripts/validate-release-control-plane.test.sh new file mode 100755 index 0000000000..5f91e56ad9 --- /dev/null +++ b/scripts/validate-release-control-plane.test.sh @@ -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" diff --git a/scripts/verify-manual-desktop-producer.test.mjs b/scripts/verify-manual-desktop-producer.test.mjs index 03f8835df1..32e4eb331b 100644 --- a/scripts/verify-manual-desktop-producer.test.mjs +++ b/scripts/verify-manual-desktop-producer.test.mjs @@ -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")); }); diff --git a/scripts/verify-release-authorization.sh b/scripts/verify-release-authorization.sh index 61490558cf..9e50b7d1d3 100755 --- a/scripts/verify-release-authorization.sh +++ b/scripts/verify-release-authorization.sh @@ -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 ;; diff --git a/scripts/verify-release-push-ci.sh b/scripts/verify-release-push-ci.sh index 3ae0a0f801..d2134f5152 100755 --- a/scripts/verify-release-push-ci.sh +++ b/scripts/verify-release-push-ci.sh @@ -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 diff --git a/scripts/verify-release-push-ci.test.sh b/scripts/verify-release-push-ci.test.sh new file mode 100755 index 0000000000..e047e48ffb --- /dev/null +++ b/scripts/verify-release-push-ci.test.sh @@ -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 test@example.invalid +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" ] diff --git a/scripts/verify-stable-release-artifacts.sh b/scripts/verify-stable-release-artifacts.sh index 19a0f69199..e826512189 100755 --- a/scripts/verify-stable-release-artifacts.sh +++ b/scripts/verify-stable-release-artifacts.sh @@ -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:-}, 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 diff --git a/tools/desktopinventory/sources.go b/tools/desktopinventory/sources.go index 3639c80307..21bd030b76 100644 --- a/tools/desktopinventory/sources.go +++ b/tools/desktopinventory/sources.go @@ -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"},