name: Release # Select a tested dev source with the workflow revision pinned independently from that source. on: workflow_dispatch: inputs: version: description: "Optional expected derived version; required original version when resuming" required: false type: string tag: description: "npm dist-tag" required: true type: choice options: - latest - preview default: latest dry-run: description: "Dry run (build + pack, no actual publish)" required: false type: boolean default: true resume-after-npm-publish: description: "Attest npm publication was acknowledged for the exact supplied source and version; skip npm publish" required: false type: boolean default: false expected-sha: description: "Optional exact full source SHA; it must have qualifying dev CI evidence" required: false type: string permissions: {} concurrency: group: release cancel-in-progress: false jobs: validate-dispatch: runs-on: ubuntu-latest permissions: contents: read actions: read outputs: source_sha: ${{ steps.source.outputs.source_sha }} workflow_sha: ${{ steps.source.outputs.workflow_sha }} version: ${{ steps.version.outputs.version }} steps: - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.sha }} persist-credentials: false fetch-depth: 0 - name: Resolve eligible source id: source env: GH_TOKEN: ${{ github.token }} REQUESTED_SHA: ${{ inputs.expected-sha }} REQUESTED_VERSION: ${{ inputs.version }} RESUME: ${{ inputs.resume-after-npm-publish }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail test "$GITHUB_EVENT_NAME" = workflow_dispatch && test "$GITHUB_REF" = refs/heads/dev || { echo '::error::Release workflow must be dispatched from trusted dev.'; exit 1; } node - <<'NODE' const { validateReleaseDispatch } = require('./.github/scripts/release-dispatch-guard.cjs'); const failure = validateReleaseDispatch({ eventName: process.env.GITHUB_EVENT_NAME, ref: process.env.GITHUB_REF, sourceSha: process.env.REQUESTED_SHA, workflowSha: process.env.GITHUB_SHA }); if (failure) throw new Error(failure); NODE if [ "$RESUME" = true ]; then if [ -z "$REQUESTED_SHA" ] || [ -z "$REQUESTED_VERSION" ] || [ "$DRY_RUN" != false ]; then echo '::error::Resume requires the original source SHA and version and cannot be a dry run.'; exit 1 fi fi if [ -n "$REQUESTED_SHA" ]; then result="$(node .github/scripts/release-evidence.cjs resolve "$REQUESTED_SHA")" else result="$(node .github/scripts/release-evidence.cjs resolve)" fi RESULT="$result" node - <<'NODE' const fs = require('node:fs'); const result = JSON.parse(process.env.RESULT); if (!/^[0-9a-f]{40}$/.test(result.sourceSha) || !Number.isSafeInteger(result.ciRunId) || result.ciRunId <= 0 || (process.env.REQUESTED_SHA && result.sourceSha !== process.env.REQUESTED_SHA)) { throw new Error('Invalid source evidence'); } fs.appendFileSync(process.env.GITHUB_OUTPUT, `source_sha=${result.sourceSha}\nworkflow_sha=${process.env.GITHUB_SHA}\n`); NODE - name: Checkout selected source for version resolution uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ steps.source.outputs.source_sha }} path: candidate persist-credentials: false - name: Setup project Bun uses: ./.github/actions/setup-project-bun - name: Resolve release version id: version env: CHANNEL: ${{ inputs.tag }} EXPECTED_VERSION: ${{ inputs.version }} RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail bun scripts/release-version-sources.ts check --root candidate if [ "$RESUME" = true ]; then # JavaScript template literals are evaluated by Bun, not the shell. # shellcheck disable=SC2016 bun -e ' import { parseVersion } from "./scripts/version-line.ts"; import { appendFileSync } from "node:fs"; const value = process.env.EXPECTED_VERSION ?? "", channel = process.env.CHANNEL; const parsed = parseVersion(value); if (!/^\d+\.\d+\.\d+(?:-preview\.\d{8}(?:\.\d+)?)?$/.test(value) || !parsed || !((channel === "latest" && parsed.prerelease === null) || (channel === "preview" && parsed.prerelease?.[0] === "preview"))) throw Error("invalid original release version or channel"); appendFileSync(process.env.GITHUB_OUTPUT, `version=${value}\n`); ' exit 0 fi npm view "$(node -p "require('./package.json').name")" versions --json > "$RUNNER_TEMP/release-npm-versions.json" git tag --list 'v[0-9]*' > "$RUNNER_TEMP/release-tags.txt" # JavaScript template literals are evaluated by Bun, not the shell. # shellcheck disable=SC2016 bun -e ' import { devReleaseVersion, parseVersion } from "./scripts/version-line.ts"; import { readFileSync, appendFileSync } from "node:fs"; const versions = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/release-npm-versions.json`, "utf8")); if (!Array.isArray(versions) || versions.some(v => typeof v !== "string")) throw new Error("invalid npm versions"); const tags = readFileSync(`${process.env.RUNNER_TEMP}/release-tags.txt`, "utf8").trim().split("\n").filter(Boolean).map(v => v.slice(1)); const all = [...new Set([...versions, ...tags])]; const stableTags = [], previewTags = []; for (const version of all) { const parsed = parseVersion(version); if (!parsed) throw new Error(`invalid published version: ${version}`); (parsed.prerelease === null ? stableTags : previewTags).push(version); } const channel = process.env.CHANNEL; if (channel !== "latest" && channel !== "preview") throw new Error("invalid release channel"); const base = JSON.parse(readFileSync("candidate/package.json", "utf8")).version; const stamp = new Date().toISOString().slice(0, 10).replaceAll("-", ""); const version = devReleaseVersion({ base, stableTags, previewTags, channel, stamp }); if (process.env.EXPECTED_VERSION && process.env.EXPECTED_VERSION !== version) throw new Error("expected version differs from derived version"); appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); ' # The same evidence gate verify-npm and publish repeat, answered before any runner packages. # Run 42951296881 resolved its CI evidence here, packaged for minutes and only then failed in # verify-npm because no service-lifecycle run existed for the source. Packaging cannot create # that evidence, so a release that lacks it now fails in seconds with the dispatch command. - name: Require source CI and service evidence before packaging env: GH_TOKEN: ${{ github.token }} SOURCE_SHA: ${{ steps.source.outputs.source_sha }} RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' import { parseVersion, compareVersions } from "./scripts/version-line.ts"; const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); eligible.sort((a, b) => compareVersions(a.version, b.version)); console.log(eligible.at(-1)?.tag ?? ""); ')" baseline=--initial if [ -n "$baseline_tag" ]; then draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" fi range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" if [ "$required" = true ]; then node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; } fi # Every publication precondition the dispatch can already decide, checked before any runner # starts packaging: channel and dist-tag, every version source, the tag, the GitHub release, # npm and the global tag ordering (scripts/ci/release-preflight.sh). # # Run 35783865160 packaged 2.62.0 for nineteen minutes and then failed the ordering gate in # `publish` on v2.63.0-preview.20260923. That tag already existed when the run's first job # started: the workflow-level `release` concurrency group above is one constant slot for every # ref, so the stable run had waited for the preview run to finish. The runs were serialised; # the check was in the wrong place. Because of that shared slot, this job sees whatever the # previous release run published. # # It is an early answer, not the final one. Tags, releases and registry state can still move # while a run packages (a hand-pushed tag, a first local publish), so `publish` repeats every # one of these checks immediately before `npm publish`. preflight: name: release preflight needs: validate-dispatch runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false fetch-depth: 0 fetch-tags: true - name: Setup project Bun uses: ./.github/actions/setup-project-bun - name: Stamp preflight version run: | set -euo pipefail test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" test "$(git rev-parse HEAD)" = "$WORKFLOW_SHA" bun scripts/release-version-sources.ts sync "$RELEASE_VERSION" bun scripts/release-version-sources.ts check "$RELEASE_VERSION" - name: Fetch the dev line run: git fetch --no-tags --depth=1 origin +refs/heads/dev:refs/remotes/origin/dev - name: Refuse a release that cannot publish env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} DRY_RUN: ${{ inputs.dry-run }} RESUME: ${{ inputs.resume-after-npm-publish }} run: bash scripts/ci/release-preflight.sh package-standalone: needs: [validate-dispatch, preflight] if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest target: bun-linux-x64 dependency_os: linux dependency_cpu: x64 smoke: true - os: macos-latest target: bun-darwin-arm64 dependency_os: darwin dependency_cpu: arm64 smoke: true - os: macos-latest target: bun-darwin-x64 dependency_os: darwin dependency_cpu: x64 smoke: false - os: windows-latest target: bun-windows-x64 dependency_os: win32 dependency_cpu: x64 smoke: true - os: ubuntu-latest target: bun-linux-arm64 dependency_os: linux dependency_cpu: arm64 smoke: false runs-on: ${{ matrix.os }} # The Windows leg can wait up to an hour for a release-signing approval in SignPath. timeout-minutes: ${{ matrix.target == 'bun-windows-x64' && 90 || 25 }} permissions: # actions: read lets the SignPath action read this job and download the unsigned artifact. actions: read contents: read env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.source_sha }} persist-credentials: false - name: Checkout trusted release policy uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} path: trusted-release-policy persist-credentials: false - name: Setup project Bun uses: ./trusted-release-policy/.github/actions/setup-project-bun - name: Stamp and verify release version shell: bash run: | set -euo pipefail test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" test "$(git rev-parse HEAD)" = "$SOURCE_SHA" bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so # a broken SignPath configuration fails in seconds instead of after the build. The rules # (no token: skip; partial configuration: fail; test-signing: dry runs only) live in # scripts/ci/windows-signing-plan.sh, where tests/ci-workflows/release-windows-signing.test.ts # exercises them. - name: Decide Windows code signing id: windows-signing if: runner.os == 'Windows' shell: bash env: SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }} SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }} SIGNPATH_SIGNING_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} DRY_RUN: ${{ inputs.dry-run }} run: bash scripts/ci/windows-signing-plan.sh - name: Install dependencies run: bun install --frozen-lockfile --os=${{ matrix.dependency_os }} --cpu=${{ matrix.dependency_cpu }} - name: Build dashboard run: bun run build:gui - name: Build standalone binary run: bun run build:standalone --target ${{ matrix.target }} - name: Smoke test standalone binary if: matrix.smoke && runner.os != 'Windows' shell: bash run: | set -euo pipefail binary="dist/standalone/${{ matrix.target }}/ocx" "$binary" --version OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & pid=$! trap 'kill "$pid" 2>/dev/null || true' EXIT for _ in $(seq 1 30); do if curl -fsS http://127.0.0.1:10177/healthz; then break; fi sleep 1 done curl -fsS http://127.0.0.1:10177/healthz test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 - name: Smoke test standalone binary (Windows) if: matrix.smoke && runner.os == 'Windows' shell: pwsh run: | $binary = "dist/standalone/${{ matrix.target }}/ocx.exe" & $binary --version $env:OPENCODEX_HOME = Join-Path $env:RUNNER_TEMP "ocx-home" $process = Start-Process -FilePath $binary -ArgumentList "start", "--port", "10177" -PassThru try { for ($i = 0; $i -lt 30; $i++) { try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Out-Null; break } catch { Start-Sleep -Seconds 1 } } Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Select-Object -ExpandProperty Content Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null } finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } # Signed after the smoke test and before the archive, so the zip and its checksum are built # from the signed binary. The artifact holds ocx.exe alone at its root, which is the shape the # windows-standalone-zip artifact configuration expects. The keyring addons beside it are # third-party builds that the SignPath Foundation policy forbids signing with its certificate. - name: Upload the unsigned Windows binary for signing id: unsigned-ocx if: steps.windows-signing.outputs.mode == 'sign' continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: signpath-unsigned-${{ matrix.target }} path: dist/standalone/${{ matrix.target }}/ocx.exe if-no-files-found: error retention-days: 1 # continue-on-error on every signing step: a SignPath outage, timeout or rejection must never # fail a release. The apply step reads this outcome and keeps the unsigned binary with a # warning. Under release-signing every request waits for a maintainer's manual approval in # app.signpath.io, hence the hour. - name: Sign the Windows binary through SignPath id: sign-ocx if: steps.windows-signing.outputs.mode == 'sign' && steps.unsigned-ocx.outcome == 'success' continue-on-error: true uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 timeout-minutes: 65 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} signing-policy-slug: ${{ steps.windows-signing.outputs.policy }} artifact-configuration-slug: windows-standalone-zip github-artifact-id: ${{ steps.unsigned-ocx.outputs.artifact-id }} wait-for-completion: true wait-for-completion-timeout-in-seconds: 3600 output-artifact-directory: ${{ runner.temp }}/signpath-signed-ocx - name: Swap in the verified signed Windows binary if: steps.windows-signing.outputs.mode == 'sign' continue-on-error: true shell: pwsh env: STANDALONE_TARGET: ${{ matrix.target }} SIGNPATH_OUTCOME: ${{ steps.sign-ocx.outcome }} SIGNING_TRUST: ${{ steps.windows-signing.outputs.trust }} run: | ./scripts/ci/apply-signed-windows-artifact.ps1 ` -Target "dist/standalone/$env:STANDALONE_TARGET/ocx.exe" ` -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-ocx") ` -SubmitOutcome "$env:SIGNPATH_OUTCOME" ` -Trust $env:SIGNING_TRUST - name: Archive standalone release shell: bash env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} STANDALONE_TARGET: ${{ matrix.target }} run: | set -euo pipefail cd "dist/standalone/$STANDALONE_TARGET" if [[ "$RUNNER_OS" == "Windows" ]]; then # Variables in this literal belong to PowerShell. # shellcheck disable=SC2016 powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui,keyring -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' else tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui keyring fi cd ../.. # The pre-publication verifier resolves every recorded checksum from # dist/release, where the artifact download lands these files flat; the # checksum therefore records the bare file name, which sha256sum takes # verbatim from its argument. if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip.sha256" else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz.sha256" fi - name: Upload standalone release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: standalone-${{ matrix.target }} path: | dist/ocx-*.tar.gz dist/ocx-*.zip dist/ocx-*.sha256 if-no-files-found: error retention-days: 7 package-desktop: needs: [validate-dispatch, preflight] if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' }} strategy: fail-fast: false matrix: include: - os: macos-latest target: universal-apple-darwin dependency_os: darwin dependency_cpu: "*" bundles: app,dmg sidecar-targets: macos artifact-suffixes: macos.dmg,macos.app.tar.gz - os: windows-latest target: x86_64-pc-windows-msvc dependency_os: win32 dependency_cpu: x64 bundles: msi sidecar-targets: x86_64-pc-windows-msvc artifact-suffixes: windows-x64.msi - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu dependency_os: linux dependency_cpu: x64 bundles: appimage,deb sidecar-targets: x86_64-unknown-linux-gnu artifact-suffixes: linux-x86_64.AppImage,linux-amd64.deb runs-on: ${{ matrix.os }} # The Windows leg can wait up to an hour for a release-signing approval in SignPath. timeout-minutes: ${{ matrix.target == 'x86_64-pc-windows-msvc' && 105 || 45 }} permissions: # actions: read lets the SignPath action read this job and download the unsigned artifact. actions: read contents: read env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} # Whether this run holds the Developer ID material at all. A run without it still builds # locally useful bundles; a run with it must not silently downgrade any part of the app. DESKTOP_SIGNING_CONFIGURED: ${{ secrets.APPLE_CERTIFICATE != '' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.source_sha }} persist-credentials: false - name: Checkout trusted release policy uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} path: trusted-release-policy persist-credentials: false - name: Setup project Bun uses: ./trusted-release-policy/.github/actions/setup-project-bun - name: Stamp and verify release version shell: bash run: | set -euo pipefail test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" test "$(git rev-parse HEAD)" = "$SOURCE_SHA" bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so # a broken SignPath configuration fails in seconds instead of after the build. The rules # (no token: skip; partial configuration: fail; test-signing: dry runs only) live in # scripts/ci/windows-signing-plan.sh, where tests/ci-workflows/release-windows-signing.test.ts # exercises them. - name: Decide Windows code signing id: windows-signing if: runner.os == 'Windows' shell: bash env: SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }} SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }} SIGNPATH_SIGNING_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} DRY_RUN: ${{ inputs.dry-run }} run: bash scripts/ci/windows-signing-plan.sh # The desktop build takes its version from tauri.conf.json and Cargo.toml (the widget # plist inherits it), not from package.json or this input, while the updater manifest is # derived from the input. A mismatch ships an app that reports the previous version under a # manifest naming this one, so the updater re-offers the same release forever. Refuse # before anything is built. Bash on every runner: Windows would otherwise run PowerShell, # where "$RELEASE_VERSION" is not the environment variable. - name: Verify every version source matches the release shell: bash env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . - name: Install project dependencies run: bun install --frozen-lockfile --os=${{ matrix.dependency_os }} --cpu=${{ matrix.dependency_cpu }} - name: Build dashboard run: bun run build:gui - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: stable targets: ${{ runner.os == 'macOS' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Install Linux desktop dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev - name: Install desktop dependencies working-directory: desktop run: bun install --frozen-lockfile - name: Prepare macOS sidecars if: runner.os == 'macOS' run: | bun desktop/scripts/prepare-sidecar.ts --target aarch64-apple-darwin bun desktop/scripts/prepare-sidecar.ts --target x86_64-apple-darwin lipo -create desktop/src-tauri/binaries/ocx-aarch64-apple-darwin \ desktop/src-tauri/binaries/ocx-x86_64-apple-darwin \ -output desktop/src-tauri/binaries/ocx-universal-apple-darwin lipo desktop/src-tauri/binaries/ocx-universal-apple-darwin -verify_arch arm64 x86_64 - name: Prepare sidecar if: runner.os != 'macOS' run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} # The signing certificate has to be in a keychain before the widget is signed, and the # Tauri build step creates its own keychain only when it runs — which is after this. Until # this step existed, build-widget.sh saw no MACOS_SIGN_IDENTITY and took its unsigned # branch, and the bundler does not re-sign anything under PlugIns, so the extension would # have gone out ad-hoc inside a Developer ID host. No release has published a macOS # application yet, so this is a defect that had not reached anyone rather than one that had. - name: Import the release signing certificate if: runner.os == 'macOS' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail # Checked as a set, because a partial set is the dangerous case: the Tauri CLI skips # notarization without failing when the notary credentials are missing, and the # unnotarized artifact is uploaded and attached exactly as a good one would be. missing="" for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do eval "value=\${$name:-}" [ -n "$value" ] || missing="$missing $name" done if [ -n "$missing" ]; then if [ "${DRY_RUN}" != "true" ]; then echo "::error::A real release needs the full signing and notarization credential set." echo "::error::Missing:$missing" exit 1 fi echo "Signing credentials are incomplete, so this build stays ad-hoc signed:$missing" echo "It is usable for local validation and is not a release asset." exit 0 fi keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" # Recorded before anything is created, so the cleanup step can still find a keychain # that a failure left half-built. echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" keychain_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" certificate="$RUNNER_TEMP/opencodex-signing.p12" # The decoded certificate must not outlive this step even when a later command fails. trap 'shred -u "$certificate" 2>/dev/null || rm -Pf "$certificate" 2>/dev/null || true' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" security create-keychain -p "$keychain_password" "$keychain" security set-keychain-settings -lut 21600 "$keychain" security unlock-keychain -p "$keychain_password" "$keychain" security import "$certificate" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" \ -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$keychain_password" "$keychain" > /dev/null # shellcheck disable=SC2046 # the keychain list is intentionally word-split into arguments security list-keychain -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - name: Build WidgetKit extension if: runner.os == 'macOS' env: MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # A run holding Developer ID material must not produce an ad-hoc widget. Without this # the script's ad-hoc branch is the silent default, which is how a signed, notarized # app shipped with an extension macOS will not register. WIDGET_SIGN_REQUIRED: ${{ env.DESKTOP_SIGNING_CONFIGURED == 'true' && '1' || '0' }} run: bash desktop/scripts/build-widget.sh - name: Verify the extension carries the release signature if: runner.os == 'macOS' env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail appex=desktop/src-tauri/widget/OpenCodexWidget.appex if [ -z "${APPLE_TEAM_ID}" ]; then if [ "${DRY_RUN}" != "true" ]; then echo "::error::A real release cannot assert its own signature without APPLE_TEAM_ID." exit 1 fi echo "No team configured; skipping the signature assertion for this non-release build." exit 0 fi codesign --verify --strict --deep "$appex" description="$(codesign -dvvv "$appex" 2>&1)" echo "$description" echo "$description" | grep -q "TeamIdentifier=$APPLE_TEAM_ID" echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" # Tauri signs the app, its sidecar and the widget it is handed, but nothing under Resources. # The packaged keyring addons (#6161) are Mach-O code, so Apple notarization rejects the whole # app unless each carries the Developer ID signature, the hardened runtime and a secure # timestamp (2.73.0-preview.20260930 was refused for exactly that). Sign them in place, after # the certificate import and before the bundler copies them. - name: Sign the packaged keyring addons if: runner.os == 'macOS' env: MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail shopt -s nullglob addons=(desktop/src-tauri/resources/keyring/*.darwin-*.node) if [ "${#addons[@]}" -eq 0 ]; then echo "::error::No macOS keyring addon was prepared for the bundle." exit 1 fi if [ "${DESKTOP_SIGNING_CONFIGURED}" != "true" ]; then if [ "${DRY_RUN}" != "true" ]; then echo "::error::A real release must sign the packaged keyring addons." exit 1 fi echo "No signing material; the keyring addons stay as prepared for this non-release build." exit 0 fi for addon in "${addons[@]}"; do codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" "$addon" codesign --verify --strict "$addon" description="$(codesign -dvvv "$addon" 2>&1)" echo "$description" # Here-strings, not pipes: under pipefail a matcher that exits on its first hit can # SIGPIPE the writer and fail the assertion it was meant to pass. grep -q "TeamIdentifier=$APPLE_TEAM_ID" <<<"$description" grep -q "flags=.*runtime" <<<"$description" grep -q "Timestamp=" <<<"$description" done - name: Prepare Windows installer version if: runner.os == 'Windows' shell: bash env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: bun desktop/scripts/windows-installer-config.ts "$RELEASE_VERSION" "$RUNNER_TEMP/opencodex-msi.json" - name: Preserve the compiled Linux sidecar if: runner.os == 'Linux' run: | chmod +x desktop/scripts/appimage-patchelf.py echo "PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py" >> "$GITHUB_ENV" # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. - name: Build desktop bundles if: runner.os != 'Linux' working-directory: desktop env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # linuxdeploy suppresses its own stderr at the default verbosity. Keep # diagnostics on the first attempt; Apple signing commands stay non-verbose. run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" # The Windows installer goes to SignPath after the build and before the asset rename, so the # renamed asset, its checksum and its updater signature all describe the signed MSI. The # windows-msi-zip artifact configuration signs every .exe inside the installer (the app and # the bundled ocx.exe sidecar) and then the MSI itself. - name: Locate the Windows installer for signing id: windows-msi if: steps.windows-signing.outputs.mode == 'sign' continue-on-error: true shell: bash env: DESKTOP_TARGET: ${{ matrix.target }} run: | set -euo pipefail shopt -s nullglob installers=("desktop/src-tauri/target/$DESKTOP_TARGET/release/bundle/msi/"*.msi) if [ "${#installers[@]}" -ne 1 ]; then echo "::warning::Expected one MSI to sign, found ${#installers[@]}; the installer ships unsigned." exit 1 fi echo "path=${installers[0]}" >> "$GITHUB_OUTPUT" - name: Upload the unsigned Windows installer for signing id: unsigned-msi if: steps.windows-signing.outputs.mode == 'sign' && steps.windows-msi.outcome == 'success' continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: signpath-unsigned-${{ matrix.target }} path: ${{ steps.windows-msi.outputs.path }} if-no-files-found: error retention-days: 1 # continue-on-error, as for the standalone binary: an outage, timeout or rejection keeps the # unsigned installer and its original updater signature. - name: Sign the Windows installer through SignPath id: sign-msi if: steps.windows-signing.outputs.mode == 'sign' && steps.unsigned-msi.outcome == 'success' continue-on-error: true uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 timeout-minutes: 65 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} signing-policy-slug: ${{ steps.windows-signing.outputs.policy }} artifact-configuration-slug: windows-msi-zip github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }} wait-for-completion: true wait-for-completion-timeout-in-seconds: 3600 output-artifact-directory: ${{ runner.temp }}/signpath-signed-msi # Authenticode rewrites the MSI, so the Tauri updater signature made during the build no # longer matches it. The apply script regenerates .sig over the signed bytes with the # same key before swapping both in; verify-release then checks that signature against the # final MSI and writes latest.json from it. - name: Swap in the verified signed Windows installer if: steps.windows-signing.outputs.mode == 'sign' && steps.windows-msi.outcome == 'success' continue-on-error: true shell: pwsh env: MSI_PATH: ${{ steps.windows-msi.outputs.path }} SIGNPATH_OUTCOME: ${{ steps.sign-msi.outcome }} SIGNING_TRUST: ${{ steps.windows-signing.outputs.trust }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | ./scripts/ci/apply-signed-windows-artifact.ps1 ` -Target "$env:MSI_PATH" ` -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-msi") ` -SubmitOutcome "$env:SIGNPATH_OUTCOME" ` -Trust $env:SIGNING_TRUST ` -Installer ` -UpdaterSignature - name: Verify the packaged universal macOS runtime if: runner.os == 'macOS' run: | set -euo pipefail app=desktop/src-tauri/target/universal-apple-darwin/release/bundle/macos/OpenCodex.app test -f "$app/Contents/Resources/keyring/keyring.darwin-arm64.node" test -f "$app/Contents/Resources/keyring/keyring.darwin-x64.node" bash desktop/scripts/verify-macos-runtime.sh "$app" # Tauri patches a bundle-type marker into the application binary for each Linux format. # Keep each format in its own Cargo target so the deb cannot inherit the AppImage marker # and linuxdeploy cannot mutate the binary later consumed by the deb build. - name: Build Linux AppImage bundle if: runner.os == 'Linux' working-directory: desktop env: CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: bunx tauri build --ci --target ${{ matrix.target }} --bundles appimage - name: Build Linux deb bundle if: runner.os == 'Linux' working-directory: desktop env: CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: bunx tauri build --ci --target ${{ matrix.target }} --bundles deb - name: Stage isolated Linux release bundles if: runner.os == 'Linux' shell: bash env: DESKTOP_TARGET: ${{ matrix.target }} APPIMAGE_TARGET: ${{ runner.temp }}/opencodex-appimage-target DEB_TARGET: ${{ runner.temp }}/opencodex-deb-target run: | set -euo pipefail bundle_root="$RUNNER_TEMP/opencodex-linux-release-bundles" mkdir -p "$bundle_root/appimage" "$bundle_root/deb" cp -a "$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/." "$bundle_root/appimage/" cp -a "$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/." "$bundle_root/deb/" chmod -R a-w "$bundle_root" echo "DESKTOP_BUNDLE_ROOT=$bundle_root" >> "$GITHUB_ENV" # After the isolated AppImage exists, and against that staged copy: the default Cargo target # holds no Linux bundle any more, so verifying there would fail or check a stale artifact. - name: Verify the packaged Linux sidecar if: runner.os == 'Linux' run: bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage" - name: Rename release assets shell: bash env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | args=( \ --version "$RELEASE_VERSION" \ --target "$DESKTOP_TARGET" \ --out dist/release \ ) if [[ -n "${DESKTOP_BUNDLE_ROOT:-}" ]]; then args+=(--bundle-root "$DESKTOP_BUNDLE_ROOT") fi bun desktop/scripts/collect-release-assets.ts "${args[@]}" # After the bundle exists, not before: a sweep that runs first passes by finding nothing. - name: Verify every Mach-O in the bundle carries the release identity if: runner.os == 'macOS' env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail if [ -z "${APPLE_TEAM_ID}" ]; then if [ "${DRY_RUN}" != "true" ]; then echo "::error::A real release cannot verify its bundle without APPLE_TEAM_ID." exit 1 fi echo "No team configured; skipping the bundle-wide assertion for this local build." exit 0 fi # Executables are found by their magic bytes rather than by path or extension. A bundler # signs what it placed; anything copied in afterwards is invisible to it, and the # binaries that get missed are the ones with no extension to filter on. apps=0 machos=0 bad=0 while IFS= read -r app; do apps=$((apps + 1)) echo "checking $app" while IFS= read -r -d '' file; do # All eight Mach-O leading words: thin and fat, 32- and 64-bit, both byte orders. # A list that covers only the common ones skips the rest in silence while the # non-zero counter below still reports a healthy sweep. case "$(head -c 4 "$file" | xxd -p)" in cefaedfe|cffaedfe|feedface|feedfacf) ;; cafebabe|bebafeca|cafebabf|bfbafeca) ;; *) continue ;; esac machos=$((machos + 1)) if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then echo "::error::$file is not signed with the release identity" bad=1 fi done < <(find "$app" -type f -print0) done < <(find desktop/src-tauri/target -maxdepth 6 -type d -name '*.app') echo "inspected $machos Mach-O files across $apps app bundles" # A sweep that inspected nothing is the failure mode this step exists to prevent. if [ "$apps" -eq 0 ] || [ "$machos" -eq 0 ]; then echo "::error::found $apps app bundles and $machos Mach-O files; the sweep inspected nothing" exit 1 fi exit "$bad" - name: Upload desktop release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: desktop-${{ matrix.target }} path: dist/release/ if-no-files-found: error retention-days: 7 # always(), because a keychain holding the release identity must not survive a failed job # on a runner image that could be reused. - name: Remove the signing keychain if: always() && runner.os == 'macOS' run: | if [ -n "${OPENCODEX_SIGNING_KEYCHAIN:-}" ] && [ -f "${OPENCODEX_SIGNING_KEYCHAIN}" ]; then security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi # Candidate code and npm lifecycle scripts run only in this read-only job. package-npm: needs: [validate-dispatch, preflight] runs-on: ubuntu-latest timeout-minutes: 25 permissions: contents: read outputs: artifact_id: ${{ steps.upload.outputs.artifact-id }} env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout selected source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.source_sha }} persist-credentials: false - name: Checkout trusted release policy uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} path: trusted-release-policy persist-credentials: false - name: Setup project Bun uses: ./trusted-release-policy/.github/actions/setup-project-bun - name: Stamp and verify release version run: | set -euo pipefail test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" test "$(git rev-parse HEAD)" = "$SOURCE_SHA" bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . - name: Install dependencies run: bun install --frozen-lockfile - name: Build npm package run: | set -euo pipefail npm run prepublishOnly npm run prepack # All candidate lifecycle scripts have finished. Bind the packed manifest # to the selected commit; npm pack below runs with scripts disabled. node - <<'NODE' const fs = require('node:fs'), { spawnSync } = require('node:child_process'); const source = process.env.SOURCE_SHA; if (!/^[0-9a-f]{40}$/.test(source || '')) throw Error('invalid source SHA'); const git = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }); if (git.status !== 0 || git.stdout.trim() !== source) throw Error('npm checkout source mismatch'); const stat = fs.lstatSync('package.json'); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('invalid npm manifest'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); if (pkg.version !== process.env.RELEASE_VERSION) throw Error('npm manifest version mismatch'); pkg.gitHead = source; fs.writeFileSync('package.json.release-tmp', JSON.stringify(pkg, null, 2) + '\n', { flag: 'wx' }); fs.renameSync('package.json.release-tmp', 'package.json'); NODE mkdir -p "$RUNNER_TEMP/npm-pack" dist/npm npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/npm-pack" shopt -s nullglob packages=("$RUNNER_TEMP"/npm-pack/*.tgz) test "${#packages[@]}" -eq 1 cp -- "${packages[0]}" dist/npm/package.tgz - name: Upload npm package id: upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-package-unverified path: dist/npm/package.tgz if-no-files-found: error retention-days: 7 # A fresh runner computes the receipt; candidate code cannot execute in this job. verify-npm: needs: [validate-dispatch, package-npm] runs-on: ubuntu-latest permissions: contents: read actions: read outputs: artifact_id: ${{ steps.upload.outputs.artifact-id }} sha256: ${{ steps.receipt.outputs.sha256 }} env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false fetch-depth: 0 - name: Resolve trusted Bun version id: bun-version run: | # JavaScript template literals are evaluated by Node, not the shell. # shellcheck disable=SC2016 node -e 'const v = require("./package.json").dependencies.bun; if (!/^\d+\.\d+\.\d+$/.test(v)) throw Error("invalid Bun pin"); require("node:fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${v}\n`);' - name: Setup trusted Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: ${{ steps.bun-version.outputs.version }} - name: Download npm package by immutable artifact ID uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.package-npm.outputs.artifact_id }} path: npm-artifact - name: Recheck source CI and service evidence env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' import { parseVersion, compareVersions } from "./scripts/version-line.ts"; const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); eligible.sort((a, b) => compareVersions(a.version, b.version)); console.log(eligible.at(-1)?.tag ?? ""); ')" baseline=--initial if [ -n "$baseline_tag" ]; then draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" fi range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" if [ "$required" = true ]; then node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; } fi - name: Require reconciled publication history if: ${{ inputs.resume-after-npm-publish != true }} env: GH_TOKEN: ${{ github.token }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ -z "$RELEASE_VERSION" ]; then echo '::error::Invalid release identity for history reconciliation.'; exit 1 fi case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) echo '::error::Invalid release channel.'; exit 1 ;; esac node .github/scripts/release-history.cjs check "$SOURCE_SHA" "$RELEASE_VERSION" "$channel" - name: Verify resume registry identity id: resume-identity if: ${{ inputs.resume-after-npm-publish == true }} run: | set -euo pipefail pkg_name="$(node -p "require('./package.json').name")" registry_version="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" version --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { echo '::error::Cannot read the exact npm version; resume refused.'; exit 1; } REGISTRY_VERSION="$registry_version" node - <<'NODE' const value = JSON.parse(process.env.REGISTRY_VERSION); if (typeof value !== 'string' || value !== process.env.RELEASE_VERSION) throw Error('npm version does not match original release'); NODE registry_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { echo '::error::Cannot read npm gitHead; resume refused.'; exit 1; } bun scripts/verify-release-resume.ts "$SOURCE_SHA" "$registry_head" echo "resume_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - name: Create verified npm receipt id: receipt run: | set -euo pipefail node - <<'NODE' const fs = require('node:fs'); const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const archive = 'npm-artifact/package.tgz'; const sha = /^[0-9a-f]{40}$/; if (!sha.test(process.env.SOURCE_SHA) || !sha.test(process.env.WORKFLOW_SHA) || !/^\d+$/.test(process.env.GITHUB_RUN_ID) || !/^\d+$/.test(process.env.GITHUB_RUN_ATTEMPT) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(process.env.RELEASE_VERSION)) throw new Error('invalid receipt identity'); const stat = fs.lstatSync(archive); if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 500 * 1024 * 1024) throw new Error('invalid npm tarball'); const entries = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); if (entries.status !== 0 || entries.stdout.split('\n').filter(p => p === 'package/package.json').length !== 1) throw new Error('invalid npm manifest entry'); const manifest = spawnSync('tar', ['-xOzf', archive, 'package/package.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); if (manifest.status !== 0) throw new Error('cannot read npm manifest'); const pkg = JSON.parse(manifest.stdout), trusted = JSON.parse(fs.readFileSync('package.json', 'utf8')); if (pkg.name !== trusted.name || pkg.version !== process.env.RELEASE_VERSION || pkg.gitHead !== process.env.SOURCE_SHA) throw new Error('npm package identity or gitHead mismatch'); const digest = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); const receipt = { sourceSha: process.env.SOURCE_SHA, workflowSha: process.env.WORKFLOW_SHA, version: process.env.RELEASE_VERSION, runId: process.env.GITHUB_RUN_ID, attempt: process.env.GITHUB_RUN_ATTEMPT, packageName: pkg.name, gitHead: pkg.gitHead, sha256: digest }; fs.writeFileSync('npm-artifact/receipt.json', JSON.stringify(receipt) + '\n'); fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha256=${digest}\n`); NODE - name: Upload verified npm package and receipt id: upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-package-verified path: npm-artifact/ if-no-files-found: error retention-days: 7 # Pre-publication verification. Everything that will be published is checked # here — expected platform set, every checksum, the updater signatures, and the # manifest parse-back — and publication consumes this result rather than # verifying after the fact. Runs on dry-run too: a dry run must prove the same # chain a real release will rely on. verify-release: runs-on: ubuntu-latest needs: [validate-dispatch, package-standalone, package-desktop] timeout-minutes: 10 permissions: contents: read env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false - name: Setup project Bun uses: ./.github/actions/setup-project-bun - name: Download standalone packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: standalone-* merge-multiple: true path: dist/release - name: Download desktop packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: desktop-* merge-multiple: true path: dist/release - name: Verify release assets env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail args=( --version "$RELEASE_VERSION" --dir dist/release --repo "$GITHUB_REPOSITORY" --sha "$SOURCE_SHA" --receipt-out verification/receipt.json ) # HEAD release policy requires updater signatures whenever the key is configured. if [ "$UPDATER_SIGNING_CONFIGURED" = "true" ]; then args+=(--manifest-out dist/release/latest.json --require-signatures) fi bun desktop/scripts/verify-release-assets.ts "${args[@]}" - name: Bind verified asset digests run: | set -euo pipefail node - <<'NODE' const fs = require('node:fs'), crypto = require('node:crypto'), path = require('node:path'); (async () => { const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); if (receipt.version !== process.env.RELEASE_VERSION || receipt.sha !== process.env.SOURCE_SHA || receipt.repo !== process.env.GITHUB_REPOSITORY) throw Error('asset verifier identity mismatch'); const names = fs.readdirSync('dist/release').sort(); if (!names.length) throw Error('empty verified release'); const assets = {}; for (const name of names) { if (name === '.' || name === '..' || name.includes('/') || name.includes('\\')) throw Error('invalid asset name'); const file = path.join('dist/release', name), stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('asset is not a regular file'); const hash = crypto.createHash('sha256'); for await (const chunk of fs.createReadStream(file)) hash.update(chunk); assets[name] = hash.digest('hex'); } receipt.workflowSha = process.env.WORKFLOW_SHA; receipt.runId = process.env.GITHUB_RUN_ID; receipt.attempt = process.env.GITHUB_RUN_ATTEMPT; receipt.assets = assets; fs.writeFileSync('verification/receipt.json', JSON.stringify(receipt) + '\n'); })().catch(error => { console.error(error.message); process.exitCode = 1; }); NODE - name: Upload verified release bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: verified-release path: dist/release/ if-no-files-found: error retention-days: 7 - name: Upload verification receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-verification-receipt path: verification/receipt.json if-no-files-found: error retention-days: 7 attach-release: runs-on: ubuntu-latest needs: [validate-dispatch, publish, verify-release] if: ${{ inputs.dry-run != true }} timeout-minutes: 10 permissions: contents: write env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false - name: Setup project Bun uses: ./.github/actions/setup-project-bun - name: Download the verified release bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: verified-release path: dist/release - name: Download the verification receipt uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-verification-receipt path: verification # The bundle is attached exactly as verified: the receipt must name this # run's version and commit, or nothing uploads. - name: Require the verification receipt for this commit env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail node - <<'NODE' const fs = require('node:fs'), crypto = require('node:crypto'), path = require('node:path'); (async () => { const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); if (receipt.version !== process.env.RELEASE_VERSION || receipt.sha !== process.env.SOURCE_SHA || receipt.repo !== process.env.GITHUB_REPOSITORY || receipt.workflowSha !== process.env.WORKFLOW_SHA || receipt.runId !== process.env.GITHUB_RUN_ID || !/^[1-9]\d*$/.test(receipt.attempt || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets)) throw Error('release receipt identity mismatch'); const names = fs.readdirSync('dist/release').sort(), recorded = Object.keys(receipt.assets).sort(); if (!names.length || JSON.stringify(names) !== JSON.stringify(recorded)) throw Error('release asset set mismatch'); for (const name of names) { if (name === '.' || name === '..' || /[\x00-\x1f\x7f/\\]/.test(name) || !/^[0-9a-f]{64}$/.test(receipt.assets[name])) throw Error('invalid asset receipt'); const file = path.join('dist/release', name), stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('asset is not a regular file'); const hash = crypto.createHash('sha256'); for await (const chunk of fs.createReadStream(file)) hash.update(chunk); if (hash.digest('hex') !== receipt.assets[name]) throw Error(`asset sha256 mismatch: ${name}`); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); NODE - name: Check resume assets if: ${{ inputs.resume-after-npm-publish == true }} env: GH_TOKEN: ${{ github.token }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) exit 1 ;; esac decision="$(node .github/scripts/release-resume.cjs check-assets "$RELEASE_VERSION" "$SOURCE_SHA" "$channel" verification/receipt.json)" DECISION="$decision" CHANNEL="$channel" node - <<'NODE' const result = JSON.parse(process.env.DECISION); if (result.version !== process.env.RELEASE_VERSION || result.sourceSha !== process.env.SOURCE_SHA || result.channel !== process.env.CHANNEL || result.publishNpm !== false || result.assetsChecked !== true || !Array.isArray(result.reuseAssets) || !Array.isArray(result.missingAssets)) throw Error('invalid resume asset decision'); NODE - name: Attach to the release env: GH_TOKEN: ${{ github.token }} # Workflow inputs reach shell code through env, never by interpolation into # run: source. tests/ci-workflows.test.ts enforces this repo-wide. RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail release_tag="v${RELEASE_VERSION}" # The preceding receipt check binds this local set to verified bytes. # Existing assets are reused only when GitHub reports their exact digest. checker="$RUNNER_TEMP/check-release-assets.cjs" cat > "$checker" <<'NODE' const fs = require('node:fs'); const release = JSON.parse(process.env.RELEASE_INFO); const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); if (typeof release.isDraft !== 'boolean' || !Array.isArray(release.assets) || !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets)) throw Error('invalid release or receipt'); const expected = receipt.assets, names = Object.keys(expected), seen = new Set(); if (!names.length) throw Error('empty asset receipt'); for (const name of names) { if (!name || name === '.' || name === '..' || /[\x00-\x1f\x7f/\\]/.test(name) || !/^[0-9a-f]{64}$/.test(expected[name])) throw Error('invalid asset receipt'); } for (const asset of release.assets) { if (!asset || typeof asset.name !== 'string' || seen.has(asset.name) || !Object.hasOwn(expected, asset.name) || asset.state !== 'uploaded' || asset.digest !== `sha256:${expected[asset.name]}`) throw Error('existing asset is unexpected, unavailable or has a different digest'); seen.add(asset.name); } const missing = names.filter(name => !seen.has(name)); if ((!release.isDraft || process.env.CHECK_FINAL === 'true') && missing.length) throw Error('release is missing verified assets'); if (missing.length) process.stdout.write(missing.join('\0') + '\0'); NODE release_info="$(gh release view "$release_tag" --json isDraft,assets)" RELEASE_INFO="$release_info" CHECK_FINAL=false node "$checker" > "$RUNNER_TEMP/missing-release-assets" missing=() while IFS= read -r -d '' name; do missing+=("dist/release/$name"); done < "$RUNNER_TEMP/missing-release-assets" if [ "${#missing[@]}" -gt 0 ]; then gh release upload "$release_tag" "${missing[@]}" fi # Re-read the entire remote set before publishing the draft. release_info="$(gh release view "$release_tag" --json isDraft,assets)" RELEASE_INFO="$release_info" CHECK_FINAL=true node "$checker" > /dev/null # A published release is immutable: GitHub rejects every later asset upload # with HTTP 422, which is why v2.55.0 through v2.60.0 shipped with zero # assets and left the desktop updater without anything to download. The # release is therefore created as a draft and becomes public here, once the # verified bundle is attached. The only edit permitted is this flip — the # notes still come from the validated notes file written at creation. draft_state="$(gh release view "$release_tag" --json isDraft --jq .isDraft)" case "$draft_state" in true) gh release edit "$release_tag" --draft=false ;; false) ;; # A successful query that answers neither true nor false — an empty body or an # unexpected shape — must not leave the release a silent draft: only an explicit # false may pass. *) echo "unexpected draft state: $draft_state" >&2; exit 1 ;; esac # One row per fact a release run can establish: the public GitHub release, the npm version read # back from the registry, and the npm dist-tag. A green run used to read the same whichever of # them were true, because the registry smoke continues to the GitHub release when its reads stay # pending, which is the intended publishing behaviour. This job only reports; it never changes the # run's result. # # A job of its own, not a step in attach-release: a failed publish skips attach-release entirely, # and that is when the rows matter most. It reads with the job token at contents: read, so a draft # release is invisible to it and reads as not public, which is the question the row answers. release-outcomes: name: release outcomes needs: [validate-dispatch, publish, attach-release] if: ${{ always() && needs.validate-dispatch.result == 'success' && inputs.dry-run != true }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Report release outcomes env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} NPM_VERSION_STATE: ${{ needs.publish.outputs.npm_version }} NPM_DIST_TAG_STATE: ${{ needs.publish.outputs.npm_dist_tag }} PUBLISH_RESULT: ${{ needs.publish.result }} ATTACH_RESULT: ${{ needs.attach-release.result }} run: bash scripts/ci/release-outcome-report.sh publish: needs: [validate-dispatch, verify-release, verify-npm] if: ${{ inputs.dry-run != true }} runs-on: ubuntu-latest # The two registry waits below can each take about 20 minutes (about 55 minutes worst case with read timeouts). timeout-minutes: 75 outputs: npm_version: ${{ steps.registry-smoke.outputs.npm_version }} npm_dist_tag: ${{ steps.registry-smoke.outputs.npm_dist_tag }} permissions: contents: write actions: read pull-requests: read id-token: write env: SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} # The ./ prefix is load-bearing: npm reads a bare "owner/repo..." spec as a GitHub shorthand # and runs git ls-remote instead of publishing the file (run 42763073267). NPM_TARBALL: ./npm-artifact/package.tgz EXPECTED_DIGEST: ${{ needs.verify-npm.outputs.sha256 }} steps: - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} fetch-depth: 0 - name: Resolve trusted Bun version id: bun-version run: | # JavaScript template literals are evaluated by Node, not the shell. # shellcheck disable=SC2016 node -e 'const v = require("./package.json").dependencies.bun; if (!/^\d+\.\d+\.\d+$/.test(v)) throw Error("invalid Bun pin"); require("node:fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${v}\n`);' - name: Setup trusted Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: ${{ steps.bun-version.outputs.version }} - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 registry-url: https://registry.npmjs.org - name: Download verified npm package by immutable artifact ID uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.verify-npm.outputs.artifact_id }} path: npm-artifact # Fetch the independently verified release receipt before publication, so # an unavailable handoff cannot strand a newly published npm version. - name: Download verified release receipt for publication uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-verification-receipt path: verification - name: Verify immutable npm receipt run: | set -euo pipefail node - <<'NODE' const fs = require('node:fs'); const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const archive = process.env.NPM_TARBALL; const stat = fs.lstatSync(archive); if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 500 * 1024 * 1024) throw new Error('invalid npm tarball'); const receipt = JSON.parse(fs.readFileSync('npm-artifact/receipt.json', 'utf8')); const sha256 = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); const expectedName = JSON.parse(fs.readFileSync('package.json', 'utf8')).name; if (!/^[0-9a-f]{64}$/.test(process.env.EXPECTED_DIGEST) || sha256 !== process.env.EXPECTED_DIGEST || receipt.sha256 !== sha256 || receipt.sourceSha !== process.env.SOURCE_SHA || receipt.workflowSha !== process.env.WORKFLOW_SHA || receipt.version !== process.env.RELEASE_VERSION || receipt.runId !== process.env.GITHUB_RUN_ID || !/^[1-9]\d*$/.test(receipt.attempt || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || receipt.packageName !== expectedName || receipt.gitHead !== process.env.SOURCE_SHA) throw new Error('npm receipt identity or digest mismatch'); const entries = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); if (entries.status !== 0 || entries.stdout.split('\n').filter(p => p === 'package/package.json').length !== 1) throw new Error('invalid npm manifest entry'); const manifest = spawnSync('tar', ['-xOzf', archive, 'package/package.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); if (manifest.status !== 0) throw new Error('cannot read npm manifest'); const pkg = JSON.parse(manifest.stdout); if (pkg.name !== expectedName || pkg.version !== process.env.RELEASE_VERSION || pkg.gitHead !== process.env.SOURCE_SHA || pkg.gitHead !== receipt.gitHead) throw new Error('npm package identity or gitHead mismatch'); NODE # Prepare and validate the receipt before the irreversible npm publication. - name: Prepare publication receipt if: ${{ inputs.resume-after-npm-publish != true }} env: NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail mkdir -p publication node - <<'NODE' const fs = require('node:fs'); const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); const sha = /^[0-9a-f]{40}$/, digest = /^[0-9a-f]{64}$/; const runId = Number(process.env.GITHUB_RUN_ID), attempt = Number(process.env.GITHUB_RUN_ATTEMPT); const version = process.env.RELEASE_VERSION, tag = process.env.NPM_DIST_TAG; if (!sha.test(process.env.SOURCE_SHA || '') || !sha.test(process.env.WORKFLOW_SHA || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ID || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || !digest.test(process.env.EXPECTED_DIGEST || '') || !Number.isSafeInteger(runId) || runId <= 0 || !Number.isSafeInteger(attempt) || attempt <= 0 || !/^\d+\.\d+\.\d+(?:-preview\.\d{8}\.\d+)?$/.test(version || '') || !((tag === 'latest' && !version.includes('-')) || (tag === 'preview' && version.includes('-preview.')))) throw Error('invalid publication identity'); if (receipt.sha !== process.env.SOURCE_SHA || receipt.workflowSha !== process.env.WORKFLOW_SHA || receipt.version !== version || receipt.repo !== process.env.GITHUB_REPOSITORY || receipt.runId !== process.env.GITHUB_RUN_ID || !/^[1-9]\d*$/.test(receipt.attempt || '') || BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets) || !Object.keys(receipt.assets).length) throw Error('release asset receipt mismatch'); for (const [name, value] of Object.entries(receipt.assets)) { if (!name || /[\x00-\x1f/\\]/.test(name) || !digest.test(value)) throw Error('invalid release asset receipt'); } const publication = { sourceSha: process.env.SOURCE_SHA, workflowSha: process.env.WORKFLOW_SHA, version, channel: tag === 'latest' ? 'stable' : 'preview', sha256: process.env.EXPECTED_DIGEST, gitHead: process.env.SOURCE_SHA, runId, attempt, published: true, assets: receipt.assets }; fs.writeFileSync('publication/pending.json', JSON.stringify(publication) + '\n', { flag: 'wx' }); NODE - name: Recheck source CI and service evidence env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' import { parseVersion, compareVersions } from "./scripts/version-line.ts"; const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); eligible.sort((a, b) => compareVersions(a.version, b.version)); console.log(eligible.at(-1)?.tag ?? ""); ')" baseline=--initial if [ -n "$baseline_tag" ]; then draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" fi range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" if [ "$required" = true ]; then node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; } fi - name: Refuse a release the current tag set already outranks env: RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail git fetch --force --tags origin allow="" existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$SOURCE_SHA" ]; then echo '::error::Existing release tag points at a different source.'; exit 1 fi if [ "$RESUME" = true ] && [ -n "$existing_tag_sha" ]; then allow="--allow-existing-tag-at-head"; fi git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" ${allow:+"$allow"} - name: Build and validate release changelog env: GH_TOKEN: ${{ github.token }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail notes_file="$GITHUB_WORKSPACE/.release-notes.md" bun scripts/build-release-changelog.ts --version "$RELEASE_VERSION" --dist-tag "$NPM_DIST_TAG" \ --repository "$GITHUB_REPOSITORY" --target "$SOURCE_SHA" --out "$notes_file" test -s "$notes_file" - name: Require publication hold to be enabled env: DRY_RUN: ${{ inputs.dry-run }} PUBLISH_ENABLED: ${{ vars.OCX_DEV_RELEASE_PUBLISH_ENABLED }} run: | if [ "$DRY_RUN" != "true" ] && [ "$PUBLISH_ENABLED" != "true" ]; then echo '::error::Publication is disabled; OCX_DEV_RELEASE_PUBLISH_ENABLED must equal true.' exit 1 fi - name: Require reconciled publication history if: ${{ inputs.resume-after-npm-publish != true }} env: GH_TOKEN: ${{ github.token }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ -z "$RELEASE_VERSION" ]; then echo '::error::Invalid release identity for history reconciliation.'; exit 1 fi case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) echo '::error::Invalid release channel.'; exit 1 ;; esac node .github/scripts/release-history.cjs check "$SOURCE_SHA" "$RELEASE_VERSION" "$channel" - name: Verify resume registry identity id: resume-identity if: ${{ inputs.resume-after-npm-publish == true }} run: | set -euo pipefail pkg_name="$(node -p "require('./package.json').name")" registry_version="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" version --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { echo '::error::Cannot read the exact npm version; resume refused.'; exit 1; } REGISTRY_VERSION="$registry_version" node - <<'NODE' const value = JSON.parse(process.env.REGISTRY_VERSION); if (typeof value !== 'string' || value !== process.env.RELEASE_VERSION) throw Error('npm version does not match original release'); NODE registry_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { echo '::error::Cannot read npm gitHead; resume refused.'; exit 1; } bun scripts/verify-release-resume.ts "$SOURCE_SHA" "$registry_head" echo "resume_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - name: Check resume publication id: resume-check if: ${{ inputs.resume-after-npm-publish == true }} env: GH_TOKEN: ${{ github.token }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) exit 1 ;; esac decision="$(node .github/scripts/release-resume.cjs check "$RELEASE_VERSION" "$SOURCE_SHA" "$channel")" DECISION="$decision" CHANNEL="$channel" node - <<'NODE' const fs = require('node:fs'), result = JSON.parse(process.env.DECISION); if (result.version !== process.env.RELEASE_VERSION || result.sourceSha !== process.env.SOURCE_SHA || result.channel !== process.env.CHANNEL || result.publishNpm !== false || result.assetsChecked !== false) throw Error('invalid resume publication decision'); fs.appendFileSync(process.env.GITHUB_OUTPUT, `resume_checked_sha=${result.sourceSha}\n`); NODE - name: Publish verified npm tarball id: publication env: NPM_DIST_TAG: ${{ inputs.tag }} RESUME: ${{ inputs.resume-after-npm-publish }} VERIFIED_RESUME_SHA: ${{ steps.resume-identity.outputs.resume_sha }} VERIFIED_RESUME_DECISION: ${{ steps.resume-check.outputs.resume_checked_sha }} run: | set -euo pipefail if [ "$RESUME" = true ]; then if [ -z "$VERIFIED_RESUME_SHA" ] || [ "$VERIFIED_RESUME_SHA" != "$SOURCE_SHA" ] || [ -z "$VERIFIED_RESUME_DECISION" ] || [ "$VERIFIED_RESUME_DECISION" != "$SOURCE_SHA" ]; then echo '::error::Resume has no matching registry verification; refusing publication acknowledgement.'; exit 1 fi echo "::notice::Resume verified; npm publish skipped." else npm publish "$NPM_TARBALL" --ignore-scripts --tag "$NPM_DIST_TAG" --access public fi echo "published=true" >> "$GITHUB_OUTPUT" echo "Publication acknowledged for ${RELEASE_VERSION} at ${SOURCE_SHA}; do not republish this version." >> "$GITHUB_STEP_SUMMARY" # A same-run artifact is evidence to be authenticated, not provenance by itself. - name: Record acknowledged publication if: ${{ steps.publication.outputs.published == 'true' && inputs.resume-after-npm-publish != true }} run: | set -euo pipefail test -s publication/pending.json && test ! -e publication/receipt.json mv publication/pending.json publication/receipt.json - name: Upload publication receipt if: ${{ steps.publication.outputs.published == 'true' && inputs.resume-after-npm-publish != true }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-publication-receipt path: publication/receipt.json if-no-files-found: error retention-days: 7 # Publication is acknowledged before registry reads, which can lag or fail. # Recover only observation failures in this run; never retry npm publish. - name: Post-publish registry smoke id: registry-smoke if: ${{ inputs.dry-run != true && steps.publication.outputs.published == 'true' }} env: RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} PUBLISHED: ${{ steps.publication.outputs.published }} run: | set -euo pipefail test "$PUBLISHED" = "true" || { echo "::error::No successful publication receipt; refusing registry recovery" exit 1 } pkg_name="$(node -p "require('./package.json').name")" # npm can take 15+ minutes to serve a new version (2.100.1: ~15 min after publish), so the # bounded wait is attempts x interval, about 20 minutes. Publication is never retried. attempts="${REGISTRY_WAIT_ATTEMPTS:-40}"; interval="${REGISTRY_WAIT_SECONDS:-30}" for attempt in $(seq 1 "$attempts"); do if VERSION=$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" version --fetch-retries=0 --fetch-timeout=8000 2>/dev/null); then if [ "$VERSION" != "$RELEASE_VERSION" ]; then echo "::error::Registry returned an unexpected version; refusing to create a release" exit 1 fi echo "registry version=$VERSION" echo "verification=verified" >> "$GITHUB_OUTPUT" echo "npm_version=confirmed" >> "$GITHUB_OUTPUT" echo "Registry verified ${pkg_name}@${RELEASE_VERSION}." >> "$GITHUB_STEP_SUMMARY" # The dist-tag is its own outcome: a version can be on the registry while the tag # still names the previous release. dist_tag_state="unconfirmed" if dist_tags="$(timeout --kill-after=2s 10s npm dist-tag ls "$pkg_name" --fetch-retries=0 --fetch-timeout=8000)"; then printf '%s\n' "$dist_tags" tagged="$(printf '%s\n' "$dist_tags" | awk -F': ' -v tag="$NPM_DIST_TAG" '$1 == tag { print $2; exit }')" if [ "$tagged" = "$RELEASE_VERSION" ]; then dist_tag_state="confirmed" elif [ -n "$tagged" ]; then dist_tag_state="mismatch" echo "::warning::npm dist-tag ${NPM_DIST_TAG} points at ${tagged}, not ${RELEASE_VERSION}" else echo "::warning::npm dist-tag ${NPM_DIST_TAG} is not listed for ${pkg_name}" fi else echo "::warning::Could not read npm dist-tags; exact version was verified" fi echo "npm_dist_tag=${dist_tag_state}" >> "$GITHUB_OUTPUT" echo "npm dist-tag ${NPM_DIST_TAG}: ${dist_tag_state}." >> "$GITHUB_STEP_SUMMARY" exit 0 fi echo "::notice::Registry lookup not confirmed (attempt $attempt/$attempts)" if [ "$attempt" -lt "$attempts" ]; then sleep "$interval"; fi done # Keep each outcome explicit for the recovery report. # shellcheck disable=SC2129 echo "verification=pending" >> "$GITHUB_OUTPUT" echo "npm_version=unconfirmed" >> "$GITHUB_OUTPUT" echo "npm_dist_tag=unconfirmed" >> "$GITHUB_OUTPUT" echo "::warning::npm publish succeeded, but registry verification remains pending; continuing GitHub release creation without republishing" echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION}; registry verification pending after bounded reads. Inspect the registry before announcing availability. Do not republish this version." >> "$GITHUB_STEP_SUMMARY" - name: Verify published npm source if: ${{ steps.publication.outputs.published == 'true' }} run: | set -euo pipefail pkg_name="$(node -p "require('./package.json').name")" attempts="${REGISTRY_WAIT_ATTEMPTS:-40}"; interval="${REGISTRY_WAIT_SECONDS:-30}" for attempt in $(seq 1 "$attempts"); do if head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000 2>/dev/null)"; then if [ -n "$head" ] && [ "$head" != null ]; then REGISTRY_HEAD="$head" node - <<'NODE' const head = JSON.parse(process.env.REGISTRY_HEAD); if (!/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA || '') || head !== process.env.SOURCE_SHA) { throw Error('published npm gitHead does not match selected source'); } NODE exit 0 fi fi if [ "$attempt" -lt "$attempts" ]; then sleep "$interval"; fi done echo '::error::Published npm gitHead could not be verified; do not republish and reconcile manually.' exit 1 - name: Create GitHub release if: ${{ inputs.dry-run != true && steps.publication.outputs.published == 'true' }} env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail release_tag="v${RELEASE_VERSION}" notes_file="$GITHUB_WORKSPACE/.release-notes.md" test -s "$notes_file" || { echo "::error::validated release notes are missing; refusing to tag or create a release" exit 1 } git fetch --force --tags origin existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$SOURCE_SHA" ]; then echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${SOURCE_SHA}" exit 1 fi prerelease_flag="" if [[ "$RELEASE_VERSION" == *-preview.* ]]; then prerelease_flag="--prerelease" fi if [ -z "$existing_tag_sha" ]; then git tag "$release_tag" "$SOURCE_SHA" git push origin "refs/tags/${release_tag}" fi # Idempotent only for the resume path: a previous run may already have # created the release and then failed before the assets were attached. # Outside resume, finding a release here means the preflight was bypassed # or the release appeared mid-run, and that stays a hard failure. if gh release view "$release_tag" >/dev/null 2>&1; then if [ "$RESUME" = "true" ]; then echo "::notice::GitHub Release ${release_tag} already exists; reusing it for attachment" else echo "::error::GitHub Release ${release_tag} already exists; refusing to reuse it outside the resume path" exit 1 fi else # Draft first. Publication freezes a release under GitHub's immutable # releases, so attach-release attaches the verified bundle to the draft # and publishes it afterwards. gh release create "$release_tag" --draft --target "$SOURCE_SHA" --title "$release_tag" \ --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} fi