From f36f413518025df386ef58944b7cdaffa4121117 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Wed, 23 Sep 2026 06:24:36 -0400 Subject: [PATCH 01/10] feat(release): add fork release pipeline with fork update isolation The fork inherited upstream's release.yml, which needs Blacksmith runners, production relay/Clerk/Cloudflare/Vercel credentials, and upstream npm publication, so the fork had no runnable release path. Update resolution also hardcoded pingdotgg/t3code for the release-index lookup, so t3 update and the install scripts could silently select upstream releases. - Add .github/workflows/fork-release.yml: builds one immutable SHA and explicit version into a Windows x64 installer with its matching Linux x64 WSL runtime, an Intel macOS DMG, and a Linux x64 runtime archive, reusing release-desktop.yml and the existing packaging scripts. GitHub-hosted standard runners only; build jobs are contents: read and publication is a separate write job behind an environment. - Default the shared release repository to nullStack65/t3code with a T3CODE_RELEASE_REPOSITORY override, closing the index gap in t3 update, the managed/pinned runtime, the SSH tunnel runtime, and the install scripts. - Add scripts/fork-release-version.ts: a tested fork version line that is strictly increasing, excludes preview/nightly identifiers, and never relies on SemVer build metadata. - Embed repository, full source SHA, version, and architecture in the desktop app and the CLI archive, and qualify checksums from final distributed bytes. - Ship unsigned macOS builds without an update feed: Squirrel.Mac cannot apply an unsigned update. --- .github/workflows/fork-release.yml | 587 +++++++++++++++++++++++++ docs/operations/fork-release.md | 161 +++++++ docs/user/fork-install.md | 51 +++ packages/shared/src/cliRelease.test.ts | 27 +- packages/shared/src/cliRelease.ts | 39 +- packages/ssh/src/tunnel.test.ts | 2 +- scripts/build-cli-archive.ts | 24 + scripts/build-desktop-artifact.test.ts | 35 +- scripts/build-desktop-artifact.ts | 60 ++- scripts/fork-release-version.test.ts | 100 +++++ scripts/fork-release-version.ts | 210 +++++++++ scripts/install.ps1 | 4 +- scripts/install.sh | 4 +- scripts/lib/source-provenance.test.ts | 114 +++++ scripts/lib/source-provenance.ts | 165 +++++++ 15 files changed, 1548 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/fork-release.yml create mode 100644 docs/operations/fork-release.md create mode 100644 docs/user/fork-install.md create mode 100644 scripts/fork-release-version.test.ts create mode 100644 scripts/fork-release-version.ts create mode 100644 scripts/lib/source-provenance.test.ts create mode 100644 scripts/lib/source-provenance.ts diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 000000000000..1cc90ea8f62c --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,587 @@ +name: Fork release + +# Fork-only release entry point. It builds one immutable SHA into the fork's +# required artifacts and, only when explicitly told to, publishes them as a +# GitHub Release on `nullStack65/t3code`. It deliberately does not reuse +# upstream's `release.yml`: that workflow requires Blacksmith runners, the +# production relay/Clerk/Cloudflare/Vercel/npm/AUR/Discord credentials, and +# publishes upstream npm packages. This one reuses the existing packaging +# scripts and `release-desktop.yml` unchanged. +# +# Targets built here: +# - Windows x64 NSIS installer, with the matching Linux x64 CLI archive +# embedded as its WSL runtime (the mechanism `release-desktop.yml` already +# implements). +# - Intel macOS x64 DMG. +# - Linux x64 self-contained CLI/runtime archive. +# - Apple Silicon macOS is available behind `include_macos_arm64` and is not +# exercised by default. +# +# Runners are GitHub-hosted standard labels only: free for this public repo, +# not paid `-large`/`-xlarge`, not guessed, and no self-hosted registration. +on: + workflow_dispatch: + inputs: + sha: + description: "Immutable source SHA to build (full 40-char commit)." + required: true + type: string + version: + description: "Fork release version (plain X.Y.Z, e.g. 0.0.43)." + required: true + type: string + upstream_base: + description: "Upstream version this fork build is based on (e.g. 0.0.42)." + required: true + type: string + publish: + description: "Publish the built artifacts as a GitHub Release. Off builds a candidate only." + required: false + default: false + type: boolean + include_macos_arm64: + description: "Also build the Apple Silicon macOS DMG (untested by default)." + required: false + default: false + type: boolean + macos_x64_runner: + description: "Runner label for the Intel macOS job." + required: false + default: macos-15-intel + type: string + +permissions: + contents: read + +concurrency: + group: fork-release-${{ inputs.version }} + cancel-in-progress: false + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + version: ${{ steps.meta.outputs.version }} + sha: ${{ steps.meta.outputs.sha }} + channel: ${{ steps.meta.outputs.channel }} + relay_url: ${{ steps.public_config.outputs.relay_url }} + clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} + steps: + - name: Checkout the release commit + shell: bash + env: + CHECKOUT_REF: ${{ inputs.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git fetch --no-tags origin main + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + # A release must build a commit that is actually on fork main, not an + # arbitrary branch tip, so the tag and the artifacts cannot disagree. + - name: Assert the source SHA is on fork main + shell: bash + env: + RELEASE_SHA: ${{ inputs.sha }} + run: | + set -euo pipefail + if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then + echo "::error::$RELEASE_SHA is not an ancestor of origin/main." + exit 1 + fi + + # Validate the version against the fork's own version line: plain X.Y.Z, + # newer than the upstream base, and newer than every published fork + # release. Preview/nightly identifiers are rejected so they can never be + # discovered as an update. + - id: meta + name: Validate fork release version + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + UPSTREAM_BASE: ${{ inputs.upstream_base }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + existing="$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName --jq '[.[].tagName | sub("^v"; "")] | join(",")')" + echo "Existing fork releases: ${existing:-}" + node scripts/fork-release-version.ts \ + --upstream-base "$UPSTREAM_BASE" \ + --existing "$existing" \ + --version "$RELEASE_VERSION" \ + --github-output + echo "sha=${{ inputs.sha }}" >> "$GITHUB_OUTPUT" + channel="$(node -e 'const v=process.env.RELEASE_VERSION;const m=/-([a-z]+)\.\d{8}\.\d+$/.exec(v);process.stdout.write(m?m[1]:"stable")')" + echo "channel=$channel" >> "$GITHUB_OUTPUT" + + # Public T3 Connect identifiers, identical to `.env.example` and + # overridable by repository variables. These are not secrets; they keep + # existing installs' pairing and relay state working across the upgrade. + - id: public_config + name: Resolve public T3 Connect configuration + shell: bash + env: + RELAY_URL: ${{ vars.T3CODE_RELAY_URL }} + CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_TEMPLATE: ${{ vars.T3CODE_CLERK_JWT_TEMPLATE }} + CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID }} + run: | + set -euo pipefail + { + echo "relay_url=${RELAY_URL:-https://relay.t3.codes}" + echo "clerk_publishable_key=${CLERK_PUBLISHABLE_KEY:-pk_live_Y2xlcmsudDMuY29kZXMk}" + echo "clerk_jwt_template=${CLERK_JWT_TEMPLATE:-t3-relay}" + echo "clerk_cli_oauth_client_id=${CLERK_CLI_OAUTH_CLIENT_ID:-hzxSgY2cH10sDU2r}" + } >> "$GITHUB_OUTPUT" + + bundle: + name: Build JS bundle + needs: [preflight] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.preflight.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.preflight.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.preflight.outputs.relay_url }} + steps: + - name: Checkout + shell: bash + env: + CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install bundle dependencies + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... + + - name: Align package versions to the fork release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - uses: ./.github/actions/setup-apt-mirrors + + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + + - name: Build JS bundle + run: vp run build:desktop + + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron + if-no-files-found: error + retention-days: 1 + + cli_linux_x64: + name: Linux x64 runtime archive + needs: [preflight, bundle] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + VP_NODE_VERSION: "26.8.2" + steps: + - name: Checkout + shell: bash + env: + CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install CLI dependencies + run: vp install --filter=t3... --filter=@t3tools/scripts... + + - name: Align package versions to the fork release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Download JS bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: js-bundle + path: apps + + - name: Setup Rust + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + + # The helper is rebuilt from this SHA rather than copied from any + # installed app, so the archive's runtime matches its own source. + - name: Build resource monitor + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml + + - name: Stage resource monitor for the archive + shell: bash + run: | + set -euo pipefail + target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" + mkdir -p "$target_dir" + cp native/resource-monitor/target/release/t3-resource-monitor "$target_dir/t3-resource-monitor" + + - name: Build CLI single-executable + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Build CLI archive + run: | + node scripts/build-cli-archive.ts \ + --platform linux \ + --arch x64 \ + --version "${{ needs.preflight.outputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" + + - name: Verify archive provenance + shell: bash + env: + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + tmp="$RUNNER_TEMP/verify-cli" + mkdir -p "$tmp" + tar -xzf release-cli/*.tar.gz -C "$tmp" + info="$(find "$tmp" -name t3code-build-info.json -print -quit)" + test -n "$info" || { echo "::error::CLI archive is missing t3code-build-info.json"; exit 1; } + node -e ' + const fs = require("node:fs"); + const info = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const expected = { + repository: "nullStack65/t3code", + sourceSha: process.env.RELEASE_SHA, + version: process.env.RELEASE_VERSION, + arch: "x64", + }; + for (const [key, value] of Object.entries(expected)) { + if (info[key] !== value) { + console.error(`::error::CLI archive ${key} is ${info[key]}, expected ${value}`); + process.exit(1); + } + } + console.log("CLI archive provenance verified:", info); + ' "$info" + + - name: Upload CLI archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-linux-x64 + path: release-cli/* + if-no-files-found: error + + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, bundle, cli_linux_x64] + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.sha }} + release_channel: ${{ needs.preflight.outputs.channel }} + clerk_publishable_key: ${{ needs.preflight.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.preflight.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.preflight.outputs.relay_url }} + label: Windows x64 + runner: windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: false + relay_client_tracing: false + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.sha }} + release_channel: ${{ needs.preflight.outputs.channel }} + clerk_publishable_key: ${{ needs.preflight.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.preflight.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.preflight.outputs.relay_url }} + label: macOS x64 + runner: ${{ inputs.macos_x64_runner }} + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + cli_archive: false + relay_client_tracing: false + + desktop_mac_arm64: + name: Desktop macOS arm64 + if: ${{ inputs.include_macos_arm64 }} + needs: [preflight, bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.sha }} + release_channel: ${{ needs.preflight.outputs.channel }} + clerk_publishable_key: ${{ needs.preflight.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.preflight.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.preflight.outputs.relay_url }} + label: macOS arm64 + runner: macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + relay_client_tracing: false + + qualify: + name: Qualify candidate + needs: [preflight, desktop_win_x64, desktop_mac_x64, cli_linux_x64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + shell: bash + env: + CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Download desktop artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-* + merge-multiple: true + path: candidate + + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: cli-* + merge-multiple: true + path: candidate + + - name: Normalize updater manifests and drop unsupported ones + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + cd candidate + shopt -s nullglob + # Windows x64 is the only platform with a supported in-app update + # (the installer verifies the downloaded hash). Its per-arch manifest + # is renamed to the channel manifest electron-updater reads. + for manifest in latest-win-x64.yml nightly-win-x64.yml; do + [[ -f "$manifest" ]] && mv "$manifest" "${manifest%-win-x64.yml}.yml" + done + # macOS ships unsigned here, so it must carry no update feed and no + # updater metadata; remove any that leaked in. + rm -f *-mac-x64.yml *-mac-arm64.yml latest-mac.yml nightly-mac.yml + # electron-builder's config dump is not a release asset. + rm -f builder-debug.yml + # A preview build must never carry updater metadata at all. + if [[ "${{ needs.preflight.outputs.channel }}" == "preview" ]]; then + rm -f *.yml *.blockmap + fi + + - name: Assert the required artifact set exists + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + cd candidate + shopt -s nullglob + fail=0 + check() { [[ -e "$1" ]] || { echo "::error::missing required artifact: $1"; fail=1; }; } + check "T3-Code-${RELEASE_VERSION}-x64.exe" + check "T3-Code-${RELEASE_VERSION}-x64.dmg" + check "t3-${RELEASE_VERSION}-linux-x64.tar.gz" + check "latest.yml" + if [[ "${{ inputs.include_macos_arm64 }}" == "true" ]]; then + check "T3-Code-${RELEASE_VERSION}-arm64.dmg" + fi + (( fail == 0 )) || exit 1 + echo "Required artifact set present." + ls -la + + # The Windows installer's WSL runtime is embedded and fail-closed inside + # `build-desktop-artifact.ts` (archive + SHA-256 sidecar present, digest + # matches, unpacks to one t3--linux- directory with t3, + # client/, node-pty's linux binary, and no loose server bundle). Re-check + # the standalone archive here so the bytes a Linux user downloads are the + # same release archive. + - name: Verify the standalone Linux runtime archive + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + tmp="$RUNNER_TEMP/qualify-cli" + mkdir -p "$tmp" + tar -xzf "candidate/t3-${RELEASE_VERSION}-linux-x64.tar.gz" -C "$tmp" + stem="t3-${RELEASE_VERSION}-linux-x64" + test -x "$tmp/$stem/t3" + test -d "$tmp/$stem/client" + test -f "$tmp/$stem/node_modules/node-pty/build/Release/pty.node" + test ! -e "$tmp/$stem/bin.mjs" + node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const stem = process.argv[1]; + const info = JSON.parse(fs.readFileSync(path.join(stem, "t3code-build-info.json"), "utf8")); + const expected = { + repository: "nullStack65/t3code", + sourceSha: process.env.RELEASE_SHA, + version: process.env.RELEASE_VERSION, + arch: "x64", + platform: "linux", + }; + for (const [key, value] of Object.entries(expected)) { + if (info[key] !== value) { + console.error(`::error::Linux runtime ${key} is ${info[key]}, expected ${value}`); + process.exit(1); + } + } + console.log("Linux runtime provenance verified:", info); + ' "$tmp/$stem" + + # Checksums come from the final distributed bytes, after manifest + # normalization, never from an earlier build stage. + - name: Write SHA-256 checksums + shell: bash + run: | + set -euo pipefail + cd candidate + shopt -s nullglob + files=(*.exe *.dmg *.zip *.tar.gz *.yml *.blockmap) + if [[ ${#files[@]} -eq 0 ]]; then + echo "::error::no artifacts to checksum" + exit 1 + fi + sha256sum "${files[@]}" | tee SHA256SUMS + cat SHA256SUMS + + - name: Upload candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: fork-release-candidate + path: candidate/* + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish GitHub Release + needs: [preflight, qualify] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # Publication is the only job with write access, and it runs in a named + # environment so it can require manual approval. + permissions: + contents: write + environment: + name: fork-release + steps: + - name: Download candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: fork-release-candidate + path: candidate + + # The tag and the target are the exact release version and source SHA + # that were built and qualified above, so a published release can never + # point at a different commit than its artifacts. + - name: Create the GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + tag="v${RELEASE_VERSION}" + notes="$(mktemp)" + { + echo "Fork build of T3 Code \`${RELEASE_SHA}\` (upstream base \`${{ inputs.upstream_base }}\`)." + echo + echo "Repository: \`nullStack65/t3code\`. Desktop in-app updates are published for Windows x64 only; macOS and Linux installs update by downloading the new artifact." + echo + echo "Assets are checksummed in \`SHA256SUMS\`." + } > "$notes" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$RELEASE_SHA" \ + --title "T3 Code (fork) v${RELEASE_VERSION}" \ + --notes-file "$notes" \ + --latest \ + candidate/* diff --git a/docs/operations/fork-release.md b/docs/operations/fork-release.md new file mode 100644 index 000000000000..8460e1e9b084 --- /dev/null +++ b/docs/operations/fork-release.md @@ -0,0 +1,161 @@ +# Fork release procedure + +> For maintainers of the `nullStack65/t3code` fork. Upstream release docs live in +> [release.md](./release.md) and do not apply here. + +This fork ships its own desktop and CLI artifacts from its own GitHub Releases. +It deliberately does not use `.github/workflows/release.yml`: that workflow +requires upstream's Blacksmith runners, the production relay/Clerk/Cloudflare/ +Vercel credentials, and publishes upstream npm packages. The fork entry point is +`.github/workflows/fork-release.yml`, which reuses the existing packaging scripts +and `release-desktop.yml` unchanged. + +## Runners + +Build jobs run on GitHub-hosted standard labels only: + +| Target | Label | +| ----------------- | ------------------------------------------------------------- | +| Linux CLI archive | `ubuntu-24.04` | +| Windows x64 | `windows-2025` | +| Intel macOS x64 | `macos-15-intel` (override with the `macos_x64_runner` input) | + +These are free for a public repository, are not the paid `-large`/`-xlarge` +variants, are not guessed, and require no self-hosted registration. If a label +is ever unavailable, pass an authorized runner label through the input and +record why. + +Apple Silicon macOS is built by the same workflow only when +`include_macos_arm64` is set; it is not exercised by default and is reported as +untested. + +## Versioning + +Fork releases are plain `X.Y.Z` and are strictly increasing. The workflow +validates the requested version against the fork's published releases with +`scripts/fork-release-version.ts`: + +```sh +# Next version above upstream base 0.0.42 and the existing 0.0.43. +node scripts/fork-release-version.ts --upstream-base 0.0.42 --existing 0.0.43 + +# Reject anything that is not newer, or that carries a prerelease identifier. +node scripts/fork-release-version.ts --upstream-base 0.0.42 --version 0.0.43-preview.20260923.1 +``` + +Rules: + +- Preview and nightly identifiers are rejected. A fork preview is a manual + download and must never be discoverable as an update. +- The version must be newer than the upstream base it was built from and newer + than every existing fork release, so an update can never move backwards and + never lands on a version number an upstream build also uses. +- SemVer build metadata is never used for ordering: `0.0.43+fork.1` is rejected. + +## Release procedure + +1. Pick the immutable source SHA on `main` and the upstream base version. +2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, and + `upstream_base`. Leave `publish` off to build a candidate. +3. The workflow builds the JS bundle once, then packages: + - `T3-Code--x64.exe` (NSIS) with the matching + `t3--linux-x64.tar.gz` embedded as its WSL runtime; + - `T3-Code--x64.dmg` (Intel macOS, unsigned unless Apple secrets + exist); + - `t3--linux-x64.tar.gz` (self-contained Linux runtime archive). +4. `qualify` re-checks the artifact set, re-verifies the Linux archive's + provenance, and writes `SHA256SUMS` from the final distributed bytes. +5. Native acceptance on real Windows/WSL and Intel macOS hardware. +6. Re-run with `publish: true` to create the GitHub Release. The tag and target + are the exact version and source SHA that were built and qualified. The + publication job is the only one with `contents: write` and runs in the + `fork-release` environment, so it can require manual approval. + +Queued CI is not a passed release gate; a release is only qualified when the +candidate artifact set exists and native smoke tests pass. + +## Provenance and checksums + +Every artifact embeds the repository, full source SHA, release version, and +architecture: + +- Desktop: `apps/desktop` staged `package.json` fields + (`t3codeSourceRepository`, `t3codeSourceSha`, `t3codeBuildVersion`, + `t3codeBuildArch`) plus a readable `t3code-build-info.json` in the packaged + app. +- CLI archive: a readable `t3code-build-info.json` at the archive root. + +`SHA256SUMS` is generated by the `qualify` job from the bytes that are actually +distributed, after updater manifests are normalized. The Windows installer's +embedded WSL payload is fail-closed at build time inside +`scripts/build-desktop-artifact.ts`: the archive and its SHA-256 sidecar must +both exist, the digest must match, and the archive must unpack to a single +`t3--linux-` directory holding `t3`, `client/`, and +`node_modules/node-pty/build/Release/pty.node` with no loose server bundle. +Native helpers are rebuilt from the selected source; they are never copied from +an installed app. + +## Update isolation + +No fork install or update path may select an upstream `pingdotgg` release: + +- `packages/shared/src/cliRelease.ts` defaults the release repository to + `nullStack65/t3code` for both the download base URL and the release-index + lookup that `t3 update` and the install scripts use. `T3CODE_RELEASE_REPOSITORY` + overrides it; `T3CODE_RELEASE_BASE_URL` still overrides only the download + origin for mirrors. +- `scripts/install.sh` and `scripts/install.ps1` default to the fork and honor + `T3CODE_RELEASE_REPOSITORY`. +- Desktop `app-update.yml` is derived from `T3CODE_DESKTOP_UPDATE_REPOSITORY` + or `GITHUB_REPOSITORY`, which is the fork in this repository. + +## Signing + +Three distinct support levels: + +- **Unsigned manual install** (always buildable): Windows and macOS artifacts + are produced without credentials. On macOS this means the app is not + notarized and the user opens it once via Gatekeeper's Open action. +- **Signed/notarized** (only when Apple/Azure secrets exist): the existing + auto-detect path in `release-desktop.yml` is reused. +- **In-app desktop update**: published for Windows x64 only. An unsigned macOS + build cannot complete a Squirrel.Mac update, so macOS is built without an + update feed and no macOS updater manifest is attached. Linux updates by + downloading the new archive. + +No signing credentials are provisioned or purchased by this workflow. + +## Migrating existing installs + +Installs that were built without a feed (today's local `0.0.42` builds) do not +self-migrate. Install the first release-managed build explicitly by downloading +the installer or archive from the fork release and running it over the existing +install; user data, credentials, pairings, projects, and databases are +preserved because `appId`, product name, and user-data paths are unchanged. + +Recovery note: an older binary is not automatically a safe database rollback. +If a release adds a database migration, restore a pre-upgrade snapshot rather +than only reinstalling the older binary. + +## Prerequisites + +For users (not build tooling): + +- Windows: x64, WSL 2 with a distro selected in **Settings → Connections** for + the WSL backend. Provider CLIs are installed inside the distro. +- Intel macOS: macOS with the x64 build. Unsigned builds need a one-time + Gatekeeper approval. +- Linux: x64 with `sh`, `tar`, and `sha256sum` or `shasum`; the runtime archive + needs no Node, npm, or compiler. + +Building locally needs Node (per `engines.node`), `vp`, and Rust only when the +native helpers are rebuilt. The release workflow installs all of these. + +## Known limitations + +- Apple Silicon macOS is available but untested by default. +- Intel macOS has no `t3` CLI archive: Node single-executables are unsupported + on x64 macOS. +- The fork still points at the public upstream T3 Connect relay/Clerk + identifiers by default, so pairing state survives upgrades; override with + repository variables to disable cloud features. diff --git a/docs/user/fork-install.md b/docs/user/fork-install.md new file mode 100644 index 000000000000..6d164f435e27 --- /dev/null +++ b/docs/user/fork-install.md @@ -0,0 +1,51 @@ +# Install the fork build of T3 Code + +> This page is for the `nullStack65/t3code` fork. Official T3 Code installs and +> updates come from `pingdotgg/t3code`; see [install.md](./install.md) for those. + +Fork builds are published on the fork's +[GitHub Releases](https://github.com/nullStack65/t3code/releases). Download the +artifact for your platform and install it; nothing here needs Node, npm, or a +compiler. + +## Windows x64 + +1. Download `T3-Code--x64.exe` and run it. It installs per user and + upgrades an existing T3 Code install in place. +2. For the WSL backend, install WSL 2 and a distro, then pick it in + **Settings → Connections**. The installer already contains the matching + Linux runtime, so no separate download is needed. + +## Intel macOS + +1. Download `T3-Code--x64.dmg` and copy the app to Applications. +2. The fork build is unsigned unless the maintainers signed it. If macOS + refuses to open it, right-click the app and choose **Open** once. +3. macOS has no fork `t3` CLI archive; run the desktop app, or build the server + from source. + +## Linux x64 + +Download the self-contained runtime archive and extract it, or install with the +fork installer: + +```sh +curl -fsSL https://raw.githubusercontent.com/nullStack65/t3code/main/scripts/install.sh | sh +``` + +This puts `t3` in `~/.local/bin`. Set `T3CODE_CHANNEL=nightly` only if you know +why; fork releases are plain stable versions. `T3CODE_RELEASE_REPOSITORY` and +`T3CODE_RELEASE_BASE_URL` exist for mirrors. + +## Updating + +Download the newer artifact and install it over the existing one. Your settings, +sign-in, pairings, projects, and databases are preserved. + +- Windows and Linux check for and download fork updates. +- Intel macOS updates by downloading the new DMG; in-app macOS updates are not + supported for unsigned fork builds. + +If you are coming from a build that had no update feed, this first +release-managed install is the migration: install it once by hand, and later +updates can follow the fork release channel. diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index c92421db5ec2..389b12af3324 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -9,6 +9,7 @@ import { cliReleaseIndexPageUrl, newestCliReleaseVersion, parseChecksums, + resolveCliReleaseRepository, } from "./cliRelease.ts"; describe("cliRelease", () => { @@ -33,11 +34,30 @@ describe("cliRelease", () => { it("resolves download URLs under the tagged release, honoring a mirror", () => { expect(cliReleaseDownloadBaseUrl("1.2.3")).toBe( - "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/pingdotgg/t3code/releases/download/v1.2.3", + "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/nullStack65/t3code/releases/download/v1.2.3", ); expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe( "https://mirror.example/t3/v1.2.3", ); + expect(cliReleaseDownloadBaseUrl("1.2.3", undefined, "example-org/fork")).toBe( + "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/example-org/fork/releases/download/v1.2.3", + ); + }); + + it("defaults the release repository to the fork and honors an override", () => { + // The shipped default must never be upstream, or a fork install would + // discover and download official `pingdotgg` builds. + expect(resolveCliReleaseRepository({})).toBe("nullStack65/t3code"); + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: "example-org/fork" })).toBe( + "example-org/fork", + ); + // A blank or malformed override must not silently redirect to nothing. + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: " " })).toBe( + "nullStack65/t3code", + ); + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: "not-a-repo" })).toBe( + "nullStack65/t3code", + ); }); it("parses sha256sum output including binary-mode markers", () => { @@ -87,8 +107,11 @@ describe("cliRelease", () => { it("pages through the release index at the largest page GitHub allows", () => { expect(cliReleaseIndexPageUrl(1)).toBe( - "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1", + "https://api.github.com/repos/nullStack65/t3code/releases?per_page=100&page=1", ); expect(cliReleaseIndexPageUrl(3)).toContain("page=3"); + expect(cliReleaseIndexPageUrl(2, "example-org/fork")).toBe( + "https://api.github.com/repos/example-org/fork/releases?per_page=100&page=2", + ); }); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 28f0d530bb29..a3d19698558e 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -5,11 +5,32 @@ * platform key, so a rename here is a release-breaking change. */ -const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +/** + * The repository this build resolves its own releases from. This is a fork, so + * the default must never be upstream: an install that fell back to + * `pingdotgg/t3code` would silently update onto an official build and lose the + * fork. `T3CODE_RELEASE_REPOSITORY` overrides it for mirrors and tests; unlike + * `T3CODE_RELEASE_BASE_URL` it also retargets the release-index lookup that + * `t3 update` and the install scripts use to discover a version. + */ +export const CLI_RELEASE_REPOSITORY = "nullStack65/t3code"; +export const CLI_RELEASE_REPOSITORY_ENV = "T3CODE_RELEASE_REPOSITORY"; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; /** Overrides the download origin for mirrors and air-gapped installs. */ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; +const REPOSITORY_PATTERN = /^[^/\s]+\/[^/\s]+$/; + +/** The `owner/repo` this build downloads and discovers releases from. */ +export function resolveCliReleaseRepository( + env: Readonly> = process.env, +): string { + const override = env[CLI_RELEASE_REPOSITORY_ENV]?.trim(); + return override !== undefined && override !== "" && REPOSITORY_PATTERN.test(override) + ? override + : CLI_RELEASE_REPOSITORY; +} + /** * The archives a release attaches. Kept in step with the build_linux_cli * matrix, build_windows_arm64_cli, and the `cli_archive` rows in @@ -54,14 +75,17 @@ export function cliArchiveFileName(version: string, platformKey: CliArchivePlatf return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; } -const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; +const CLI_RELEASE_DEFAULT_BASE_URL = (repository: string) => + `https://github.com/${repository}/releases/download`; /** Directory that `releases/download//` lives under. */ export function cliReleaseDownloadBaseUrl( version: string, - baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, + baseUrl: string | undefined = undefined, + repository: string = resolveCliReleaseRepository(), ): string { - return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; + const origin = baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL(repository); + return `${origin.replace(/\/+$/, "")}/v${version}`; } /** @@ -97,8 +121,11 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel { * until a channel match turns up; a busy nightly train can push the newest * preview or stable release past any single page. */ -export function cliReleaseIndexPageUrl(page: number): string { - return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; +export function cliReleaseIndexPageUrl( + page: number, + repository: string = resolveCliReleaseRepository(), +): string { + return `https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`; } /** diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..d736d65aa7ab 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -118,7 +118,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include( script, - "T3_RELEASE_BASE_URL='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/pingdotgg/t3code/releases/download'", + "T3_RELEASE_BASE_URL='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/nullStack65/t3code/releases/download'", ); assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"'); assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index e5839989efc7..d1344864719e 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -42,6 +42,14 @@ import { } from "./build-desktop-artifact.ts"; import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; +import { + BUILD_INFO_FILE_NAME, + createBuildInfo, + readGitSourceProvenance, + resolveSourceRepository, + resolveSourceSha, + serializeBuildInfo, +} from "./lib/source-provenance.ts"; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64"]); @@ -517,6 +525,22 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { version: input.version, }); + // Provenance travels with the archive so an installer, a WSL extraction, or + // a human can read the exact repository, full source SHA, version, and + // architecture without trusting the file name. + const gitSource = yield* readGitSourceProvenance(repoRoot); + const buildInfo = createBuildInfo({ + version: input.version, + platform: input.platform, + arch: input.arch, + repository: resolveSourceRepository(process.env), + sourceSha: resolveSourceSha(process.env, gitSource.sourceSha), + }); + yield* fs.writeFileString( + path.join(contentDir, BUILD_INFO_FILE_NAME), + `${yield* serializeBuildInfo(buildInfo)}\n`, + ); + const executablePath = path.join(contentDir, executableName); if (input.platform === "mac") { yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 05fc0baa45a0..731acdd3ddab 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -318,7 +318,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); - it.effect("omits update feeds for pull request preview builds", () => + it.effect("omits update feeds for preview builds and unsigned macOS builds", () => Effect.gen(function* () { const preview = yield* createBuildConfig( "mac", @@ -329,7 +329,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, undefined, ); - const release = yield* createBuildConfig( + // Unsigned macOS cannot complete a Squirrel.Mac update, so it must not + // poll a feed it can never apply. + const unsignedMac = yield* createBuildConfig( "mac", "dmg", "0.0.33", @@ -338,6 +340,24 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, undefined, ); + const signedMac = yield* createBuildConfig( + "mac", + "dmg", + "0.0.33", + true, + false, + undefined, + undefined, + ); + const unsignedWindows = yield* createBuildConfig( + "win", + "nsis", + "0.0.33", + false, + false, + undefined, + undefined, + ); const previewChannel = yield* createBuildConfig( "mac", @@ -351,7 +371,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(preview, "publish"); assert.notProperty(previewChannel, "publish"); - assert.deepStrictEqual(release.publish, [ + assert.notProperty(unsignedMac, "publish"); + assert.deepStrictEqual(signedMac.publish, [ + { + provider: "github", + owner: "pingdotgg", + repo: "t3code", + releaseType: "release", + }, + ]); + assert.deepStrictEqual(unsignedWindows.publish, [ { provider: "github", owner: "pingdotgg", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 02e514d83fbd..0698ba65da26 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -36,6 +36,14 @@ import { import { loadRepoEnv } from "./lib/public-config.ts"; import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; +import { + BUILD_INFO_FILE_NAME, + createBuildInfo, + readGitSourceProvenance, + resolveSourceRepository, + resolveSourceSha, + serializeBuildInfo, +} from "./lib/source-provenance.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -824,26 +832,8 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( }); const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { - const result = yield* spawnAndCollectOutput( - ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { - cwd: repoRoot, - }), - ).pipe( - Effect.orElseSucceed(() => ({ - stdout: "", - stderr: "", - exitCode: 1, - })), - ); - - if (result.exitCode !== 0) { - return "unknown"; - } - const hash = result.stdout.trim(); - if (!/^[0-9a-f]{7,40}$/i.test(hash)) { - return "unknown"; - } - return hash.toLowerCase(); + const gitSource = yield* readGitSourceProvenance(repoRoot); + return resolveSourceSha(process.env, gitSource.sourceSha); }); const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { @@ -926,6 +916,10 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly t3codeSourceRepository: string; + readonly t3codeSourceSha: string; + readonly t3codeBuildVersion: string; + readonly t3codeBuildArch: string; readonly private: true; readonly packageManager: string; readonly description: string; @@ -2668,7 +2662,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], }; const updateChannel = resolveDesktopUpdateChannel(version); - if (!isDesktopPreviewVersion(version)) { + // An unsigned macOS build cannot complete a Squirrel.Mac update: the new + // bundle must carry the same signature as the running one, so an unsigned + // build polls for an update it can never apply. Ship it without a feed + // instead. Windows updates verify the downloaded bytes against the manifest + // hash rather than a signature, so an unsigned Windows build keeps its feed. + const supportsUpdateFeed = platform !== "mac" || signed; + if (!isDesktopPreviewVersion(version) && supportsUpdateFeed) { const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); if (publishConfig) { buildConfig.publish = [publishConfig]; @@ -3398,6 +3398,14 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const appVersion = options.version ?? serverPackageJson.version; const iconAssets = resolveDesktopBuildIconAssets(appVersion); const commitHash = yield* resolveGitCommitHash(repoRoot); + const sourceRepository = resolveSourceRepository(process.env); + const buildInfo = createBuildInfo({ + version: appVersion, + platform: options.platform, + arch: options.arch, + repository: sourceRepository, + sourceSha: commitHash, + }); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, @@ -3639,6 +3647,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + t3codeSourceRepository: buildInfo.repository, + t3codeSourceSha: buildInfo.sourceSha, + t3codeBuildVersion: buildInfo.version, + t3codeBuildArch: buildInfo.arch, private: true, packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", @@ -3668,6 +3680,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); + // The same provenance as a standalone readable file, so the app package.json + // fields are not the only place a verifier can read repository/SHA/version/arch. + yield* fs.writeFileString( + path.join(stageAppDir, BUILD_INFO_FILE_NAME), + `${yield* serializeBuildInfo(buildInfo)}\n`, + ); const stageWorkspaceConfig = createStageWorkspaceConfig({ platform: options.platform, arch: options.arch, diff --git a/scripts/fork-release-version.test.ts b/scripts/fork-release-version.test.ts new file mode 100644 index 000000000000..23c7d31daa9c --- /dev/null +++ b/scripts/fork-release-version.test.ts @@ -0,0 +1,100 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + compareStableVersions, + nextForkReleaseVersion, + parseStableVersion, + validateForkReleaseVersion, +} from "./fork-release-version.ts"; + +const ordering = (upstreamBase: string, existingForkVersions: readonly string[] = []) => ({ + upstreamBase, + existingForkVersions, +}); + +it.effect("computes the first fork release above today's 0.0.42 install", () => + Effect.gen(function* () { + assert.equal(yield* nextForkReleaseVersion(ordering("0.0.42")), "0.0.43"); + }), +); + +it.effect("orders two fork releases on the same upstream version", () => + Effect.gen(function* () { + const first = yield* nextForkReleaseVersion(ordering("0.0.42")); + const second = yield* nextForkReleaseVersion(ordering("0.0.42", [first])); + assert.equal(first, "0.0.43"); + assert.equal(second, "0.0.44"); + }), +); + +it.effect("jumps above a later upstream base instead of colliding with it", () => + Effect.gen(function* () { + // Fork is at 0.0.44; upstream then ships 0.0.45. The next fork release + // must clear both, so it never downgrades a fork install back onto a + // version number an upstream build also uses. + assert.equal(yield* nextForkReleaseVersion(ordering("0.0.45", ["0.0.43", "0.0.44"])), "0.0.46"); + }), +); + +it.effect("ignores malformed entries in the existing fork release list", () => + Effect.gen(function* () { + assert.equal( + yield* nextForkReleaseVersion( + ordering("0.0.42", ["", "0.0.43-preview.20260923.1", "0.0.44"]), + ), + "0.0.45", + ); + }), +); + +it.effect("reports a non-stable upstream base instead of guessing", () => + Effect.gen(function* () { + const error = yield* nextForkReleaseVersion(ordering("0.0.42-nightly.20260923.1")).pipe( + Effect.flip, + ); + assert.equal(error._tag, "InvalidUpstreamBaseVersionError"); + }), +); + +it("accepts a plain version newer than the base and every existing release", () => { + assert.deepStrictEqual( + validateForkReleaseVersion("0.0.45", ordering("0.0.42", ["0.0.43", "0.0.44"])), + { + ok: true, + }, + ); +}); + +it("rejects preview, nightly, and build-metadata versions", () => { + for (const version of [ + "0.0.43-preview.20260923.1", + "0.0.43-nightly.20260923.1", + "0.0.43+fork.1", + ]) { + const verdict = validateForkReleaseVersion(version, ordering("0.0.42")); + assert.equal(verdict.ok, false); + } +}); + +it("rejects a downgrade against an existing fork release or the upstream base", () => { + const existing = validateForkReleaseVersion("0.0.44", ordering("0.0.42", ["0.0.44"])); + assert.equal(existing.ok, false); + if (!existing.ok) { + assert.include(existing.reason, "existing fork release 0.0.44"); + } + + const atBase = validateForkReleaseVersion("0.0.42", ordering("0.0.42")); + assert.equal(atBase.ok, false); + if (!atBase.ok) { + assert.include(atBase.reason, "upstream base 0.0.42"); + } +}); + +it("parses and compares stable versions", () => { + assert.deepStrictEqual(parseStableVersion("1.2.3"), { major: 1, minor: 2, patch: 3 }); + assert.equal(parseStableVersion("1.2.3-rc.1"), undefined); + assert.isBelow(compareStableVersions("0.0.42", "0.0.43"), 0); + assert.isAbove(compareStableVersions("0.1.0", "0.0.99"), 0); + assert.equal(compareStableVersions("1.0.0", "1.0.0"), 0); +}); diff --git a/scripts/fork-release-version.ts b/scripts/fork-release-version.ts new file mode 100644 index 000000000000..a6a63f22a47c --- /dev/null +++ b/scripts/fork-release-version.ts @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * Fork release versioning. + * + * The fork publishes its own GitHub Releases, so its versions must sort + * strictly above everything already published on the fork and above the + * upstream base the build came from. A plain `X.Y.Z` line does that: SemVer + * build metadata is ignored when ordering, and a prerelease identifier such as + * `-preview` sorts *below* the matching release, so neither can be the update + * mechanism. Fork releases are therefore plain `X.Y.Z`, and each one is + * `bumpPatch(max(upstreamBase, every existing fork release))`. + * + * upstream base 0.0.42, no fork release yet -> 0.0.43 + * upstream base 0.0.42, fork 0.0.43 exists -> 0.0.44 + * upstream base 0.0.45, fork 0.0.44 exists -> 0.0.46 + * + * Preview and nightly identifiers are rejected outright: a fork preview build + * is a manual download and must never be discoverable as an update. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Console from "effect/Console"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; + +const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +export interface ForkVersionOrdering { + readonly upstreamBase: string; + readonly existingForkVersions: readonly string[]; +} + +export class InvalidUpstreamBaseVersionError extends Schema.TaggedError()( + "InvalidUpstreamBaseVersionError", + { version: Schema.String }, +) { + override get message(): string { + return `Upstream base version '${this.version}' is not a plain X.Y.Z version.`; + } +} + +export class InvalidForkReleaseVersionError extends Schema.TaggedError()( + "InvalidForkReleaseVersionError", + { version: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Fork release version '${this.version}' is not acceptable: ${this.reason}.`; + } +} + +export interface ParsedStableVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +export function parseStableVersion(version: string): ParsedStableVersion | undefined { + const match = STABLE_VERSION_PATTERN.exec(version.trim()); + if (match === null) return undefined; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +export function formatStableVersion(version: ParsedStableVersion): string { + return `${version.major}.${version.minor}.${version.patch}`; +} + +/** Ordering for plain X.Y.Z versions; a non-version sorts lowest. */ +export function compareStableVersions(left: string, right: string): number { + const a = parseStableVersion(left); + const b = parseStableVersion(right); + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +const bumpPatch = (version: ParsedStableVersion): ParsedStableVersion => ({ + major: version.major, + minor: version.minor, + patch: version.patch + 1, +}); + +const validExisting = (versions: readonly string[]): string[] => + versions + .map((version) => version.trim()) + .filter((version) => parseStableVersion(version) !== undefined); + +/** + * The next fork release version: one patch above the highest of the upstream + * base and every existing fork release. Returns a failure for a base that is + * not plain `X.Y.Z`. + */ +export const nextForkReleaseVersion = (input: ForkVersionOrdering) => + Effect.gen(function* () { + const base = parseStableVersion(input.upstreamBase); + if (base === undefined) { + return yield* new InvalidUpstreamBaseVersionError({ version: input.upstreamBase }); + } + const highest = validExisting(input.existingForkVersions).reduce( + (winner, candidate) => (compareStableVersions(candidate, winner) > 0 ? candidate : winner), + formatStableVersion(base), + ); + const highestParsed = parseStableVersion(highest); + if (highestParsed === undefined) { + return yield* new InvalidUpstreamBaseVersionError({ version: input.upstreamBase }); + } + return formatStableVersion(bumpPatch(highestParsed)); + }); + +/** + * Rejects a version the fork must not publish: a prerelease/build identifier + * (preview or nightly), one at or below an existing fork release, or one at or + * below the upstream base it was built from. + */ +export function validateForkReleaseVersion( + version: string, + input: ForkVersionOrdering, +): { readonly ok: true } | { readonly ok: false; readonly reason: string } { + if (parseStableVersion(version) === undefined) { + return { + ok: false, + reason: "must be a plain X.Y.Z version with no prerelease or build identifier", + }; + } + if (parseStableVersion(input.upstreamBase) === undefined) { + return { + ok: false, + reason: `upstream base '${input.upstreamBase}' is not a plain X.Y.Z version`, + }; + } + if (compareStableVersions(version, input.upstreamBase) <= 0) { + return { ok: false, reason: `must be newer than the upstream base ${input.upstreamBase}` }; + } + const conflicting = validExisting(input.existingForkVersions).find( + (existing) => compareStableVersions(version, existing) <= 0, + ); + if (conflicting !== undefined) { + return { ok: false, reason: `must be newer than the existing fork release ${conflicting}` }; + } + return { ok: true }; +} + +const parseExisting = (value: string | undefined): string[] => + (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + +const command = Command.make( + "fork-release-version", + { + upstreamBase: Flag.String("upstream-base").pipe( + Flag.withDescription("Upstream stable version the fork build is based on, e.g. 0.0.42."), + ), + existing: Flag.String("existing").pipe( + Flag.withDescription("Comma-separated versions already published on the fork."), + Flag.optional, + ), + version: Flag.String("version").pipe( + Flag.withDescription("Version to validate. Omit to compute the next one."), + Flag.optional, + ), + githubOutput: Flag.Boolean("github-output").pipe( + Flag.withDescription("Append version= to GITHUB_OUTPUT instead of stdout."), + Flag.withDefault(false), + ), + }, + ({ upstreamBase, existing, version, githubOutput }) => + Effect.gen(function* () { + const ordering: ForkVersionOrdering = { + upstreamBase, + existingForkVersions: parseExisting(Option.getOrUndefined(existing)), + }; + const requested = Option.getOrUndefined(version)?.trim(); + let resolved: string; + if (requested === undefined || requested === "") { + resolved = yield* nextForkReleaseVersion(ordering); + } else { + const verdict = validateForkReleaseVersion(requested, ordering); + if (!verdict.ok) { + return yield* new InvalidForkReleaseVersionError({ + version: requested, + reason: verdict.reason, + }); + } + resolved = requested; + } + + if (githubOutput) { + const outputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT"); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(outputPath, `version=${resolved}\n`, { flag: "a" }); + } else { + yield* Console.log(`version=${resolved}`); + } + }), +).pipe(Command.withDescription("Compute or validate a fork release version.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d0d7140d2f4f..c429d0f90dd5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -10,13 +10,15 @@ # T3CODE_HOME T3 home directory (default: ~\.t3) # T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# T3CODE_RELEASE_REPOSITORY owner/repo to discover and download from +# (default: this fork, nullStack65/t3code) # # The archive is unpacked into $T3CODE_HOME\runtime\versions\, the # same layout `t3 service install` uses, so the service reuses this download. $ErrorActionPreference = "Stop" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$repo = "pingdotgg/t3code" +$repo = if ($env:T3CODE_RELEASE_REPOSITORY) { $env:T3CODE_RELEASE_REPOSITORY } else { "nullStack65/t3code" } $baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "/$repo/releases/download" } $t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } $binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } diff --git a/scripts/install.sh b/scripts/install.sh index 421d8d021aef..6037c1512fea 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,13 +11,15 @@ # T3CODE_HOME T3 home directory (default: ~/.t3) # T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# T3CODE_RELEASE_REPOSITORY owner/repo to discover and download from +# (default: this fork, nullStack65/t3code) # # The archive is unpacked into $T3CODE_HOME/runtime/versions/, the # same layout `t3 service install` uses, so the service reuses this download # instead of fetching the release again. set -eu -repo="pingdotgg/t3code" +repo="${T3CODE_RELEASE_REPOSITORY:-nullStack65/t3code}" base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" t3_home="${T3CODE_HOME:-$HOME/.t3}" bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" diff --git a/scripts/lib/source-provenance.test.ts b/scripts/lib/source-provenance.test.ts new file mode 100644 index 000000000000..e4fbf35acbbc --- /dev/null +++ b/scripts/lib/source-provenance.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + BUILD_INFO_FILE_NAME, + createBuildInfo, + parseBuildInfo, + parseSourceRepository, + readGitSourceProvenance, + resolveBuildChannel, + resolveSourceRepository, + resolveSourceSha, + serializeBuildInfo, +} from "./source-provenance.ts"; + +const FULL_SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; + +it("names the readable provenance file", () => { + assert.equal(BUILD_INFO_FILE_NAME, "t3code-build-info.json"); +}); + +it("normalizes repository remotes to owner/repo", () => { + assert.equal(parseSourceRepository("nullStack65/t3code"), "nullStack65/t3code"); + assert.equal( + parseSourceRepository("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/nullStack65/t3code"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("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/nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("git@github.com:nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("ssh://git@github.com/nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal(parseSourceRepository("not-a-repo"), undefined); + assert.equal(parseSourceRepository(undefined), undefined); +}); + +it("prefers an explicit repository and SHA over GitHub Actions and the fork default", () => { + assert.equal( + resolveSourceRepository({ + T3CODE_SOURCE_REPOSITORY: "explicit/fork", + GITHUB_REPOSITORY: "actions/repo", + }), + "explicit/fork", + ); + assert.equal(resolveSourceRepository({ GITHUB_REPOSITORY: "actions/repo" }), "actions/repo"); + // A local fork worktree usually names upstream `origin`; the default must + // still be the fork so a local build is never labelled as an upstream build. + assert.equal(resolveSourceRepository({}), "nullStack65/t3code"); + + assert.equal( + resolveSourceSha({ T3CODE_SOURCE_SHA: FULL_SHA, GITHUB_SHA: "a".repeat(40) }), + FULL_SHA, + ); + assert.equal(resolveSourceSha({ GITHUB_SHA: FULL_SHA }), FULL_SHA); + assert.equal(resolveSourceSha({ GITHUB_SHA: "not-a-sha" }), "unknown"); + assert.equal(resolveSourceSha({}, undefined), "unknown"); +}); + +it("labels fork releases by channel and rejects non-stable labels for a plain version", () => { + assert.equal(resolveBuildChannel("0.0.43"), "stable"); + assert.equal(resolveBuildChannel("0.0.43-nightly.20260923.1"), "nightly"); + assert.equal(resolveBuildChannel("0.0.43-preview.20260923.1"), "preview"); +}); + +it.effect("serializes provenance that round-trips and carries every required field", () => + Effect.gen(function* () { + const info = createBuildInfo({ + version: "0.0.43", + platform: "win", + arch: "x64", + repository: "nullStack65/t3code", + sourceSha: FULL_SHA, + }); + const parsed = parseBuildInfo(yield* serializeBuildInfo(info)); + assert.deepStrictEqual(parsed, { + schemaVersion: 1, + repository: "nullStack65/t3code", + sourceSha: FULL_SHA, + version: "0.0.43", + platform: "win", + arch: "x64", + channel: "stable", + }); + }), +); + +it.effect("falls back to unknown rather than omitting fields", () => + Effect.gen(function* () { + const info = createBuildInfo({ version: "0.0.43", platform: "mac", arch: "x64" }); + assert.equal(info.repository, "unknown"); + assert.equal(info.sourceSha, "unknown"); + const parsed = parseBuildInfo(yield* serializeBuildInfo(info)); + assert.equal(parsed.repository, "unknown"); + assert.equal(parsed.sourceSha, "unknown"); + }), +); + +it.effect("reads a full HEAD sha from the git checkout", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoRoot = yield* path.fromFileUrl(new URL("../../", import.meta.url)); + const git = yield* readGitSourceProvenance(repoRoot); + assert.match(git.sourceSha, /^[0-9a-f]{40}$/); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/scripts/lib/source-provenance.ts b/scripts/lib/source-provenance.ts new file mode 100644 index 000000000000..e2f88b564697 --- /dev/null +++ b/scripts/lib/source-provenance.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Build provenance embedded into packaged output. + * + * A fork build has to be distinguishable from an upstream build from the bytes + * alone: a release is only trustworthy if the desktop app, the bundled server, + * and the WSL runtime archive all name the same repository, full source SHA, + * version, and architecture. This module resolves those values once and both + * packaging scripts write them the same way. + * + * Resolution order for the repository and SHA is: an explicit + * `T3CODE_SOURCE_REPOSITORY`/`T3CODE_SOURCE_SHA`, then the GitHub Actions + * variables, then the local git checkout, then `unknown`. The full 40-character + * SHA is preferred; a short SHA is accepted from git only as a fallback. + */ +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { CLI_RELEASE_REPOSITORY } from "@t3tools/shared/cliRelease"; + +export const BUILD_INFO_FILE_NAME = "t3code-build-info.json"; +export const SOURCE_REPOSITORY_ENV = "T3CODE_SOURCE_REPOSITORY"; +export const SOURCE_SHA_ENV = "T3CODE_SOURCE_SHA"; +export const UNKNOWN_PROVENANCE = "unknown"; + +const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i; +const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i; +const REPOSITORY_PATTERN = /^[^/\s]+\/[^/\s]+$/; + +export interface BuildInfo { + readonly schemaVersion: 1; + readonly repository: string; + readonly sourceSha: string; + readonly version: string; + readonly platform: string; + readonly arch: string; + readonly channel: string; +} + +export interface BuildInfoInput { + readonly version: string; + readonly platform: string; + readonly arch: string; + readonly repository?: string | undefined; + readonly sourceSha?: string | undefined; +} + +/** The release train a fork version belongs to. Fork releases are plain stable. */ +export function resolveBuildChannel(version: string): string { + const match = /-([a-z]+)\.\d{8}\.\d+$/.exec(version.trim()); + return match?.[1] ?? "stable"; +} + +/** Accepts `owner/repo`, a GitHub URL, or an ssh remote and returns `owner/repo`. */ +export function parseSourceRepository(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (trimmed === undefined || trimmed === "") return undefined; + const sshMatch = /^git@[^:]+:(.+?)(?:\.git)?$/.exec(trimmed); + const candidate = sshMatch?.[1] ?? trimmed; + const withoutUrl = candidate + .replace(/^https?:\/\/[^/]+\//, "") + .replace(/^ssh:\/\/[^/]+\//, "") + .replace(/\.git$/, ""); + return REPOSITORY_PATTERN.test(withoutUrl) ? withoutUrl : undefined; +} + +/** + * The repository a build is attributed to. An explicit override or the GitHub + * Actions variable wins; otherwise this is the fork's own release repository. + * A local git remote is deliberately not consulted: a fork worktree usually + * names the upstream repo `origin`, and a fork build must never be labelled as + * an upstream build. + */ +export function resolveSourceRepository(env: Readonly>): string { + return ( + parseSourceRepository(env[SOURCE_REPOSITORY_ENV]) ?? + parseSourceRepository(env.GITHUB_REPOSITORY) ?? + CLI_RELEASE_REPOSITORY + ); +} + +export function resolveSourceSha( + env: Readonly>, + gitSha?: string | undefined, +): string { + for (const candidate of [env[SOURCE_SHA_ENV], env.GITHUB_SHA, gitSha]) { + const trimmed = candidate?.trim(); + if (trimmed !== undefined && SHORT_SHA_PATTERN.test(trimmed)) { + return trimmed.toLowerCase(); + } + } + return UNKNOWN_PROVENANCE; +} + +export function createBuildInfo(input: BuildInfoInput): BuildInfo { + return { + schemaVersion: 1, + repository: input.repository ?? UNKNOWN_PROVENANCE, + sourceSha: input.sourceSha ?? UNKNOWN_PROVENANCE, + version: input.version, + platform: input.platform, + arch: input.arch, + channel: resolveBuildChannel(input.version), + }; +} + +const BuildInfoSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + repository: Schema.String, + sourceSha: Schema.String, + version: Schema.String, + platform: Schema.String, + arch: Schema.String, + channel: Schema.String, +}); +const encodeBuildInfo = Schema.encodeEffect(Schema.fromJsonString(BuildInfoSchema)); + +/** The exact JSON text written into packaged output. */ +export const serializeBuildInfo = (info: BuildInfo) => encodeBuildInfo(info); + +/** Decodes the packaged `t3code-build-info.json` text back into typed provenance. */ +export const parseBuildInfo = Schema.decodeUnknownSync(Schema.fromJsonString(BuildInfoSchema)); + +const collectStream = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runGit = Effect.fn("runGit")(function* (repoRoot: string, args: readonly string[]) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("git", [...args], { cwd: repoRoot, stdin: "ignore" }), + ); + const [stdout, exitCode] = yield* Effect.all( + [collectStream(child.stdout), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe(Effect.orElseSucceed((): readonly [string, number] => ["", 1])); + return exitCode === 0 ? stdout.trim() : ""; +}); + +export interface GitSourceProvenance { + readonly sourceSha: string; +} + +/** + * Reads the full HEAD SHA from a git checkout. Best effort: a shallow CI + * checkout still answers `rev-parse HEAD`, and any failure yields an empty + * string so the caller falls back to environment variables. + */ +export const readGitSourceProvenance = Effect.fn("readGitSourceProvenance")(function* ( + repoRoot: string, +) { + const sourceSha = yield* runGit(repoRoot, ["rev-parse", "HEAD"]); + return { + sourceSha: + FULL_SHA_PATTERN.test(sourceSha) || SHORT_SHA_PATTERN.test(sourceSha) ? sourceSha : "", + } satisfies GitSourceProvenance; +}); From 89369420870a3086051fb01462193c805ecc2aaa Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Wed, 23 Sep 2026 06:28:51 -0400 Subject: [PATCH 02/10] docs(release): document blocking WinGet pin for retained package identity --- docs/operations/fork-release.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/operations/fork-release.md b/docs/operations/fork-release.md index 8460e1e9b084..a4ff912d5a12 100644 --- a/docs/operations/fork-release.md +++ b/docs/operations/fork-release.md @@ -137,6 +137,18 @@ Recovery note: an older binary is not automatically a safe database rollback. If a release adds a database migration, restore a pre-upgrade snapshot rather than only reinstalling the older binary. +### Windows package-manager identity + +The fork installer keeps the upstream `appId` (`com.t3tools.t3code`) and the +Winget ARP entry `T3Tools.T3Code`, so an existing Winget install is upgraded in +place and its user data is preserved. A normal Winget pin (`winget pin add --id +T3Tools.T3Code`, pin type `Pinning`) does **not** block an explicit +`winget upgrade T3Tools.T3Code`, and `--include-pinned` bypasses it entirely. To +retain the upstream package identity while preventing package-manager +replacement, use a blocking pin (`winget pin add --blocking --id +T3Tools.T3Code`) or remove the Winget package. Document that removing the pin is +deliberate, not accidental. + ## Prerequisites For users (not build tooling): From df8aceb5f8441305d61f8de17c6a0763b37b096c Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 17:06:06 -0400 Subject: [PATCH 03/10] fix(release): bind source/provenance, freeze-and-promote candidates, gate runners --- .github/workflows/fork-release.yml | 429 ++++++++++++++++------ .github/workflows/release-desktop.yml | 16 +- docs/operations/fork-release.md | 146 +++++--- docs/user/fork-install.md | 10 +- scripts/build-cli-archive.ts | 6 +- scripts/build-desktop-artifact.ts | 14 +- scripts/build-fork-candidate.ts | 169 +++++++++ scripts/install.ps1 | 12 +- scripts/install.sh | 9 +- scripts/lib/candidate-build-plan.test.ts | 82 +++++ scripts/lib/candidate-build-plan.ts | 184 ++++++++++ scripts/lib/fork-release-manifest.test.ts | 251 +++++++++++++ scripts/lib/fork-release-manifest.ts | 278 ++++++++++++++ scripts/lib/fork-release-workflow.test.ts | 107 ++++++ scripts/lib/release-source.test.ts | 193 ++++++++++ scripts/lib/release-source.ts | 181 +++++++++ scripts/lib/source-provenance.test.ts | 98 +++++ scripts/lib/source-provenance.ts | 120 +++++- scripts/lib/wsl-payload.test.ts | 105 ++++++ scripts/lib/wsl-payload.ts | 98 +++++ scripts/select-release-source.ts | 84 +++++ scripts/verify-fork-candidate.ts | 273 ++++++++++++++ scripts/verify-windows-installer.ts | 114 ++++++ 23 files changed, 2804 insertions(+), 175 deletions(-) create mode 100644 scripts/build-fork-candidate.ts create mode 100644 scripts/lib/candidate-build-plan.test.ts create mode 100644 scripts/lib/candidate-build-plan.ts create mode 100644 scripts/lib/fork-release-manifest.test.ts create mode 100644 scripts/lib/fork-release-manifest.ts create mode 100644 scripts/lib/fork-release-workflow.test.ts create mode 100644 scripts/lib/release-source.test.ts create mode 100644 scripts/lib/release-source.ts create mode 100644 scripts/lib/wsl-payload.test.ts create mode 100644 scripts/lib/wsl-payload.ts create mode 100644 scripts/select-release-source.ts create mode 100644 scripts/verify-fork-candidate.ts create mode 100644 scripts/verify-windows-installer.ts diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 1cc90ea8f62c..1b237e4f2b26 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -1,24 +1,25 @@ name: Fork release -# Fork-only release entry point. It builds one immutable SHA into the fork's -# required artifacts and, only when explicitly told to, publishes them as a -# GitHub Release on `nullStack65/t3code`. It deliberately does not reuse -# upstream's `release.yml`: that workflow requires Blacksmith runners, the -# production relay/Clerk/Cloudflare/Vercel/npm/AUR/Discord credentials, and -# publishes upstream npm packages. This one reuses the existing packaging -# scripts and `release-desktop.yml` unchanged. +# Fork-only release entry point. It builds one immutable source SHA into the +# fork's required artifacts and, only when explicitly told to, promotes an +# already-qualified candidate to a GitHub Release on `nullStack65/t3code`. # -# Targets built here: +# Runner capacity is an explicit, authorized input, never a silent hosted +# default: every runner label must be declared in the repository variable +# `T3CODE_AUTHORIZED_RUNNERS` or preflight fails closed. When no authorized +# runner exists, assemble and verify the same candidate on an authorized +# Windows/WSL or Intel macOS machine with `scripts/build-fork-candidate.ts`. +# +# Required initial targets: # - Windows x64 NSIS installer, with the matching Linux x64 CLI archive -# embedded as its WSL runtime (the mechanism `release-desktop.yml` already -# implements). +# embedded as its WSL runtime. # - Intel macOS x64 DMG. -# - Linux x64 self-contained CLI/runtime archive. -# - Apple Silicon macOS is available behind `include_macos_arm64` and is not -# exercised by default. +# - Linux x64 self-contained runtime archive. +# - Windows x64 self-contained CLI archive (the Windows install/update path). +# - Apple Silicon macOS only behind `include_macos_arm64`, reported untested. # -# Runners are GitHub-hosted standard labels only: free for this public repo, -# not paid `-large`/`-xlarge`, not guessed, and no self-hosted registration. +# Publication promotes the frozen candidate artifact by id; it never rebuilds +# and never overwrites an existing release or tag. on: workflow_dispatch: inputs: @@ -34,21 +35,38 @@ on: description: "Upstream version this fork build is based on (e.g. 0.0.42)." required: true type: string + linux_runner: + description: "Authorized runner label for Linux jobs (must be in T3CODE_AUTHORIZED_RUNNERS)." + required: true + type: string + windows_runner: + description: "Authorized runner label for the Windows job (must be in T3CODE_AUTHORIZED_RUNNERS)." + required: true + type: string + macos_x64_runner: + description: "Authorized runner label for the Intel macOS job (must be in T3CODE_AUTHORIZED_RUNNERS)." + required: true + type: string + macos_arm64_runner: + description: "Authorized runner label for the optional Apple Silicon job." + required: false + default: "" + type: string publish: - description: "Publish the built artifacts as a GitHub Release. Off builds a candidate only." + description: "Promote a qualified candidate. Off builds/verifies a candidate only." required: false default: false type: boolean + candidate_run_id: + description: "Run id of the qualifying candidate to promote. Required when publish is true." + required: false + default: "" + type: string include_macos_arm64: description: "Also build the Apple Silicon macOS DMG (untested by default)." required: false default: false type: boolean - macos_x64_runner: - description: "Runner label for the Intel macOS job." - required: false - default: macos-15-intel - type: string permissions: contents: read @@ -60,18 +78,23 @@ concurrency: jobs: preflight: name: Preflight - runs-on: ubuntu-24.04 - timeout-minutes: 10 + runs-on: ${{ inputs.linux_runner }} + timeout-minutes: 15 outputs: version: ${{ steps.meta.outputs.version }} - sha: ${{ steps.meta.outputs.sha }} + sha: ${{ steps.source.outputs.sha }} + head_sha: ${{ steps.source.outputs.head_sha }} + workflow_sha: ${{ steps.source.outputs.workflow_sha }} channel: ${{ steps.meta.outputs.channel }} relay_url: ${{ steps.public_config.outputs.relay_url }} clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} steps: - - name: Checkout the release commit + # Bootstrap only enough of the selected commit to read package.json and + # the selection script. The authoritative checkout happens below, after + # dependencies are installed. + - name: Bootstrap the release commit shell: bash env: CHECKOUT_REF: ${{ inputs.sha }} @@ -79,11 +102,10 @@ jobs: run: | set -euo pipefail git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git fetch --no-tags origin main - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD + git checkout --detach "$CHECKOUT_REF" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -94,18 +116,52 @@ jobs: args: - --filter=@t3tools/scripts... - # A release must build a commit that is actually on fork main, not an - # arbitrary branch tip, so the tag and the artifacts cannot disagree. - - name: Assert the source SHA is on fork main + # Fetch the requested SHA and main, check out the explicit SHA (never + # FETCH_HEAD), assert HEAD equals it, and assert ancestry separately. + - id: source + name: Select and verify the exact source SHA + shell: bash + run: | + set -euo pipefail + node scripts/select-release-source.ts \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + --sha "${{ inputs.sha }}" \ + --main-ref main \ + --github-output + + # Fail closed unless the operator has declared this fork's authorized + # runner capacity. No hosted/paid fallback is added silently. + - name: Assert authorized runner capacity shell: bash env: - RELEASE_SHA: ${{ inputs.sha }} + AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} + LINUX_RUNNER: ${{ inputs.linux_runner }} + WINDOWS_RUNNER: ${{ inputs.windows_runner }} + MACOS_X64_RUNNER: ${{ inputs.macos_x64_runner }} + MACOS_ARM64_RUNNER: ${{ inputs.macos_arm64_runner }} + INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} run: | set -euo pipefail - if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then - echo "::error::$RELEASE_SHA is not an ancestor of origin/main." + if [ -z "${AUTHORIZED:-}" ]; then + echo "::error::No authorized runner capacity is declared. Set the repository variable T3CODE_AUTHORIZED_RUNNERS to the comma-separated labels this fork may use, or build a candidate with scripts/build-fork-candidate.ts on an authorized machine." exit 1 fi + IFS=',' read -ra allowed <<< "$AUTHORIZED" + check() { + local label="$1" role="$2" candidate + [ -n "$label" ] || { echo "::error::$role runner label is empty"; exit 1; } + for candidate in "${allowed[@]}"; do + candidate="$(echo "$candidate" | xargs)" + [ "$candidate" = "$label" ] && return 0 + done + echo "::error::$role runner '$label' is not declared in T3CODE_AUTHORIZED_RUNNERS" + exit 1 + } + check "$LINUX_RUNNER" Linux + check "$WINDOWS_RUNNER" Windows + check "$MACOS_X64_RUNNER" "Intel macOS" + if [ "$INCLUDE_ARM64" = "true" ]; then check "$MACOS_ARM64_RUNNER" "Apple Silicon macOS"; fi + echo "Authorized runner capacity confirmed." # Validate the version against the fork's own version line: plain X.Y.Z, # newer than the upstream base, and newer than every published fork @@ -128,7 +184,6 @@ jobs: --existing "$existing" \ --version "$RELEASE_VERSION" \ --github-output - echo "sha=${{ inputs.sha }}" >> "$GITHUB_OUTPUT" channel="$(node -e 'const v=process.env.RELEASE_VERSION;const m=/-([a-z]+)\.\d{8}\.\d+$/.exec(v);process.stdout.write(m?m[1]:"stable")')" echo "channel=$channel" >> "$GITHUB_OUTPUT" @@ -155,15 +210,17 @@ jobs: bundle: name: Build JS bundle needs: [preflight] - runs-on: ubuntu-24.04 + runs-on: ${{ inputs.linux_runner }} timeout-minutes: 30 env: + T3CODE_RELEASE_BUILD: "1" + T3CODE_SOURCE_SHA: ${{ needs.preflight.outputs.sha }} T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.preflight.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.preflight.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.preflight.outputs.relay_url }} steps: - - name: Checkout + - name: Bootstrap the release commit shell: bash env: CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} @@ -171,10 +228,10 @@ jobs: run: | set -euo pipefail git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD + git checkout --detach "$CHECKOUT_REF" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -183,6 +240,15 @@ jobs: cache: true run-install: false + - name: Select and verify the exact source SHA + shell: bash + run: | + set -euo pipefail + node scripts/select-release-source.ts \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + --sha "${{ needs.preflight.outputs.sha }}" \ + --main-ref main + - name: Install bundle dependencies run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... @@ -210,12 +276,13 @@ jobs: cli_linux_x64: name: Linux x64 runtime archive needs: [preflight, bundle] - runs-on: ubuntu-24.04 + runs-on: ${{ inputs.linux_runner }} timeout-minutes: 30 env: - VP_NODE_VERSION: "26.8.2" + T3CODE_RELEASE_BUILD: "1" + T3CODE_SOURCE_SHA: ${{ needs.preflight.outputs.sha }} steps: - - name: Checkout + - name: Bootstrap the release commit shell: bash env: CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} @@ -223,10 +290,10 @@ jobs: run: | set -euo pipefail git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD + git checkout --detach "$CHECKOUT_REF" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -235,6 +302,15 @@ jobs: cache: true run-install: false + - name: Select and verify the exact source SHA + shell: bash + run: | + set -euo pipefail + node scripts/select-release-source.ts \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + --sha "${{ needs.preflight.outputs.sha }}" \ + --main-ref main + - name: Install CLI dependencies run: vp install --filter=t3... --filter=@t3tools/scripts... @@ -265,6 +341,24 @@ jobs: mkdir -p "$target_dir" cp native/resource-monitor/target/release/t3-resource-monitor "$target_dir/t3-resource-monitor" + # The single-executable embeds the Node pinned in apps/server/vite.config.ts + # (SEA_NODE_VERSION), so the host only has to support `--build-sea` (25.7+). + # Assert and log the effective host Node instead of trusting an ambient var. + - name: Assert SEA build toolchain + shell: bash + run: | + set -euo pipefail + node --version + vp --version + node -e ' + const [major, minor] = process.versions.node.split(".").map(Number); + if (major < 25 || (major === 25 && minor < 7)) { + console.error(`::error::the SEA build needs a host Node >= 25.7, got ${process.versions.node}`); + process.exit(1); + } + console.log(`SEA host Node OK: ${process.versions.node} (embedded Node is pinned by SEA_NODE_VERSION)`); + ' + - name: Build CLI single-executable run: node apps/server/scripts/cli.ts build-exe --verbose @@ -331,13 +425,16 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.preflight.outputs.relay_url }} label: Windows x64 - runner: windows-2025 + runner: ${{ inputs.windows_runner }} platform: win target: nsis arch: x64 rust_target: x86_64-pc-windows-msvc resource_key: win32-x64 - cli_archive: false + # The Windows installer embeds the Linux WSL runtime; the Windows CLI + # archive is built here too so the Windows install/update path has a real + # asset instead of a predictable 404. + cli_archive: true relay_client_tracing: false desktop_mac_x64: @@ -378,7 +475,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.preflight.outputs.relay_url }} label: macOS arm64 - runner: macos-26 + runner: ${{ inputs.macos_arm64_runner }} platform: mac target: dmg arch: arm64 @@ -389,12 +486,12 @@ jobs: qualify: name: Qualify candidate - needs: [preflight, desktop_win_x64, desktop_mac_x64, cli_linux_x64] + needs: [preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 + runs-on: ${{ inputs.linux_runner }} + timeout-minutes: 20 steps: - - name: Checkout + - name: Bootstrap the release commit shell: bash env: CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} @@ -402,10 +499,10 @@ jobs: run: | set -euo pipefail git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD + git checkout --detach "$CHECKOUT_REF" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -414,6 +511,21 @@ jobs: cache: false run-install: false + - name: Select and verify the exact source SHA + shell: bash + run: | + set -euo pipefail + node scripts/select-release-source.ts \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + --sha "${{ needs.preflight.outputs.sha }}" \ + --main-ref main + + - name: Install verification dependencies + run: vp install --filter=@t3tools/scripts... + + - name: Install archive extraction tools + run: sudo apt-get update && sudo apt-get install -y p7zip-full + - name: Download desktop artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -428,34 +540,23 @@ jobs: merge-multiple: true path: candidate - - name: Normalize updater manifests and drop unsupported ones + # No updater manifest is attached for the first release: Windows + # automatic update is not advertised until an N -> N+1 acceptance test + # passes, and unsigned macOS cannot apply a Squirrel.Mac update at all. + # Installs update by downloading the new artifact. + - name: Drop updater manifests and builder metadata shell: bash - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} run: | set -euo pipefail cd candidate shopt -s nullglob - # Windows x64 is the only platform with a supported in-app update - # (the installer verifies the downloaded hash). Its per-arch manifest - # is renamed to the channel manifest electron-updater reads. - for manifest in latest-win-x64.yml nightly-win-x64.yml; do - [[ -f "$manifest" ]] && mv "$manifest" "${manifest%-win-x64.yml}.yml" - done - # macOS ships unsigned here, so it must carry no update feed and no - # updater metadata; remove any that leaked in. - rm -f *-mac-x64.yml *-mac-arm64.yml latest-mac.yml nightly-mac.yml - # electron-builder's config dump is not a release asset. - rm -f builder-debug.yml - # A preview build must never carry updater metadata at all. - if [[ "${{ needs.preflight.outputs.channel }}" == "preview" ]]; then - rm -f *.yml *.blockmap - fi + rm -f *.yml *.blockmap builder-debug.yml - name: Assert the required artifact set exists shell: bash env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} run: | set -euo pipefail cd candidate @@ -465,20 +566,15 @@ jobs: check "T3-Code-${RELEASE_VERSION}-x64.exe" check "T3-Code-${RELEASE_VERSION}-x64.dmg" check "t3-${RELEASE_VERSION}-linux-x64.tar.gz" - check "latest.yml" - if [[ "${{ inputs.include_macos_arm64 }}" == "true" ]]; then + check "t3-${RELEASE_VERSION}-win32-x64.zip" + if [[ "$INCLUDE_ARM64" == "true" ]]; then check "T3-Code-${RELEASE_VERSION}-arm64.dmg" fi (( fail == 0 )) || exit 1 echo "Required artifact set present." ls -la - # The Windows installer's WSL runtime is embedded and fail-closed inside - # `build-desktop-artifact.ts` (archive + SHA-256 sidecar present, digest - # matches, unpacks to one t3--linux- directory with t3, - # client/, node-pty's linux binary, and no loose server bundle). Re-check - # the standalone archive here so the bytes a Linux user downloads are the - # same release archive. + # The bytes a Linux user downloads must carry this source's provenance. - name: Verify the standalone Linux runtime archive shell: bash env: @@ -515,21 +611,45 @@ jobs: console.log("Linux runtime provenance verified:", info); ' "$tmp/$stem" - # Checksums come from the final distributed bytes, after manifest - # normalization, never from an earlier build stage. - - name: Write SHA-256 checksums + # The WSL runtime embedded in the Windows installer must be the exact + # standalone archive, with matching source/version/architecture. + - name: Verify the embedded WSL runtime shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} run: | set -euo pipefail - cd candidate - shopt -s nullglob - files=(*.exe *.dmg *.zip *.tar.gz *.yml *.blockmap) - if [[ ${#files[@]} -eq 0 ]]; then - echo "::error::no artifacts to checksum" - exit 1 - fi - sha256sum "${files[@]}" | tee SHA256SUMS - cat SHA256SUMS + node scripts/verify-windows-installer.ts \ + --installer "candidate/T3-Code-${RELEASE_VERSION}-x64.exe" \ + --standalone-archive "candidate/t3-${RELEASE_VERSION}-linux-x64.tar.gz" \ + --repository "nullStack65/t3code" \ + --sha "$RELEASE_SHA" \ + --version "$RELEASE_VERSION" \ + --arch x64 + + # Freeze the candidate: manifest + checksums from the exact distributed + # bytes. Native acceptance receipts are attached separately and required + # only at promotion. + - name: Freeze candidate manifest and checksums + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} + run: | + set -euo pipefail + args=( + --candidate-dir candidate + --version "$RELEASE_VERSION" + --sha "$RELEASE_SHA" + --repository "nullStack65/t3code" + --channel "${{ needs.preflight.outputs.channel }}" + --write-manifest + --write-checksums + ) + if [[ "$INCLUDE_ARM64" == "true" ]]; then args+=(--include-macos-arm64); fi + node scripts/verify-fork-candidate.ts "${args[@]}" - name: Upload candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -540,44 +660,133 @@ jobs: retention-days: 14 publish: - name: Publish GitHub Release + name: Promote qualified candidate needs: [preflight, qualify] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - # Publication is the only job with write access, and it runs in a named - # environment so it can require manual approval. + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish && inputs.candidate_run_id != '' }} + runs-on: ${{ inputs.linux_runner }} + timeout-minutes: 20 + # Publication is the only job with write access and it runs in a named + # environment so it can require manual approval. The job-level concurrency + # group serializes stable publication across runs, so an older concurrent + # build cannot overwrite the latest-release pointer. permissions: contents: write environment: name: fork-release + concurrency: + group: fork-release-publish + cancel-in-progress: false steps: - - name: Download candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - name: Bootstrap the release commit + shell: bash + env: + CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git checkout --detach "$CHECKOUT_REF" + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: - name: fork-release-candidate - path: candidate + node-version-file: package.json + cache: false + run-install: false + + - name: Install verification dependencies + run: vp install --filter=@t3tools/scripts... + + - name: Download the qualified candidate by run id + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} + run: | + set -euo pipefail + gh run download "$CANDIDATE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name fork-release-candidate \ + --dir candidate + + - name: Download native acceptance receipts + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} + run: | + set -euo pipefail + if ! gh run download "$CANDIDATE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name fork-release-native-receipts \ + --dir receipts; then + echo "::error::no fork-release-native-receipts artifact on run $CANDIDATE_RUN_ID; native acceptance must pass before publication" + exit 1 + fi - # The tag and the target are the exact release version and source SHA - # that were built and qualified above, so a published release can never - # point at a different commit than its artifacts. - - name: Create the GitHub Release + # The environment name is not evidence of an approval gate; assert the + # gate is actually configured. A token that cannot read the environment + # fails closed rather than assuming the gate exists. + - name: Assert the publication authorization gate exists + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + rules="$(gh api "repos/$GITHUB_REPOSITORY/environments/fork-release" \ + --jq '[.protection_rules[]?] | length' 2>/dev/null || echo "")" + if [ -z "$rules" ] || [ "$rules" = "0" ]; then + echo "::error::environment 'fork-release' is missing or has no protection rules; configure required reviewers before publishing" + exit 1 + fi + echo "Publication authorization gate present ($rules protection rules)." + + - name: Verify promotion and create the release shell: bash env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.preflight.outputs.version }} RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} run: | set -euo pipefail tag="v${RELEASE_VERSION}" + + release_exists=false + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then release_exists=true; fi + tag_exists=false + if git ls-remote --tags origin "refs/tags/$tag" | grep -q .; then tag_exists=true; fi + latest="$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName --jq '[.[].tagName | sub("^v"; "")] | join(",")')" + + args=( + --candidate-dir candidate + --version "$RELEASE_VERSION" + --sha "$RELEASE_SHA" + --repository "nullStack65/t3code" + --native-receipts receipts/fork-native-receipts.json + --promote + --tag-target "$RELEASE_SHA" + --latest-version "$latest" + --authorization-gate-exists true + ) + [[ "$release_exists" == "true" ]] && args+=(--release-exists) + [[ "$tag_exists" == "true" ]] && args+=(--tag-exists) + if [[ "$INCLUDE_ARM64" == "true" ]]; then args+=(--include-macos-arm64); fi + node scripts/verify-fork-candidate.ts "${args[@]}" + notes="$(mktemp)" { echo "Fork build of T3 Code \`${RELEASE_SHA}\` (upstream base \`${{ inputs.upstream_base }}\`)." echo - echo "Repository: \`nullStack65/t3code\`. Desktop in-app updates are published for Windows x64 only; macOS and Linux installs update by downloading the new artifact." + echo "Repository: \`nullStack65/t3code\`. Fork version \`${RELEASE_VERSION}\` is the fork's own increasing line, independent of the upstream base version." + echo + echo "Installs update by downloading the new artifact; automatic desktop update is not advertised for this release." echo echo "Assets are checksummed in \`SHA256SUMS\`." } > "$notes" + gh release create "$tag" \ --repo "$GITHUB_REPOSITORY" \ --target "$RELEASE_SHA" \ diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c374a8692db6..9793b2ac7c00 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -117,10 +117,16 @@ jobs: run: | set -euo pipefail git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD + # Check out the explicit ref, never FETCH_HEAD, and assert it landed. + git checkout --detach "$CHECKOUT_REF" + test "$(git rev-parse HEAD)" = "$CHECKOUT_REF" || { + echo "::error::checked out $(git rev-parse HEAD), expected $CHECKOUT_REF" + exit 1 + } - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -318,6 +324,10 @@ jobs: - name: Build desktop artifact shell: bash env: + # Bind provenance to the exact checked-out source, not the workflow + # dispatch commit (`GITHUB_SHA`). + T3CODE_RELEASE_BUILD: "1" + T3CODE_SOURCE_SHA: ${{ inputs.ref }} pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} @@ -463,6 +473,8 @@ jobs: if: inputs.cli_archive shell: bash env: + T3CODE_RELEASE_BUILD: "1" + T3CODE_SOURCE_SHA: ${{ inputs.ref }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/docs/operations/fork-release.md b/docs/operations/fork-release.md index a4ff912d5a12..f3f527bf0b54 100644 --- a/docs/operations/fork-release.md +++ b/docs/operations/fork-release.md @@ -8,32 +8,51 @@ It deliberately does not use `.github/workflows/release.yml`: that workflow requires upstream's Blacksmith runners, the production relay/Clerk/Cloudflare/ Vercel credentials, and publishes upstream npm packages. The fork entry point is `.github/workflows/fork-release.yml`, which reuses the existing packaging scripts -and `release-desktop.yml` unchanged. +and `release-desktop.yml`. ## Runners -Build jobs run on GitHub-hosted standard labels only: +Runner capacity is an explicit, authorized input. `fork-release.yml` has no +default runner label: the operator must pass `linux_runner`, `windows_runner`, +and `macos_x64_runner`, and every label must appear in the repository variable +`T3CODE_AUTHORIZED_RUNNERS` or preflight fails closed. This prevents a workflow +dispatch from silently landing on GitHub-hosted or paid capacity that was never +authorized. -| Target | Label | -| ----------------- | ------------------------------------------------------------- | -| Linux CLI archive | `ubuntu-24.04` | -| Windows x64 | `windows-2025` | -| Intel macOS x64 | `macos-15-intel` (override with the `macos_x64_runner` input) | +- No self-hosted label is guessed. +- No personal machine is registered to run public-PR jobs. +- No hosted/paid fallback is added silently. -These are free for a public repository, are not the paid `-large`/`-xlarge` -variants, are not guessed, and require no self-hosted registration. If a label -is ever unavailable, pass an authorized runner label through the input and -record why. +When no authorized CI runner is available, build the candidate on the already +authorized Windows/WSL and Intel macOS machines with the same scripts: -Apple Silicon macOS is built by the same workflow only when -`include_macos_arm64` is set; it is not exercised by default and is reported as -untested. +```sh +# Linux x64 runtime archive + resource monitor, inside the WSL distro: +node scripts/build-fork-candidate.ts --target linux --version 0.0.43 --sha + +# Windows x64 installer (embeds the Linux archive), on Windows: +node scripts/build-fork-candidate.ts --target win --version 0.0.43 --sha \ + --linux-archive candidate/t3-0.0.43-linux-x64.tar.gz + +# Intel macOS DMG, on the Intel Mac: +node scripts/build-fork-candidate.ts --target mac --version 0.0.43 --sha +``` + +`build-fork-candidate.ts` prints the plan by default and runs it with +`--execute`. It verifies the checked-out HEAD equals the requested source, runs +the same `build-cli-archive.ts`/`build-desktop-artifact.ts`/`smoke-cli-archive.ts` +steps, and freezes the candidate with the same `verify-fork-candidate.ts` the +workflow uses. It never invents a native acceptance receipt. + +Apple Silicon macOS is built only when `include_macos_arm64` is set and an +authorized `macos_arm64_runner` is supplied; it is reported untested. ## Versioning -Fork releases are plain `X.Y.Z` and are strictly increasing. The workflow -validates the requested version against the fork's published releases with -`scripts/fork-release-version.ts`: +Fork releases are plain `X.Y.Z` and are strictly increasing. The fork version is +the fork's own line, independent of the upstream base version it was built from; +`upstream_base` is recorded in the release notes only. The workflow validates the +requested version with `scripts/fork-release-version.ts`: ```sh # Next version above upstream base 0.0.42 and the existing 0.0.43. @@ -55,45 +74,60 @@ Rules: ## Release procedure 1. Pick the immutable source SHA on `main` and the upstream base version. -2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, and - `upstream_base`. Leave `publish` off to build a candidate. -3. The workflow builds the JS bundle once, then packages: +2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, + `upstream_base`, and the authorized runner labels. Leave `publish` off to + build a candidate. +3. Preflight checks out that explicit SHA (never `FETCH_HEAD`), asserts + `HEAD == sha`, and asserts the SHA is an ancestor of `origin/main` with + `scripts/select-release-source.ts`. +4. The workflow builds the JS bundle once, then packages: - `T3-Code--x64.exe` (NSIS) with the matching `t3--linux-x64.tar.gz` embedded as its WSL runtime; - `T3-Code--x64.dmg` (Intel macOS, unsigned unless Apple secrets exist); - - `t3--linux-x64.tar.gz` (self-contained Linux runtime archive). -4. `qualify` re-checks the artifact set, re-verifies the Linux archive's - provenance, and writes `SHA256SUMS` from the final distributed bytes. -5. Native acceptance on real Windows/WSL and Intel macOS hardware. -6. Re-run with `publish: true` to create the GitHub Release. The tag and target - are the exact version and source SHA that were built and qualified. The - publication job is the only one with `contents: write` and runs in the - `fork-release` environment, so it can require manual approval. + - `t3--linux-x64.tar.gz` (self-contained Linux runtime archive); + - `t3--win32-x64.zip` (self-contained Windows CLI archive). +5. `qualify` drops updater manifests, checks the required asset set, verifies + the Linux archive provenance, verifies the embedded WSL runtime equals the + standalone archive, and freezes `fork-release-candidate` with + `fork-release-manifest.json` and `SHA256SUMS` written from the exact + distributed bytes. +6. Native acceptance on real Windows/WSL and Intel macOS hardware. The accepted + bytes are recorded as `fork-native-receipts.json` and uploaded as the + `fork-release-native-receipts` artifact on the same run. A changed/rebuilt + asset invalidates its previous receipt. +7. Re-run with `publish: true` and `candidate_run_id` set to the qualifying + run. Promotion downloads that immutable artifact, verifies the manifest, + checksums, receipts, tag target, no-overwrite, version ordering, and the + authorization gate, then creates the release. It never rebuilds. Queued CI is not a passed release gate; a release is only qualified when the candidate artifact set exists and native smoke tests pass. ## Provenance and checksums -Every artifact embeds the repository, full source SHA, release version, and -architecture: +Every artifact embeds the repository, full source SHA, version, architecture, +and the workflow revision separately: - Desktop: `apps/desktop` staged `package.json` fields - (`t3codeSourceRepository`, `t3codeSourceSha`, `t3codeBuildVersion`, - `t3codeBuildArch`) plus a readable `t3code-build-info.json` in the packaged - app. + (`t3codeSourceRepository`, `t3codeSourceSha`, `t3codeWorkflowRevision`, + `t3codeBuildVersion`, `t3codeBuildArch`) plus a readable + `t3code-build-info.json` in the packaged app. - CLI archive: a readable `t3code-build-info.json` at the archive root. -`SHA256SUMS` is generated by the `qualify` job from the bytes that are actually -distributed, after updater manifests are normalized. The Windows installer's -embedded WSL payload is fail-closed at build time inside -`scripts/build-desktop-artifact.ts`: the archive and its SHA-256 sidecar must -both exist, the digest must match, and the archive must unpack to a single -`t3--linux-` directory holding `t3`, `client/`, and -`node_modules/node-pty/build/Release/pty.node` with no loose server bundle. -Native helpers are rebuilt from the selected source; they are never copied from -an installed app. +Release builds set `T3CODE_RELEASE_BUILD=1` and `T3CODE_SOURCE_SHA=`. In that mode the actual checkout is authoritative: an explicit +`T3CODE_SOURCE_SHA` that disagrees with `HEAD` fails the build, and `GITHUB_SHA` +(the workflow-dispatch revision) is recorded only as `workflowRevision`. A +manual dispatch that builds an older selected SHA therefore cannot mislabel the +payload with the dispatch commit. + +`SHA256SUMS` is generated by `qualify` from the bytes that are actually +distributed, after updater manifests are dropped. The Windows installer's +embedded WSL payload is verified against the standalone archive (byte-identical +plus matching source/version/architecture) by `scripts/verify-windows-installer.ts` +using 7-Zip. Native helpers are rebuilt from the selected source; they are never +copied from an installed app. ## Update isolation @@ -105,11 +139,17 @@ No fork install or update path may select an upstream `pingdotgg` release: overrides it; `T3CODE_RELEASE_BASE_URL` still overrides only the download origin for mirrors. - `scripts/install.sh` and `scripts/install.ps1` default to the fork and honor - `T3CODE_RELEASE_REPOSITORY`. + `T3CODE_RELEASE_REPOSITORY`. Both check the release's `SHA256SUMS` for the + requested archive and fail with a clear message for an unsupported + platform/architecture before attempting a download. - Desktop `app-update.yml` is derived from `T3CODE_DESKTOP_UPDATE_REPOSITORY` or `GITHUB_REPOSITORY`, which is the fork in this repository. -## Signing +The fork release attaches `linux-x64` and `win32-x64` self-contained archives. +Other platform keys are rejected clearly by the installers and `t3 update` +rather than producing a predictable missing-asset error. + +## Signing and updates Three distinct support levels: @@ -118,10 +158,12 @@ Three distinct support levels: notarized and the user opens it once via Gatekeeper's Open action. - **Signed/notarized** (only when Apple/Azure secrets exist): the existing auto-detect path in `release-desktop.yml` is reused. -- **In-app desktop update**: published for Windows x64 only. An unsigned macOS - build cannot complete a Squirrel.Mac update, so macOS is built without an - update feed and no macOS updater manifest is attached. Linux updates by - downloading the new archive. +- **In-app desktop update**: not advertised for the first release. No updater + manifest (`latest.yml`) is attached. Windows automatic update is enabled only + after an N -> N+1 update acceptance test passes with the real + publisher/signature configuration intact; until then installs update by + downloading the new artifact. An unsigned macOS build cannot complete a + Squirrel.Mac update at all. No signing credentials are provisioned or purchased by this workflow. @@ -161,13 +203,19 @@ For users (not build tooling): needs no Node, npm, or compiler. Building locally needs Node (per `engines.node`), `vp`, and Rust only when the -native helpers are rebuilt. The release workflow installs all of these. +native helpers are rebuilt. The release workflow installs all of these. The +Intel macOS native `node-pty` build requires Homebrew LLVM 20 with an explicit +`-isysroot` on `CFLAGS`, `CXXFLAGS`, and `LDFLAGS` (Apple clang 12 rejects +`-std=gnu++20`, and Homebrew LLVM links against a default sysroot that does not +exist on a Command Line Tools-only host). ## Known limitations - Apple Silicon macOS is available but untested by default. - Intel macOS has no `t3` CLI archive: Node single-executables are unsupported on x64 macOS. +- Automatic desktop update is not enabled for the first release; installs update + by downloading the new artifact. - The fork still points at the public upstream T3 Connect relay/Clerk identifiers by default, so pairing state survives upgrades; override with repository variables to disable cloud features. diff --git a/docs/user/fork-install.md b/docs/user/fork-install.md index 6d164f435e27..0e0b97b3f407 100644 --- a/docs/user/fork-install.md +++ b/docs/user/fork-install.md @@ -15,6 +15,8 @@ compiler. 2. For the WSL backend, install WSL 2 and a distro, then pick it in **Settings → Connections**. The installer already contains the matching Linux runtime, so no separate download is needed. +3. The fork also publishes `t3--win32-x64.zip`, a self-contained + Windows CLI archive for `t3` outside the desktop app. ## Intel macOS @@ -42,10 +44,16 @@ why; fork releases are plain stable versions. `T3CODE_RELEASE_REPOSITORY` and Download the newer artifact and install it over the existing one. Your settings, sign-in, pairings, projects, and databases are preserved. -- Windows and Linux check for and download fork updates. +- Fork releases do not enable automatic desktop updates yet; install the new + Windows or macOS artifact by hand. +- On Linux, `t3 update` and the installer download the newer fork archive. - Intel macOS updates by downloading the new DMG; in-app macOS updates are not supported for unsigned fork builds. +The fork publishes `linux-x64` and `win32-x64` CLI archives. Other +platform/architecture combinations fail with a clear message instead of +downloading a missing asset. + If you are coming from a build that had no update feed, this first release-managed install is the migration: install it once by hand, and later updates can follow the fork release channel. diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index d1344864719e..f9ca08dc5221 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -46,8 +46,8 @@ import { BUILD_INFO_FILE_NAME, createBuildInfo, readGitSourceProvenance, + resolveBuildSourceShaFromEnv, resolveSourceRepository, - resolveSourceSha, serializeBuildInfo, } from "./lib/source-provenance.ts"; @@ -529,12 +529,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { // a human can read the exact repository, full source SHA, version, and // architecture without trusting the file name. const gitSource = yield* readGitSourceProvenance(repoRoot); + const source = yield* resolveBuildSourceShaFromEnv(process.env, gitSource.sourceSha); const buildInfo = createBuildInfo({ version: input.version, platform: input.platform, arch: input.arch, repository: resolveSourceRepository(process.env), - sourceSha: resolveSourceSha(process.env, gitSource.sourceSha), + sourceSha: source.sourceSha, + workflowRevision: source.workflowRevision, }); yield* fs.writeFileString( path.join(contentDir, BUILD_INFO_FILE_NAME), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 0698ba65da26..f8e71c84993a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -40,8 +40,8 @@ import { BUILD_INFO_FILE_NAME, createBuildInfo, readGitSourceProvenance, + resolveBuildSourceShaFromEnv, resolveSourceRepository, - resolveSourceSha, serializeBuildInfo, } from "./lib/source-provenance.ts"; @@ -831,9 +831,9 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( return { stdout, stderr, exitCode } as const; }); -const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { +const resolveSourceProvenance = Effect.fn("resolveSourceProvenance")(function* (repoRoot: string) { const gitSource = yield* readGitSourceProvenance(repoRoot); - return resolveSourceSha(process.env, gitSource.sourceSha); + return yield* resolveBuildSourceShaFromEnv(process.env, gitSource.sourceSha); }); const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { @@ -918,6 +918,7 @@ interface StagePackageJson { readonly t3codeCommitHash: string; readonly t3codeSourceRepository: string; readonly t3codeSourceSha: string; + readonly t3codeWorkflowRevision: string; readonly t3codeBuildVersion: string; readonly t3codeBuildArch: string; readonly private: true; @@ -3397,14 +3398,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const appVersion = options.version ?? serverPackageJson.version; const iconAssets = resolveDesktopBuildIconAssets(appVersion); - const commitHash = yield* resolveGitCommitHash(repoRoot); + const source = yield* resolveSourceProvenance(repoRoot); + const commitHash = source.sourceSha; const sourceRepository = resolveSourceRepository(process.env); const buildInfo = createBuildInfo({ version: appVersion, platform: options.platform, arch: options.arch, repository: sourceRepository, - sourceSha: commitHash, + sourceSha: source.sourceSha, + workflowRevision: source.workflowRevision, }); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ @@ -3649,6 +3652,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( t3codeCommitHash: commitHash, t3codeSourceRepository: buildInfo.repository, t3codeSourceSha: buildInfo.sourceSha, + t3codeWorkflowRevision: buildInfo.workflowRevision, t3codeBuildVersion: buildInfo.version, t3codeBuildArch: buildInfo.arch, private: true, diff --git a/scripts/build-fork-candidate.ts b/scripts/build-fork-candidate.ts new file mode 100644 index 000000000000..6c77f675552a --- /dev/null +++ b/scripts/build-fork-candidate.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - A machine-local orchestrator that shells out to the repo's own build scripts. +/** + * Machine-local candidate build route. + * + * Use this when no authorized CI runner is available. It runs the same + * packaging and verification scripts the workflow runs, on the authorized + * Windows/WSL and Intel macOS sessions, and writes the same candidate layout + * (`fork-release-manifest.json`, `SHA256SUMS`, assets) that `qualify` writes. + * + * Default is a dry run: it prints the exact plan. Pass `--execute` to run it. + * A native acceptance receipt for each required target is still required before + * publication; this tool never invents one. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { + planCandidateBuild, + planCandidateVerification, + type CandidatePlanStep, + type CandidateTarget, +} from "./lib/candidate-build-plan.ts"; + +interface Args { + target: CandidateTarget; + version: string; + sha: string; + repository: string; + outputDir: string; + resourceMonitorDir: string; + linuxArchive: string | undefined; + includeMacosArm64: boolean; + execute: boolean; +} + +function parseArgs(argv: ReadonlyArray): Args { + const values = new Map(); + const flags = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + values.set(key, next); + index += 1; + } else { + flags.add(key); + } + } + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.trim() === "") throw new Error(`--${key} is required`); + return value.trim(); + }; + const target = required("target"); + if (target !== "linux" && target !== "win" && target !== "mac") { + throw new Error("--target must be linux, win, or mac"); + } + return { + target, + version: required("version"), + sha: required("sha").toLowerCase(), + repository: values.get("repository")?.trim() || "nullStack65/t3code", + outputDir: values.get("output-dir")?.trim() || "candidate", + resourceMonitorDir: + values.get("resource-monitor-dir")?.trim() || + NodePath.join(NodeOS.tmpdir(), "t3-candidate-resource-monitor"), + linuxArchive: values.get("linux-archive")?.trim(), + includeMacosArm64: flags.has("include-macos-arm64"), + execute: flags.has("execute"), + }; +} + +const run = (step: CandidatePlanStep): void => { + console.log(`\n$ ${step.command.join(" ")}`); + const result = NodeChildProcess.spawnSync(step.command[0]!, step.command.slice(1), { + stdio: "inherit", + shell: HostProcessPlatform.defaultValue() === "win32", + }); + if (result.status !== 0) { + throw new Error(`step '${step.id}' failed with exit code ${result.status ?? "unknown"}`); + } +}; + +function assertSource(args: Args): void { + const head = NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + if (head.toLowerCase() !== args.sha) { + throw new Error(`checked-out HEAD ${head} does not match requested source ${args.sha}`); + } + const ancestor = NodeChildProcess.spawnSync( + "git", + ["merge-base", "--is-ancestor", args.sha, "origin/main"], + { stdio: "ignore" }, + ); + if (ancestor.status !== 0) { + throw new Error(`${args.sha} is not an ancestor of origin/main`); + } + console.log(`Source verified: ${args.sha} (HEAD, ancestor of origin/main).`); +} + +function stageResourceMonitor(args: Args): void { + const target = NodePath.join(args.resourceMonitorDir, "linux-x64"); + NodeFS.mkdirSync(target, { recursive: true }); + NodeFS.copyFileSync( + NodePath.join("native/resource-monitor/target/release/t3-resource-monitor"), + NodePath.join(target, "t3-resource-monitor"), + ); + console.log(`Staged resource monitor into ${target}`); +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const buildSteps = planCandidateBuild({ + target: args.target, + version: args.version, + outputDir: args.outputDir, + resourceMonitorDir: args.resourceMonitorDir, + linuxArchive: args.linuxArchive, + }); + const verifyStep = planCandidateVerification({ + version: args.version, + sourceSha: args.sha, + repository: args.repository, + candidateDir: args.outputDir, + includeMacosArm64: args.includeMacosArm64, + }); + + console.log( + `Machine-local candidate plan for ${args.target} (${args.repository} v${args.version})`, + ); + for (const step of [...buildSteps, verifyStep]) { + console.log(` - ${step.id}: ${step.description}`); + console.log(` ${step.command.join(" ")}`); + } + + if (!args.execute) { + console.log("\nDry run. Pass --execute to run these steps."); + return; + } + + assertSource(args); + NodeFS.mkdirSync(args.outputDir, { recursive: true }); + if (args.target === "linux") { + run(buildSteps.find((step) => step.id === "resource-monitor")!); + stageResourceMonitor(args); + for (const step of buildSteps) { + if (step.id === "resource-monitor") continue; + run(step); + } + } else { + for (const step of buildSteps) { + run(step); + } + } + run(verifyStep); + console.log( + "\nCandidate assembled and verified locally. Native acceptance receipts are still required before publication.", + ); +} + +main(); diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c429d0f90dd5..d0d4845edb29 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -178,13 +178,19 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { } throw } + + # The fork release publishes only linux-x64 and win32-x64 archives. Reject + # an unsupported target here, before downloading a file that is not attached. + $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) + if (-not $expected) { + Fail "t3 $version has no fork release archive for win32-$arch; the fork publishes linux-x64 and win32-x64 self-contained archives" + } + $expected = ($expected -split "\s+")[0].ToLowerInvariant() + Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress Step "Verifying the download..." - $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) - if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } - $expected = ($expected -split "\s+")[0].ToLowerInvariant() $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } diff --git a/scripts/install.sh b/scripts/install.sh index 6037c1512fea..5ff7a660de42 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -199,11 +199,16 @@ else elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi + + # The fork release publishes only linux-x64 and win32-x64 archives. Reject an + # unsupported target here, before downloading a file that is not attached. + expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" + if [ -z "$expected" ]; then + fail "t3 ${version} has no fork release archive for ${platform}-${arch}; the fork publishes linux-x64 and win32-x64 self-contained archives" + fi download "${base_url}/v${version}/${archive}" "${staging}/${archive}" step "Verifying the download..." - expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" - [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" actual="$(checksum "${staging}/${archive}")" [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" diff --git a/scripts/lib/candidate-build-plan.test.ts b/scripts/lib/candidate-build-plan.test.ts new file mode 100644 index 000000000000..372b7b2e3f97 --- /dev/null +++ b/scripts/lib/candidate-build-plan.test.ts @@ -0,0 +1,82 @@ +import { assert, it } from "@effect/vitest"; + +import { + planCandidateBuild, + planCandidateVerification, + type CandidatePlanStep, +} from "./candidate-build-plan.ts"; + +const flat = (steps: ReadonlyArray): string => + steps.map((step) => step.command.join(" ")).join("\n"); + +const VERSION = "0.0.43"; +const SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; + +it("plans the Linux runtime archive with the workflow's own scripts", () => { + const steps = planCandidateBuild({ + target: "linux", + version: VERSION, + outputDir: "candidate", + resourceMonitorDir: "/tmp/rm", + }); + const commands = flat(steps); + assert.include(commands, "scripts/build-cli-archive.ts"); + assert.include(commands, "scripts/smoke-cli-archive.ts"); + assert.include(commands, "apps/server/scripts/cli.ts build-exe"); + assert.include(commands, `--version ${VERSION}`); + assert.include(commands, "--resource-monitor-dir /tmp/rm"); +}); + +it("plans the Windows installer with the Linux WSL runtime embedded", () => { + const steps = planCandidateBuild({ + target: "win", + version: VERSION, + outputDir: "candidate", + resourceMonitorDir: "/tmp/rm", + linuxArchive: `candidate/t3-${VERSION}-linux-x64.tar.gz`, + }); + const commands = flat(steps); + assert.include(commands, "scripts/build-desktop-artifact.ts"); + assert.include(commands, "--platform win"); + assert.include(commands, "--target nsis"); + assert.include(commands, `--wsl-runtime candidate/t3-${VERSION}-linux-x64.tar.gz`); +}); + +it("refuses a Windows plan without the Linux runtime archive", () => { + assert.throws(() => + planCandidateBuild({ + target: "win", + version: VERSION, + outputDir: "candidate", + resourceMonitorDir: "/tmp/rm", + }), + ); +}); + +it("plans the Intel macOS DMG", () => { + const steps = planCandidateBuild({ + target: "mac", + version: VERSION, + outputDir: "candidate", + resourceMonitorDir: "/tmp/rm", + }); + const commands = flat(steps); + assert.include(commands, "--platform mac"); + assert.include(commands, "--target dmg"); + assert.include(commands, "--arch x64"); +}); + +it("uses the shared verifier so local and CI candidates are checked identically", () => { + const step = planCandidateVerification({ + version: VERSION, + sourceSha: SHA, + repository: "nullStack65/t3code", + candidateDir: "candidate", + }); + const command = step.command.join(" "); + assert.include(command, "scripts/verify-fork-candidate.ts"); + assert.include(command, `--sha ${SHA}`); + assert.include(command, "--write-checksums"); + // Native receipts are a promotion gate, not a build-time requirement. + assert.notInclude(command, "--require-native-receipts"); +}); diff --git a/scripts/lib/candidate-build-plan.ts b/scripts/lib/candidate-build-plan.ts new file mode 100644 index 000000000000..9a4adfa5d870 --- /dev/null +++ b/scripts/lib/candidate-build-plan.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Machine-local fork release candidate build plan. + * + * CI capacity is a separate provisioning gate. When an authorized GitHub + * Actions runner is unavailable, the already-authorized Windows/WSL and Intel + * macOS sessions assemble the candidate with the *same* scripts the workflow + * uses (`build-cli-archive.ts`, `build-desktop-artifact.ts`, + * `smoke-cli-archive.ts`) and the same verifier + * (`verify-fork-candidate.ts`). This module is the shared plan so the two + * routes cannot drift. + */ + +export type CandidateTarget = "linux" | "win" | "mac"; + +export interface CandidatePlanStep { + readonly id: string; + readonly description: string; + readonly command: ReadonlyArray; +} + +export interface CandidatePlanInput { + readonly target: CandidateTarget; + readonly version: string; + readonly outputDir: string; + /** Directory holding `/t3-resource-monitor[.exe]` for the archive. */ + readonly resourceMonitorDir: string; + /** Path to the Linux x64 archive embedded as the Windows WSL runtime. */ + readonly linuxArchive?: string | undefined; +} + +export interface VerificationPlanInput { + readonly version: string; + readonly sourceSha: string; + readonly repository: string; + readonly candidateDir: string; + readonly includeMacosArm64?: boolean; +} + +/** The steps that build one target's artifacts into `outputDir`. */ +export function planCandidateBuild(input: CandidatePlanInput): ReadonlyArray { + if (input.target === "linux") { + return [ + { + id: "install", + description: "Install workspace dependencies", + command: ["vp", "install"], + }, + { + id: "bundle", + description: "Build the server/web bundle", + command: ["vp", "run", "--filter", "t3", "build"], + }, + { + id: "resource-monitor", + description: "Build the Linux resource monitor from source", + command: [ + "cargo", + "build", + "--locked", + "--release", + "--manifest-path", + "native/resource-monitor/Cargo.toml", + ], + }, + { + id: "sea", + description: "Build the Linux single-executable", + command: ["node", "apps/server/scripts/cli.ts", "build-exe", "--verbose"], + }, + { + id: "archive", + description: "Assemble the Linux x64 runtime archive", + command: [ + "node", + "scripts/build-cli-archive.ts", + "--platform", + "linux", + "--arch", + "x64", + "--version", + input.version, + "--resource-monitor-dir", + input.resourceMonitorDir, + "--output-dir", + input.outputDir, + ], + }, + { + id: "smoke", + description: "Smoke-test the Linux archive with no ambient Node", + command: [ + "node", + "scripts/smoke-cli-archive.ts", + "--archive", + `${input.outputDir}/t3-${input.version}-linux-x64.tar.gz`, + "--expect-version", + input.version, + ], + }, + ]; + } + + if (input.target === "win") { + if (input.linuxArchive === undefined || input.linuxArchive.trim() === "") { + throw new Error( + "the Windows desktop embeds the Linux x64 runtime; pass --linux-archive -linux-x64.tar.gz>", + ); + } + return [ + { + id: "install", + description: "Install workspace dependencies", + command: ["vp", "install"], + }, + { + id: "desktop", + description: "Package the Windows x64 NSIS installer with the Linux WSL runtime", + command: [ + "node", + "scripts/build-desktop-artifact.ts", + "--platform", + "win", + "--target", + "nsis", + "--arch", + "x64", + "--wsl-runtime", + input.linuxArchive, + "--verbose", + ], + }, + ]; + } + + return [ + { + id: "install", + description: "Install workspace dependencies", + command: ["vp", "install"], + }, + { + id: "desktop", + description: "Package the Intel macOS x64 DMG (unsigned unless Apple secrets exist)", + command: [ + "node", + "scripts/build-desktop-artifact.ts", + "--platform", + "mac", + "--target", + "dmg", + "--arch", + "x64", + "--verbose", + ], + }, + ]; +} + +/** The verification step every candidate must pass before it is publishable. */ +export function planCandidateVerification(input: VerificationPlanInput): CandidatePlanStep { + const command = [ + "node", + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + input.candidateDir, + "--version", + input.version, + "--sha", + input.sourceSha, + "--repository", + input.repository, + "--write-manifest", + "--write-checksums", + ]; + if (input.includeMacosArm64 === true) { + command.push("--include-macos-arm64"); + } + return { + id: "verify", + description: "Freeze the manifest/checksums and verify the candidate bytes", + command, + }; +} diff --git a/scripts/lib/fork-release-manifest.test.ts b/scripts/lib/fork-release-manifest.test.ts new file mode 100644 index 000000000000..4336300efdbc --- /dev/null +++ b/scripts/lib/fork-release-manifest.test.ts @@ -0,0 +1,251 @@ +import { assert, it } from "@effect/vitest"; + +import { + CANDIDATE_ARTIFACT_NAME, + RELEASE_ENVIRONMENT, + compareStableVersions, + renderChecksums, + requiredReleaseAssetNames, + verifyCandidate, + verifyPromotion, + type NativeReceipt, + type ReleaseAsset, + type ReleaseCandidateManifest, +} from "./fork-release-manifest.ts"; + +const VERSION = "0.0.43"; +const SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; +const DISPATCH = "89369420870a3086051fb01462193c805ecc2aaa"; + +const assets: ReleaseAsset[] = requiredReleaseAssetNames(VERSION).map((name, index) => ({ + name, + sha256: `${index}`.repeat(64).slice(0, 64), + size: 100 + index, +})); + +const receipts: NativeReceipt[] = [ + { + schemaVersion: 1, + owner: "W", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: `T3-Code-${VERSION}-x64.exe`, + assetSha256: assets[0]!.sha256, + result: "pass", + }, + { + schemaVersion: 1, + owner: "M", + target: "darwin-x64", + sourceSha: SHA, + version: VERSION, + assetName: `T3-Code-${VERSION}-x64.dmg`, + assetSha256: assets[1]!.sha256, + result: "pass", + }, +]; + +const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + repository: "nullStack65/t3code", + version: VERSION, + sourceSha: SHA, + workflowRevision: DISPATCH, + workflowRunId: "123", + workflowRunAttempt: "1", + channel: "stable", + createdAt: "2026-09-23T00:00:00.000Z", + assets, + nativeReceipts: receipts, +}; + +const expected = { repository: "nullStack65/t3code", version: VERSION, sourceSha: SHA }; + +it("requires the first-release target set", () => { + assert.deepEqual(requiredReleaseAssetNames(VERSION), [ + `T3-Code-${VERSION}-x64.exe`, + `T3-Code-${VERSION}-x64.dmg`, + `t3-${VERSION}-linux-x64.tar.gz`, + `t3-${VERSION}-win32-x64.zip`, + ]); + assert.include( + requiredReleaseAssetNames(VERSION, { includeMacosArm64: true }), + `T3-Code-${VERSION}-arm64.dmg`, + ); + assert.equal(CANDIDATE_ARTIFACT_NAME, "fork-release-candidate"); + assert.equal(RELEASE_ENVIRONMENT, "fork-release"); +}); + +it("accepts a complete, self-consistent candidate with receipts", () => { + const result = verifyCandidate({ + manifest, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.deepEqual(result, { ok: true, failures: [] }); +}); + +it("rejects a candidate whose source disagrees with the expected source", () => { + const result = verifyCandidate({ + manifest: { ...manifest, sourceSha: DISPATCH }, + expected, + observedAssets: assets, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /manifest sourceSha/); +}); + +it("rejects a missing, corrupt, or replaced asset", () => { + const missing = verifyCandidate({ + manifest, + expected, + observedAssets: assets.slice(1), + }); + assert.match(missing.failures.join("\n"), /required asset .*x64\.exe is missing/); + + const corrupt = verifyCandidate({ + manifest, + expected, + observedAssets: [{ ...assets[0]!, sha256: "f".repeat(64) }, ...assets.slice(1)], + }); + assert.match(corrupt.failures.join("\n"), /sha256/); + + const replaced = verifyCandidate({ + manifest, + expected, + observedAssets: [{ ...assets[0]!, size: assets[0]!.size + 1 }, ...assets.slice(1)], + }); + assert.match(replaced.failures.join("\n"), /size/); +}); + +it("rejects an extra unrecorded asset and a manifest entry with no file", () => { + const extra = verifyCandidate({ + manifest, + expected, + observedAssets: [...assets, { name: "surprise.zip", sha256: "a".repeat(64), size: 1 }], + }); + assert.match(extra.failures.join("\n"), /not recorded in the manifest/); + + const absent = verifyCandidate({ + manifest: { + ...manifest, + assets: [...assets, { name: "ghost.zip", sha256: "a".repeat(64), size: 1 }], + }, + expected, + observedAssets: assets, + }); + assert.match(absent.failures.join("\n"), /ghost\.zip .*not present/); +}); + +it("rejects missing, wrong-source, and wrong-version native receipts", () => { + const missing = verifyCandidate({ + manifest: { ...manifest, nativeReceipts: [] }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.match(missing.failures.join("\n"), /no native acceptance receipt for win32-x64/); + + const wrongSource = verifyCandidate({ + manifest: { + ...manifest, + nativeReceipts: receipts.map((receipt) => ({ ...receipt, sourceSha: DISPATCH })), + }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.match(wrongSource.failures.join("\n"), /receipt .* is for source/); + + const wrongVersion = verifyCandidate({ + manifest: { + ...manifest, + nativeReceipts: receipts.map((receipt) => ({ ...receipt, version: "0.0.44" })), + }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.match(wrongVersion.failures.join("\n"), /receipt .* is for version/); + + const failed = verifyCandidate({ + manifest: { + ...manifest, + nativeReceipts: receipts.map((receipt) => ({ ...receipt, result: "fail" as const })), + }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.match(failed.failures.join("\n"), /no passing native acceptance receipt/); +}); + +it("promotion refuses overwrite, older versions, and a missing authorization gate", () => { + const base = { + manifest, + expected, + observedAssets: assets, + requireNativeReceipts: true, + tagTargetSha: SHA, + }; + + const good = verifyPromotion({ + ...base, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.42", + authorizationGateExists: true, + }); + assert.deepEqual(good, { ok: true, failures: [] }); + + const overwrite = verifyPromotion({ + ...base, + releaseExists: true, + tagExists: true, + latestExistingVersion: "0.0.42", + authorizationGateExists: true, + }); + assert.match(overwrite.failures.join("\n"), /already exists/); + + const older = verifyPromotion({ + ...base, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.44", + authorizationGateExists: true, + }); + assert.match(older.failures.join("\n"), /not newer than the latest published 0.0.44/); + + const noGate = verifyPromotion({ + ...base, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.42", + authorizationGateExists: false, + }); + assert.match(noGate.failures.join("\n"), /authorization gate/); + + const wrongTarget = verifyPromotion({ + ...base, + tagTargetSha: DISPATCH, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.42", + authorizationGateExists: true, + }); + assert.match(wrongTarget.failures.join("\n"), /tag target/); +}); + +it("renders checksums and orders versions", () => { + assert.equal( + renderChecksums([ + { name: "b.zip", sha256: "b".repeat(64), size: 2 }, + { name: "a.zip", sha256: "a".repeat(64), size: 1 }, + ]), + `${"a".repeat(64)} a.zip\n${"b".repeat(64)} b.zip\n`, + ); + assert.isBelow(compareStableVersions("0.0.42", "0.0.43"), 0); + assert.isAbove(compareStableVersions("1.0.0", "0.9.9"), 0); +}); diff --git a/scripts/lib/fork-release-manifest.ts b/scripts/lib/fork-release-manifest.ts new file mode 100644 index 000000000000..50e1e5fcace4 --- /dev/null +++ b/scripts/lib/fork-release-manifest.ts @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Fork release candidate manifest and promotion verification. + * + * A release is only trustworthy if the bytes that were qualified are the bytes + * that get published. The candidate is therefore frozen as an immutable + * workflow artifact plus a manifest that records the source SHA, version, + * complete asset list, per-asset SHA-256, and the native acceptance receipts. + * Promotion re-reads that artifact and verifies it instead of rebuilding. + * + * Everything here is pure: the caller supplies the manifest and the observed + * hashes, so the rules can be exercised without a network or a build. + */ +import * as NodeCrypto from "node:crypto"; + +export const CANDIDATE_MANIFEST_FILE_NAME = "fork-release-manifest.json"; +export const NATIVE_RECEIPTS_FILE_NAME = "fork-native-receipts.json"; +export const SHA256SUMS_FILE_NAME = "SHA256SUMS"; +export const CANDIDATE_ARTIFACT_NAME = "fork-release-candidate"; +export const NATIVE_RECEIPTS_ARTIFACT_NAME = "fork-release-native-receipts"; + +export const RELEASE_REPOSITORY = "nullStack65/t3code"; +export const RELEASE_ENVIRONMENT = "fork-release"; + +export const CANDIDATE_SCHEMA_VERSION = 1; +export const NATIVE_RECEIPTS_SCHEMA_VERSION = 1; + +export interface ReleaseAsset { + readonly name: string; + readonly sha256: string; + readonly size: number; +} + +export interface NativeReceipt { + readonly schemaVersion: 1; + /** Who ran the acceptance: `W` (Windows/WSL) or `M` (macOS). */ + readonly owner: string; + /** Target the receipt accepts, for example `win32-x64` or `darwin-x64`. */ + readonly target: string; + readonly sourceSha: string; + readonly version: string; + readonly assetName: string; + readonly assetSha256: string; + readonly result: "pass" | "fail"; + readonly notes?: string | undefined; +} + +export interface ReleaseCandidateManifest { + readonly schemaVersion: 1; + readonly repository: string; + readonly version: string; + readonly sourceSha: string; + readonly workflowRevision: string; + readonly workflowRunId: string; + readonly workflowRunAttempt: string; + readonly channel: string; + readonly createdAt: string; + readonly assets: ReadonlyArray; + readonly nativeReceipts: ReadonlyArray; +} + +export interface CandidateExpectations { + readonly repository: string; + readonly version: string; + readonly sourceSha: string; +} + +/** The native targets whose acceptance a first release requires. */ +export const REQUIRED_NATIVE_TARGETS = ["win32-x64", "darwin-x64"] as const; + +export function requiredReleaseAssetNames( + version: string, + options: { readonly includeMacosArm64?: boolean } = {}, +): ReadonlyArray { + const names = [ + `T3-Code-${version}-x64.exe`, + `T3-Code-${version}-x64.dmg`, + `t3-${version}-linux-x64.tar.gz`, + `t3-${version}-win32-x64.zip`, + ]; + if (options.includeMacosArm64 === true) { + names.push(`T3-Code-${version}-arm64.dmg`); + } + return names; +} + +export function sha256Hex(bytes: Uint8Array): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +export interface VerificationResult { + readonly ok: boolean; + readonly failures: ReadonlyArray; +} + +const ok = (): VerificationResult => ({ ok: true, failures: [] }); + +const failures = (list: ReadonlyArray): VerificationResult => + list.length === 0 ? ok() : { ok: false, failures: list }; + +export interface VerifyCandidateInput { + readonly manifest: ReleaseCandidateManifest; + readonly expected: CandidateExpectations; + /** Observed `sha256` and byte size for each file actually present. */ + readonly observedAssets: ReadonlyArray; + readonly includeMacosArm64?: boolean; + readonly requireNativeReceipts?: boolean; +} + +/** + * Verifies a candidate's manifest against the bytes on disk and the expected + * source. Every required asset must be present with a matching digest, the + * manifest must not disagree with the expected source, and each native receipt + * must bind to the same source and the same asset digest. + */ +export function verifyCandidate(input: VerifyCandidateInput): VerificationResult { + const problems: string[] = []; + const { manifest, expected } = input; + + if (manifest.schemaVersion !== CANDIDATE_SCHEMA_VERSION) { + problems.push(`manifest schemaVersion is ${manifest.schemaVersion}`); + } + if (manifest.repository !== expected.repository) { + problems.push(`manifest repository is ${manifest.repository}, expected ${expected.repository}`); + } + if (manifest.version !== expected.version) { + problems.push(`manifest version is ${manifest.version}, expected ${expected.version}`); + } + if (manifest.sourceSha !== expected.sourceSha) { + problems.push(`manifest sourceSha is ${manifest.sourceSha}, expected ${expected.sourceSha}`); + } + + const observed = new Map(input.observedAssets.map((asset) => [asset.name, asset])); + const required = requiredReleaseAssetNames(expected.version, { + includeMacosArm64: input.includeMacosArm64 === true, + }); + const manifestAssets = new Map(manifest.assets.map((asset) => [asset.name, asset])); + + for (const name of required) { + if (!observed.has(name)) { + problems.push(`required asset ${name} is missing from the candidate`); + } + if (!manifestAssets.has(name)) { + problems.push(`required asset ${name} is missing from the manifest`); + } + } + + for (const asset of input.observedAssets) { + const recorded = manifestAssets.get(asset.name); + if (recorded === undefined) { + problems.push(`asset ${asset.name} is present but not recorded in the manifest`); + continue; + } + if (recorded.sha256.toLowerCase() !== asset.sha256.toLowerCase()) { + problems.push( + `asset ${asset.name} sha256 is ${asset.sha256}, manifest recorded ${recorded.sha256}`, + ); + } + if (recorded.size !== asset.size) { + problems.push( + `asset ${asset.name} size is ${asset.size}, manifest recorded ${recorded.size}`, + ); + } + } + + for (const recorded of manifest.assets) { + if (!observed.has(recorded.name)) { + problems.push(`manifest lists ${recorded.name} but it is not present`); + } + } + + const receipts = manifest.nativeReceipts; + if (input.requireNativeReceipts === true) { + for (const target of REQUIRED_NATIVE_TARGETS) { + const matching = receipts.filter((receipt) => receipt.target === target); + if (matching.length === 0) { + problems.push(`no native acceptance receipt for ${target}`); + continue; + } + if (!matching.some((receipt) => receipt.result === "pass")) { + problems.push(`no passing native acceptance receipt for ${target}`); + } + } + } + for (const receipt of receipts) { + if (receipt.schemaVersion !== NATIVE_RECEIPTS_SCHEMA_VERSION) { + problems.push(`receipt for ${receipt.target} has schemaVersion ${receipt.schemaVersion}`); + } + if (receipt.sourceSha !== expected.sourceSha) { + problems.push( + `receipt for ${receipt.target} is for source ${receipt.sourceSha}, expected ${expected.sourceSha}`, + ); + } + if (receipt.version !== expected.version) { + problems.push( + `receipt for ${receipt.target} is for version ${receipt.version}, expected ${expected.version}`, + ); + } + const asset = manifestAssets.get(receipt.assetName); + if (asset === undefined) { + problems.push(`receipt for ${receipt.target} names unknown asset ${receipt.assetName}`); + } else if (asset.sha256.toLowerCase() !== receipt.assetSha256.toLowerCase()) { + problems.push( + `receipt for ${receipt.target} accepted ${receipt.assetSha256}, asset is ${asset.sha256}`, + ); + } + } + + return failures(problems); +} + +export interface VerifyPromotionInput extends VerifyCandidateInput { + readonly tagTargetSha: string; + readonly releaseExists: boolean; + readonly tagExists: boolean; + readonly latestExistingVersion: string | undefined; + readonly authorizationGateExists: boolean; +} + +/** + * Promotion adds the release-level checks on top of candidate verification: + * the tag target must be the source, nothing may be overwritten, the version + * must stay ahead of the latest published release, and the environment + * authorization gate must actually exist. + */ +export function verifyPromotion(input: VerifyPromotionInput): VerificationResult { + const candidate = verifyCandidate(input); + const problems = [...candidate.failures]; + + if (input.tagTargetSha !== input.expected.sourceSha) { + problems.push( + `tag target ${input.tagTargetSha} does not match source ${input.expected.sourceSha}`, + ); + } + if (input.releaseExists) { + problems.push(`release for v${input.expected.version} already exists`); + } + if (input.tagExists) { + problems.push(`tag v${input.expected.version} already exists`); + } + if (input.latestExistingVersion !== undefined) { + const comparison = compareStableVersions(input.expected.version, input.latestExistingVersion); + if (comparison <= 0) { + problems.push( + `version ${input.expected.version} is not newer than the latest published ${input.latestExistingVersion}`, + ); + } + } + if (!input.authorizationGateExists) { + problems.push( + `publication authorization gate (environment '${RELEASE_ENVIRONMENT}') is not configured with protection rules`, + ); + } + + return failures(problems); +} + +/** Plain `X.Y.Z` ordering; a non-version sorts lowest. */ +export function compareStableVersions(left: string, right: string): number { + const parse = (value: string): readonly [number, number, number] | undefined => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim()); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; + }; + const a = parse(left); + const b = parse(right); + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** Renders `sha256sum`-style checksums from a manifest's asset list. */ +export function renderChecksums(assets: ReadonlyArray): string { + return ( + assets + .map((asset) => `${asset.sha256} ${asset.name}`) + .sort() + .join("\n") + "\n" + ); +} diff --git a/scripts/lib/fork-release-workflow.test.ts b/scripts/lib/fork-release-workflow.test.ts new file mode 100644 index 000000000000..612b24516270 --- /dev/null +++ b/scripts/lib/fork-release-workflow.test.ts @@ -0,0 +1,107 @@ +// @effect-diagnostics nodeBuiltinImport:off - Reads the workflow files as text to assert the job graph. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +const workflowsDir = NodePath.resolve(import.meta.dirname, "../../.github/workflows"); + +const readWorkflow = (name: string): Promise => + NodeFSP.readFile(NodePath.join(workflowsDir, name), "utf8"); + +/** Extracts a top-level job block (` name:`) up to the next top-level job. */ +function jobBlock(text: string, job: string): string { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((line) => line.startsWith(` ${job}:`)); + assert.notEqual(start, -1, `job ${job} not found`); + const block: string[] = []; + for (let index = start; index < lines.length; index += 1) { + const line = lines[index]!; + if (index > start && /^ {2}\S/.test(line)) break; + block.push(line); + } + return block.join("\n"); +} + +const inlineList = (block: string, key: string): string[] => { + const match = new RegExp(`^\\s*${key}:\\s*\\[(.*)\\]`, "m").exec(block); + if (match === null) return []; + return match[1]! + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); +}; + +const scalar = (block: string, key: string): string | undefined => + new RegExp(`^\\s*${key}:\\s*(.+)$`, "m").exec(block)?.[1]?.trim(); + +it.effect("the qualify job depends on the optional arm64 job and handles skipped", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const qualify = jobBlock(text, "qualify"); + const needs = inlineList(qualify, "needs"); + assert.include(needs, "desktop_mac_arm64", "qualify.needs must include the optional arm64 job"); + assert.include(needs, "desktop_win_x64"); + assert.include(needs, "desktop_mac_x64"); + assert.include(needs, "cli_linux_x64"); + + const condition = scalar(qualify, "if") ?? ""; + assert.include(condition, "needs.desktop_mac_arm64.result"); + assert.include(condition, "inputs.include_macos_arm64"); + // Disabled arm64 is a skipped job, so the guard must allow the disabled case. + assert.include(condition, "inputs.include_macos_arm64 == false"); + }), +); + +it.effect("arm64 stays optional and is not silently exercised", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const arm64 = jobBlock(text, "desktop_mac_arm64"); + assert.include(scalar(arm64, "if") ?? "", "inputs.include_macos_arm64"); + }), +); + +it.effect("builds the Windows CLI archive so the Windows install path has an asset", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const win = jobBlock(text, "desktop_win_x64"); + assert.include(scalar(win, "cli_archive") ?? "", "true"); + }), +); + +it.effect("no job silently defaults to a GitHub-hosted runner label", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + assert.notInclude(text, "runs-on: ubuntu-"); + assert.notInclude(text, "runs-on: windows-"); + assert.notInclude(text, "runs-on: macos-"); + assert.include(text, "T3CODE_AUTHORIZED_RUNNERS"); + }), +); + +it.effect("publication promotes a candidate by run id and never rebuilds", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const publish = jobBlock(text, "publish"); + assert.include(publish, "candidate_run_id"); + assert.include(publish, "gh run download"); + assert.include(publish, "fork-release-native-receipts"); + assert.include(publish, "--promote"); + assert.include(publish, "--tag-target"); + assert.include(publish, "environments/fork-release"); + assert.include(publish, "fork-release-publish"); + assert.notInclude(publish, "build-cli-archive.ts"); + assert.notInclude(publish, "build-desktop-artifact.ts"); + }), +); + +it.effect("release-desktop binds provenance to the checked-out ref", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("release-desktop.yml")); + assert.include(text, 'git checkout --detach "$CHECKOUT_REF"'); + assert.notInclude(text, "git checkout --detach FETCH_HEAD"); + assert.include(text, "T3CODE_SOURCE_SHA: ${{ inputs.ref }}"); + assert.include(text, 'T3CODE_RELEASE_BUILD: "1"'); + }), +); diff --git a/scripts/lib/release-source.test.ts b/scripts/lib/release-source.test.ts new file mode 100644 index 000000000000..2ef143434f17 --- /dev/null +++ b/scripts/lib/release-source.test.ts @@ -0,0 +1,193 @@ +// @effect-diagnostics nodeBuiltinImport:off - Sets up real git repositories to exercise the selection sequence end to end. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + InvalidSourceShaError, + SourceNotOnMainError, + normalizeSourceSha, + selectReleaseSource, +} from "./release-source.ts"; + +const isSourceNotOnMain = Schema.is(SourceNotOnMainError); +const isInvalidSourceSha = Schema.is(InvalidSourceShaError); + +const git = (cwd: string, args: readonly string[]): string => + NodeChildProcess.execFileSync( + "git", + [ + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ...args, + ], + { cwd, encoding: "utf8" }, + ).trim(); + +interface Fixture { + readonly root: string; + readonly origin: string; + readonly work: string; + readonly shaA: string; + readonly shaB: string; + readonly shaOffMain: string; +} + +/** + * Builds a remote with two commits on `main` (A then B) plus a commit on a + * side branch that is not an ancestor of `main`. + */ +async function createFixture(): Promise { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-release-source-")); + const origin = NodePath.join(root, "origin"); + const work = NodePath.join(root, "work"); + await NodeFSP.mkdir(origin, { recursive: true }); + await NodeFSP.mkdir(work, { recursive: true }); + + git(origin, ["init", "-b", "main"]); + await NodeFSP.writeFile(NodePath.join(origin, "a.txt"), "a\n"); + git(origin, ["add", "."]); + git(origin, ["commit", "-m", "A"]); + const shaA = git(origin, ["rev-parse", "HEAD"]); + + await NodeFSP.writeFile(NodePath.join(origin, "b.txt"), "b\n"); + git(origin, ["add", "."]); + git(origin, ["commit", "-m", "B"]); + const shaB = git(origin, ["rev-parse", "HEAD"]); + + git(origin, ["checkout", "-b", "feature"]); + await NodeFSP.writeFile(NodePath.join(origin, "c.txt"), "c\n"); + git(origin, ["add", "."]); + git(origin, ["commit", "-m", "C"]); + const shaOffMain = git(origin, ["rev-parse", "HEAD"]); + git(origin, ["checkout", "main"]); + + return { root, origin, work, shaA, shaB, shaOffMain }; +} + +const cleanup = (root: string) => NodeFSP.rm(root, { recursive: true, force: true }); + +it.layer(NodeServices.layer)("release-source", (it) => { + it.effect("checks out the requested older SHA even after fetching newer main", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const selected = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaA, + mainRef: "main", + }); + + assert.equal(selected.sha, fixture.shaA); + assert.equal(selected.headSha, fixture.shaA); + // The real checkout is the requested source, not main's tip. + assert.equal(git(fixture.work, ["rev-parse", "HEAD"]), fixture.shaA); + assert.notEqual(git(fixture.work, ["rev-parse", "HEAD"]), fixture.shaB); + // Ancestry is verified separately and passes for an on-main commit. + git(fixture.work, ["merge-base", "--is-ancestor", fixture.shaA, "origin/main"]); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it.effect("demonstrates the old FETCH_HEAD sequence selected main, then fixes it", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + // Reproduce the previous sequence: fetch the requested SHA, fetch main, + // then check out FETCH_HEAD. The second fetch wins, so HEAD is main. + git(fixture.work, ["init", "-b", "main"]); + git(fixture.work, ["remote", "add", "origin", fixture.origin]); + git(fixture.work, ["fetch", "--no-tags", "--depth=1", "origin", fixture.shaA]); + git(fixture.work, ["fetch", "--no-tags", "origin", "main"]); + git(fixture.work, ["checkout", "--detach", "FETCH_HEAD"]); + assert.equal(git(fixture.work, ["rev-parse", "HEAD"]), fixture.shaB); + + // The repaired selector must end on the requested source. + const selected = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaA, + mainRef: "main", + }); + assert.equal(selected.headSha, fixture.shaA); + assert.equal(git(fixture.work, ["rev-parse", "HEAD"]), fixture.shaA); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it.effect("accepts a SHA that is the tip of main", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const selected = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaB, + mainRef: "main", + }); + assert.equal(selected.headSha, fixture.shaB); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it.effect("rejects a commit that is not an ancestor of main", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const error = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaOffMain, + mainRef: "main", + }).pipe(Effect.flip); + assert.isTrue(isSourceNotOnMain(error)); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it.effect("rejects a SHA that is not a full 40-character commit", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const error = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: "bcc1a58", + mainRef: "main", + }).pipe(Effect.flip); + assert.isTrue(isInvalidSourceSha(error)); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it("normalizes only full SHAs", () => { + assert.equal( + normalizeSourceSha(" BCC1A58B19A9D610A4F08FED191A364767BC65B3 "), + "bcc1a58b19a9d610a4f08fed191a364767bc65b3", + ); + assert.equal(normalizeSourceSha("bcc1a58"), undefined); + assert.equal(normalizeSourceSha("not-a-sha"), undefined); + assert.equal(normalizeSourceSha(undefined), undefined); + }); +}); diff --git a/scripts/lib/release-source.ts b/scripts/lib/release-source.ts new file mode 100644 index 000000000000..6b5f4cb544ed --- /dev/null +++ b/scripts/lib/release-source.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * Exact source selection for a fork release. + * + * A release must build the commit the operator named, not "whatever `FETCH_HEAD` + * happens to point at" after a second fetch. The previous sequence fetched the + * requested SHA, then fetched `main`, then checked out `FETCH_HEAD`: the second + * fetch overwrote `FETCH_HEAD`, so the build silently ran on `main` instead of + * the selected (possibly older) SHA. + * + * This module validates the requested full SHA, fetches the refs it needs, + * checks out that explicit SHA, asserts the actual `HEAD` equals it, and checks + * ancestry against `origin/
` separately. Ancestry is a policy check (the + * SHA must already be on `main`); it is never the checkout target. + */ +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export const FULL_SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/; +export const DEFAULT_MAIN_REF = "main"; + +export class InvalidSourceShaError extends Schema.TaggedError()( + "InvalidSourceShaError", + { sha: Schema.String }, +) { + override get message(): string { + return `Release source SHA '${this.sha}' is not a full 40-character hex commit.`; + } +} + +export class SourceCheckoutMismatchError extends Schema.TaggedError()( + "SourceCheckoutMismatchError", + { requested: Schema.String, actual: Schema.String }, +) { + override get message(): string { + return `Checked out HEAD ${this.actual} does not match the requested source ${this.requested}.`; + } +} + +export class SourceNotOnMainError extends Schema.TaggedError()( + "SourceNotOnMainError", + { sha: Schema.String, mainRef: Schema.String }, +) { + override get message(): string { + return `Release source ${this.sha} is not an ancestor of origin/${this.mainRef}.`; + } +} + +export class GitCommandError extends Schema.TaggedError()("GitCommandError", { + args: Schema.Array(Schema.String), + exitCode: Schema.Number, + stderr: Schema.String, +}) { + override get message(): string { + return `git ${this.args.join(" ")} exited ${this.exitCode}: ${this.stderr.trim()}`; + } +} + +/** Accepts only a full 40-character hex SHA; everything else is `undefined`. */ +export function normalizeSourceSha(value: string | undefined): string | undefined { + const trimmed = value?.trim().toLowerCase(); + return trimmed !== undefined && FULL_SOURCE_SHA_PATTERN.test(trimmed) ? trimmed : undefined; +} + +export interface GitResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +const collectStream = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runGit = Effect.fn("runGit")(function* (cwd: string, args: readonly string[]) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make("git", [...args], { cwd, stdin: "ignore" })); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStream(child.stdout), + collectStream(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.orElseSucceed((): readonly [string, string, number] => ["", "", 1])); + return { stdout, stderr, exitCode } satisfies GitResult; +}); + +const runGitChecked = Effect.fn("runGitChecked")(function* (cwd: string, args: readonly string[]) { + const result = yield* runGit(cwd, args); + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + args: [...args], + exitCode: result.exitCode, + stderr: result.stderr, + }); + } + return result; +}); + +export interface ReleaseSourceSelection { + readonly sha: string; + readonly mainRef: string; + readonly headSha: string; + readonly repository: string; +} + +export interface SelectReleaseSourceInput { + readonly cwd: string; + readonly repoUrl: string; + readonly sha: string; + readonly mainRef?: string; + /** `owner/repo`, recorded for provenance output. Defaults to the URL's path. */ + readonly repository?: string; +} + +const repositoryFromUrl = (url: string): string => + url + .replace(/^https?:\/\/[^/]+\//, "") + .replace(/^ssh:\/\/[^/]+\//, "") + .replace(/^git@[^:]+:/, "") + .replace(/\.git$/, ""); + +/** + * Fetches and checks out exactly `input.sha`, then asserts the checkout and the + * ancestry. The explicit SHA is the only checkout target, so a later fetch of + * `main` cannot change what was selected. + */ +export const selectReleaseSource = Effect.fn("selectReleaseSource")(function* ( + input: SelectReleaseSourceInput, +) { + const sha = normalizeSourceSha(input.sha); + if (sha === undefined) { + return yield* new InvalidSourceShaError({ sha: input.sha }); + } + const mainRef = input.mainRef?.trim() || DEFAULT_MAIN_REF; + + yield* runGit(input.cwd, ["init", "."]); + const add = yield* runGit(input.cwd, ["remote", "add", "origin", input.repoUrl]); + if (add.exitCode !== 0) { + yield* runGitChecked(input.cwd, ["remote", "set-url", "origin", input.repoUrl]); + } + + yield* runGitChecked(input.cwd, ["fetch", "--no-tags", "--depth=1", "origin", sha]); + yield* runGitChecked(input.cwd, ["fetch", "--no-tags", "origin", mainRef]); + yield* runGitChecked(input.cwd, ["sparse-checkout", "set", "--no-cone", "/*", "!/.repos/"]); + + // Check out the validated SHA itself, never `FETCH_HEAD`. + yield* runGitChecked(input.cwd, ["checkout", "--detach", sha]); + + const head = yield* runGitChecked(input.cwd, ["rev-parse", "HEAD"]); + const headSha = head.stdout.trim().toLowerCase(); + if (headSha !== sha) { + return yield* new SourceCheckoutMismatchError({ requested: sha, actual: headSha }); + } + + const ancestor = yield* runGit(input.cwd, [ + "merge-base", + "--is-ancestor", + sha, + `origin/${mainRef}`, + ]); + if (ancestor.exitCode !== 0) { + return yield* new SourceNotOnMainError({ sha, mainRef }); + } + + return { + sha, + mainRef, + headSha, + repository: input.repository?.trim() || repositoryFromUrl(input.repoUrl), + } satisfies ReleaseSourceSelection; +}); diff --git a/scripts/lib/source-provenance.test.ts b/scripts/lib/source-provenance.test.ts index e4fbf35acbbc..5474ea2b4c0e 100644 --- a/scripts/lib/source-provenance.test.ts +++ b/scripts/lib/source-provenance.test.ts @@ -2,20 +2,29 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { BUILD_INFO_FILE_NAME, + SourceShaMismatchError, + UnknownSourceShaError, createBuildInfo, parseBuildInfo, parseSourceRepository, readGitSourceProvenance, resolveBuildChannel, + resolveBuildSourceSha, + resolveBuildSourceShaFromEnv, resolveSourceRepository, resolveSourceSha, serializeBuildInfo, } from "./source-provenance.ts"; const FULL_SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; +const DISPATCH_SHA = "89369420870a3086051fb01462193c805ecc2aaa"; + +const isMismatch = Schema.is(SourceShaMismatchError); +const isUnknown = Schema.is(UnknownSourceShaError); it("names the readable provenance file", () => { assert.equal(BUILD_INFO_FILE_NAME, "t3code-build-info.json"); @@ -65,6 +74,91 @@ it("prefers an explicit repository and SHA over GitHub Actions and the fork defa assert.equal(resolveSourceSha({}, undefined), "unknown"); }); +it("binds a release build to the actual checkout, not the dispatch SHA", () => { + // Dispatch SHA B, source A: provenance is A, and B is recorded separately. + const resolution = resolveBuildSourceSha({ + explicitSha: FULL_SHA, + gitHead: FULL_SHA, + workflowSha: DISPATCH_SHA, + releaseMode: true, + }); + assert.isFalse(isMismatch(resolution)); + assert.isFalse(isUnknown(resolution)); + if (isMismatch(resolution) || isUnknown(resolution)) { + return; + } + assert.equal(resolution.sourceSha, FULL_SHA); + assert.equal(resolution.workflowRevision, DISPATCH_SHA); + + // The same holds when only the checkout identifies the source. + const fromCheckout = resolveBuildSourceSha({ + gitHead: FULL_SHA, + workflowSha: DISPATCH_SHA, + releaseMode: true, + }); + if (isMismatch(fromCheckout) || isUnknown(fromCheckout)) { + assert.fail("expected a resolved source"); + } + assert.equal(fromCheckout.sourceSha, FULL_SHA); + assert.equal(fromCheckout.workflowRevision, DISPATCH_SHA); + + // The workflow SHA is never the source, even when the checkout is unknown. + const noCheckout = resolveBuildSourceSha({ + workflowSha: DISPATCH_SHA, + releaseMode: true, + }); + assert.isTrue(isUnknown(noCheckout)); +}); + +it("rejects a release build whose declared SHA disagrees with the checkout", () => { + const resolution = resolveBuildSourceSha({ + explicitSha: DISPATCH_SHA, + gitHead: FULL_SHA, + workflowSha: DISPATCH_SHA, + releaseMode: true, + }); + assert.isTrue(isMismatch(resolution)); +}); + +it.effect("resolves release provenance end to end through the environment", () => + Effect.gen(function* () { + const resolution = yield* resolveBuildSourceShaFromEnv( + { + T3CODE_RELEASE_BUILD: "1", + T3CODE_SOURCE_SHA: FULL_SHA, + GITHUB_SHA: DISPATCH_SHA, + }, + FULL_SHA, + ); + assert.equal(resolution.sourceSha, FULL_SHA); + assert.equal(resolution.workflowRevision, DISPATCH_SHA); + + const mismatch = yield* resolveBuildSourceShaFromEnv( + { + T3CODE_RELEASE_BUILD: "1", + T3CODE_SOURCE_SHA: DISPATCH_SHA, + GITHUB_SHA: DISPATCH_SHA, + }, + FULL_SHA, + ).pipe(Effect.flip); + assert.isTrue(isMismatch(mismatch)); + }), +); + +it("keeps the historical precedence outside release mode", () => { + const resolution = resolveBuildSourceSha({ + explicitSha: FULL_SHA, + gitHead: DISPATCH_SHA, + workflowSha: DISPATCH_SHA, + releaseMode: false, + }); + if (isMismatch(resolution) || isUnknown(resolution)) { + assert.fail("expected a resolved source"); + } + assert.equal(resolution.sourceSha, FULL_SHA); + assert.equal(resolution.workflowRevision, DISPATCH_SHA); +}); + it("labels fork releases by channel and rejects non-stable labels for a plain version", () => { assert.equal(resolveBuildChannel("0.0.43"), "stable"); assert.equal(resolveBuildChannel("0.0.43-nightly.20260923.1"), "nightly"); @@ -79,12 +173,14 @@ it.effect("serializes provenance that round-trips and carries every required fie arch: "x64", repository: "nullStack65/t3code", sourceSha: FULL_SHA, + workflowRevision: DISPATCH_SHA, }); const parsed = parseBuildInfo(yield* serializeBuildInfo(info)); assert.deepStrictEqual(parsed, { schemaVersion: 1, repository: "nullStack65/t3code", sourceSha: FULL_SHA, + workflowRevision: DISPATCH_SHA, version: "0.0.43", platform: "win", arch: "x64", @@ -98,9 +194,11 @@ it.effect("falls back to unknown rather than omitting fields", () => const info = createBuildInfo({ version: "0.0.43", platform: "mac", arch: "x64" }); assert.equal(info.repository, "unknown"); assert.equal(info.sourceSha, "unknown"); + assert.equal(info.workflowRevision, "unknown"); const parsed = parseBuildInfo(yield* serializeBuildInfo(info)); assert.equal(parsed.repository, "unknown"); assert.equal(parsed.sourceSha, "unknown"); + assert.equal(parsed.workflowRevision, "unknown"); }), ); diff --git a/scripts/lib/source-provenance.ts b/scripts/lib/source-provenance.ts index e2f88b564697..2d993191b3a7 100644 --- a/scripts/lib/source-provenance.ts +++ b/scripts/lib/source-provenance.ts @@ -12,6 +12,14 @@ * `T3CODE_SOURCE_REPOSITORY`/`T3CODE_SOURCE_SHA`, then the GitHub Actions * variables, then the local git checkout, then `unknown`. The full 40-character * SHA is preferred; a short SHA is accepted from git only as a fallback. + * + * In a release build (`T3CODE_RELEASE_BUILD=1`) the rules tighten: the source + * SHA must be the full SHA of the actual checkout, an explicit `T3CODE_SOURCE_SHA` + * that disagrees with the checkout is a hard failure, and `GITHUB_SHA` (the + * workflow-dispatch revision) is never used as source provenance — it is + * recorded separately as `workflowRevision`. A manually dispatched workflow can + * therefore build an older selected SHA without labelling the payload with the + * dispatch commit. */ import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; @@ -24,6 +32,8 @@ import { CLI_RELEASE_REPOSITORY } from "@t3tools/shared/cliRelease"; export const BUILD_INFO_FILE_NAME = "t3code-build-info.json"; export const SOURCE_REPOSITORY_ENV = "T3CODE_SOURCE_REPOSITORY"; export const SOURCE_SHA_ENV = "T3CODE_SOURCE_SHA"; +export const WORKFLOW_SHA_ENV = "GITHUB_SHA"; +export const RELEASE_BUILD_ENV = "T3CODE_RELEASE_BUILD"; export const UNKNOWN_PROVENANCE = "unknown"; const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i; @@ -34,6 +44,7 @@ export interface BuildInfo { readonly schemaVersion: 1; readonly repository: string; readonly sourceSha: string; + readonly workflowRevision: string; readonly version: string; readonly platform: string; readonly arch: string; @@ -46,6 +57,7 @@ export interface BuildInfoInput { readonly arch: string; readonly repository?: string | undefined; readonly sourceSha?: string | undefined; + readonly workflowRevision?: string | undefined; } /** The release train a fork version belongs to. Fork releases are plain stable. */ @@ -86,7 +98,7 @@ export function resolveSourceSha( env: Readonly>, gitSha?: string | undefined, ): string { - for (const candidate of [env[SOURCE_SHA_ENV], env.GITHUB_SHA, gitSha]) { + for (const candidate of [env[SOURCE_SHA_ENV], env[WORKFLOW_SHA_ENV], gitSha]) { const trimmed = candidate?.trim(); if (trimmed !== undefined && SHORT_SHA_PATTERN.test(trimmed)) { return trimmed.toLowerCase(); @@ -95,11 +107,116 @@ export function resolveSourceSha( return UNKNOWN_PROVENANCE; } +export class SourceShaMismatchError extends Schema.TaggedError()( + "SourceShaMismatchError", + { declared: Schema.String, checkedOut: Schema.String }, +) { + override get message(): string { + return `Declared source SHA ${this.declared} does not match the checked-out HEAD ${this.checkedOut}.`; + } +} + +export class UnknownSourceShaError extends Schema.TaggedError()( + "UnknownSourceShaError", + {}, +) { + override get message(): string { + return "A release build requires a full source SHA, but neither T3CODE_SOURCE_SHA nor the git checkout provided one."; + } +} + +const isSourceShaMismatchError = Schema.is(SourceShaMismatchError); +const isUnknownSourceShaError = Schema.is(UnknownSourceShaError); + +export function isReleaseBuild(env: Readonly>): boolean { + const value = env[RELEASE_BUILD_ENV]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +const fullSha = (value: string | undefined): string | undefined => { + const trimmed = value?.trim().toLowerCase(); + return trimmed !== undefined && FULL_SHA_PATTERN.test(trimmed) ? trimmed : undefined; +}; + +export interface SourceShaResolution { + readonly sourceSha: string; + /** `GITHUB_SHA` when it differs from the source; never the source itself. */ + readonly workflowRevision: string | undefined; +} + +export interface SourceShaResolutionInput { + readonly explicitSha?: string | undefined; + readonly gitHead?: string | undefined; + readonly workflowSha?: string | undefined; + readonly releaseMode?: boolean; +} + +/** + * Resolves the source SHA that belongs in provenance. + * + * In release mode the actual source checkout is authoritative. An explicit + * `T3CODE_SOURCE_SHA` that disagrees with the checkout is rejected instead of + * silently winning, and `GITHUB_SHA` is only ever a separately recorded + * workflow revision. Outside release mode the historical precedence is kept + * (explicit, then GitHub Actions, then git). + */ +export function resolveBuildSourceSha( + input: SourceShaResolutionInput, +): SourceShaResolution | SourceShaMismatchError | UnknownSourceShaError { + const explicit = fullSha(input.explicitSha); + const gitHead = fullSha(input.gitHead); + const workflow = fullSha(input.workflowSha); + + if (input.releaseMode === true) { + if (explicit !== undefined && gitHead !== undefined && explicit !== gitHead) { + return new SourceShaMismatchError({ declared: explicit, checkedOut: gitHead }); + } + const sourceSha = explicit ?? gitHead; + if (sourceSha === undefined) { + return new UnknownSourceShaError({}); + } + return { + sourceSha, + workflowRevision: workflow !== undefined && workflow !== sourceSha ? workflow : undefined, + }; + } + + const sourceSha = resolveSourceSha( + { + [SOURCE_SHA_ENV]: input.explicitSha, + [WORKFLOW_SHA_ENV]: input.workflowSha, + }, + input.gitHead, + ); + return { + sourceSha, + workflowRevision: workflow !== undefined && workflow !== sourceSha ? workflow : undefined, + }; +} + +/** Resolves provenance from the environment, failing closed in release mode. */ +export const resolveBuildSourceShaFromEnv = Effect.fn("resolveBuildSourceShaFromEnv")(function* ( + env: Readonly>, + gitHead?: string | undefined, +) { + const resolution = resolveBuildSourceSha({ + explicitSha: env[SOURCE_SHA_ENV], + gitHead, + workflowSha: env[WORKFLOW_SHA_ENV], + releaseMode: isReleaseBuild(env), + }); + if (isSourceShaMismatchError(resolution) || isUnknownSourceShaError(resolution)) { + return yield* resolution; + } + return resolution; +}); + export function createBuildInfo(input: BuildInfoInput): BuildInfo { return { schemaVersion: 1, repository: input.repository ?? UNKNOWN_PROVENANCE, sourceSha: input.sourceSha ?? UNKNOWN_PROVENANCE, + workflowRevision: input.workflowRevision ?? UNKNOWN_PROVENANCE, version: input.version, platform: input.platform, arch: input.arch, @@ -111,6 +228,7 @@ const BuildInfoSchema = Schema.Struct({ schemaVersion: Schema.Literal(1), repository: Schema.String, sourceSha: Schema.String, + workflowRevision: Schema.String, version: Schema.String, platform: Schema.String, arch: Schema.String, diff --git a/scripts/lib/wsl-payload.test.ts b/scripts/lib/wsl-payload.test.ts new file mode 100644 index 000000000000..0156db679d01 --- /dev/null +++ b/scripts/lib/wsl-payload.test.ts @@ -0,0 +1,105 @@ +import { assert, it } from "@effect/vitest"; + +import { verifyEmbeddedWslRuntime, type EmbeddedBuildInfo } from "./wsl-payload.ts"; + +const SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; +const VERSION = "0.0.43"; +const expected = { + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + arch: "x64", +}; + +const info: EmbeddedBuildInfo = { + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + platform: "linux", + arch: "x64", +}; + +const bytes = new TextEncoder().encode("runtime-archive-bytes"); + +it("accepts an embedded runtime identical to the standalone archive", () => { + const result = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: info, + standaloneInfo: info, + expected, + }); + assert.deepEqual(result, { ok: true, failures: [] }); +}); + +it("rejects an embedded runtime that is not byte-identical to the standalone archive", () => { + const result = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: new TextEncoder().encode("a different archive"), + embeddedInfo: info, + standaloneInfo: info, + expected, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /does not equal the standalone Linux archive/); +}); + +it("rejects wrong-source, wrong-arch, and wrong-version embedded provenance", () => { + const wrongSource = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: { ...info, sourceSha: "a".repeat(40) }, + standaloneInfo: info, + expected, + }); + assert.match(wrongSource.failures.join("\n"), /sourceSha/); + + const wrongArch = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: { ...info, arch: "arm64" }, + standaloneInfo: info, + expected, + }); + assert.match(wrongArch.failures.join("\n"), /arch/); + + const wrongVersion = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: { ...info, version: "0.0.42" }, + standaloneInfo: info, + expected, + }); + assert.match(wrongVersion.failures.join("\n"), /version/); +}); + +it("rejects a missing embedded archive or provenance file", () => { + const missingArchive = verifyEmbeddedWslRuntime({ + embeddedArchive: undefined, + standaloneArchive: bytes, + embeddedInfo: info, + standaloneInfo: info, + expected, + }); + assert.match(missingArchive.failures.join("\n"), /no embedded WSL runtime archive/); + + const missingInfo = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: undefined, + standaloneInfo: info, + expected, + }); + assert.match(missingInfo.failures.join("\n"), /no t3code-build-info\.json/); +}); + +it("rejects provenance that differs from the standalone archive", () => { + const result = verifyEmbeddedWslRuntime({ + embeddedArchive: bytes, + standaloneArchive: bytes, + embeddedInfo: { ...info, repository: "someone/else" }, + standaloneInfo: info, + expected, + }); + assert.match(result.failures.join("\n"), /provenance differs/); +}); diff --git a/scripts/lib/wsl-payload.ts b/scripts/lib/wsl-payload.ts new file mode 100644 index 000000000000..c80579209354 --- /dev/null +++ b/scripts/lib/wsl-payload.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Verifies the WSL runtime that is actually embedded in a Windows installer. + * + * The packaging step already fail-closes on a missing/mismatched archive and + * SHA sidecar, but that only proves *an* archive was embedded. A candidate must + * also prove the embedded payload is the standalone Linux archive the release + * publishes and that its provenance names the same source, version, and + * architecture. This module holds the pure comparison; the CLI extracts the + * payload with 7-Zip and calls it. + */ +import { sha256Hex, type VerificationResult } from "./fork-release-manifest.ts"; + +export interface EmbeddedBuildInfo { + readonly repository: string; + readonly sourceSha: string; + readonly version: string; + readonly platform: string; + readonly arch: string; +} + +export interface VerifyEmbeddedWslRuntimeInput { + readonly embeddedArchive: Uint8Array | undefined; + readonly standaloneArchive: Uint8Array | undefined; + readonly embeddedInfo: EmbeddedBuildInfo | undefined; + readonly standaloneInfo: EmbeddedBuildInfo | undefined; + readonly expected: { + readonly repository: string; + readonly sourceSha: string; + readonly version: string; + readonly arch: string; + }; +} + +const problems = (list: ReadonlyArray): VerificationResult => + list.length === 0 ? { ok: true, failures: [] } : { ok: false, failures: list }; + +/** + * Byte-identity plus provenance equality between the Windows installer's + * embedded `wsl-runtime.tar.gz` and the standalone Linux x64 archive. + */ +export function verifyEmbeddedWslRuntime(input: VerifyEmbeddedWslRuntimeInput): VerificationResult { + const failures: string[] = []; + const { expected } = input; + + if (input.embeddedArchive === undefined) { + failures.push("the Windows installer has no embedded WSL runtime archive"); + } + if (input.standaloneArchive === undefined) { + failures.push("the standalone Linux x64 archive is missing"); + } + if (input.embeddedArchive !== undefined && input.standaloneArchive !== undefined) { + const embedded = sha256Hex(input.embeddedArchive); + const standalone = sha256Hex(input.standaloneArchive); + if (embedded !== standalone) { + failures.push( + `embedded WSL runtime sha256 ${embedded} does not equal the standalone Linux archive ${standalone}`, + ); + } + } + + const info = input.embeddedInfo; + if (info === undefined) { + failures.push("the embedded WSL runtime has no t3code-build-info.json"); + } else { + if (info.repository !== expected.repository) { + failures.push( + `embedded WSL runtime repository is ${info.repository}, expected ${expected.repository}`, + ); + } + if (info.sourceSha !== expected.sourceSha) { + failures.push( + `embedded WSL runtime sourceSha is ${info.sourceSha}, expected ${expected.sourceSha}`, + ); + } + if (info.version !== expected.version) { + failures.push( + `embedded WSL runtime version is ${info.version}, expected ${expected.version}`, + ); + } + if (info.platform !== "linux") { + failures.push(`embedded WSL runtime platform is ${info.platform}, expected linux`); + } + if (info.arch !== expected.arch) { + failures.push(`embedded WSL runtime arch is ${info.arch}, expected ${expected.arch}`); + } + } + + if (input.embeddedInfo !== undefined && input.standaloneInfo !== undefined) { + if (JSON.stringify(input.embeddedInfo) !== JSON.stringify(input.standaloneInfo)) { + failures.push( + "embedded WSL runtime provenance differs from the standalone archive provenance", + ); + } + } + + return problems(failures); +} diff --git a/scripts/select-release-source.ts b/scripts/select-release-source.ts new file mode 100644 index 000000000000..f2010a307e91 --- /dev/null +++ b/scripts/select-release-source.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Selects and verifies the exact source commit a fork release builds. + * + * Run from the release workspace after the bootstrap fetch. It re-runs the + * selection authoritatively: fetch the requested SHA and `main`, check out the + * explicit SHA, assert `HEAD` equals it, and assert the SHA is an ancestor of + * `origin/main`. Writes `sha`, `head_sha`, and `workflow_sha` to `GITHUB_OUTPUT` + * when available so a later job can bind provenance to the actual checkout. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Console from "effect/Console"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import { Command, Flag } from "effect/unstable/cli"; + +import { selectReleaseSource } from "./lib/release-source.ts"; + +const command = Command.make( + "select-release-source", + { + repoUrl: Flag.String("repo-url").pipe( + Flag.withDescription("Git remote URL to fetch the release source from."), + ), + sha: Flag.String("sha").pipe( + Flag.withDescription("Full 40-character source commit to check out."), + ), + mainRef: Flag.String("main-ref").pipe( + Flag.withDescription("Branch the source must be an ancestor of."), + Flag.withDefault("main"), + ), + cwd: Flag.String("cwd").pipe( + Flag.withDescription("Workspace directory to run git in."), + Flag.optional, + ), + githubOutput: Flag.Boolean("github-output").pipe( + Flag.withDescription("Append sha/head_sha/workflow_sha to GITHUB_OUTPUT."), + Flag.withDefault(false), + ), + }, + ({ repoUrl, sha, mainRef, cwd, githubOutput }) => + Effect.gen(function* () { + const selected = yield* selectReleaseSource({ + cwd: Option.getOrElse(cwd, () => process.cwd()), + repoUrl, + sha, + mainRef, + }); + const workflowSha = process.env.GITHUB_SHA?.trim() ?? ""; + yield* Console.log( + `Selected source ${selected.sha} (HEAD ${selected.headSha}, ancestor of origin/${selected.mainRef}, repo ${selected.repository})`, + ); + if (workflowSha !== "" && workflowSha.toLowerCase() !== selected.sha) { + yield* Console.log( + `Workflow revision ${workflowSha} differs from the selected source; it is recorded separately, not as source provenance.`, + ); + } + if (githubOutput) { + const outputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT"); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + outputPath, + [ + `sha=${selected.sha}`, + `head_sha=${selected.headSha}`, + `workflow_sha=${workflowSha}`, + "", + ].join("\n"), + { flag: "a" }, + ); + } + }), +).pipe(Command.withDescription("Check out and verify the exact release source commit.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/verify-fork-candidate.ts b/scripts/verify-fork-candidate.ts new file mode 100644 index 000000000000..db75244db8d7 --- /dev/null +++ b/scripts/verify-fork-candidate.ts @@ -0,0 +1,273 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off - A self-contained CI verification utility over plain files. +/** + * Verifies (and optionally freezes) a fork release candidate. + * + * Two jobs share this tool so the candidate bytes are checked the same way in + * both: + * - `qualify` freezes the candidate: write the manifest, write SHA256SUMS + * from the observed bytes, then verify everything agrees. + * - `publish` re-verifies the *downloaded* artifact and the promotion-level + * rules (tag target, no overwrite, version ordering, authorization gate) + * before creating a release. + * + * It never builds or mutates an asset; it only reads bytes and writes the + * manifest/checksum metadata beside them. + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + CANDIDATE_MANIFEST_FILE_NAME, + NATIVE_RECEIPTS_FILE_NAME, + NATIVE_RECEIPTS_SCHEMA_VERSION, + RELEASE_ENVIRONMENT, + SHA256SUMS_FILE_NAME, + compareStableVersions, + renderChecksums, + requiredReleaseAssetNames, + sha256Hex, + verifyCandidate, + verifyPromotion, + type NativeReceipt, + type ReleaseAsset, + type ReleaseCandidateManifest, +} from "./lib/fork-release-manifest.ts"; + +interface Args { + candidateDir: string; + version: string; + sha: string; + repository: string; + includeMacosArm64: boolean; + requireNativeReceipts: boolean; + writeManifest: boolean; + writeChecksums: boolean; + channel: string; + runId: string; + runAttempt: string; + promote: boolean; + tagTarget: string | undefined; + releaseExists: boolean; + tagExists: boolean; + latestVersion: string | undefined; + authorizationGateExists: boolean; + nativeReceiptsPath: string | undefined; +} + +function parseArgs(argv: ReadonlyArray): Args { + const values = new Map(); + const flags = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + values.set(key, next); + index += 1; + } else { + flags.add(key); + } + } + const bool = (key: string): boolean => { + const value = values.get(key)?.trim().toLowerCase(); + return flags.has(key) || value === "true" || value === "1"; + }; + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.trim() === "") { + throw new Error(`--${key} is required`); + } + return value.trim(); + }; + return { + candidateDir: required("candidate-dir"), + version: required("version"), + sha: required("sha").toLowerCase(), + repository: required("repository"), + includeMacosArm64: bool("include-macos-arm64"), + requireNativeReceipts: bool("require-native-receipts"), + writeManifest: bool("write-manifest"), + writeChecksums: bool("write-checksums"), + channel: values.get("channel")?.trim() || "stable", + runId: values.get("run-id")?.trim() || "local", + runAttempt: values.get("run-attempt")?.trim() || "1", + promote: bool("promote"), + tagTarget: values.get("tag-target")?.trim().toLowerCase(), + releaseExists: bool("release-exists"), + tagExists: bool("tag-exists"), + latestVersion: values.get("latest-version")?.trim(), + authorizationGateExists: bool("authorization-gate-exists"), + nativeReceiptsPath: values.get("native-receipts")?.trim(), + }; +} + +const META_FILES = new Set([ + CANDIDATE_MANIFEST_FILE_NAME, + NATIVE_RECEIPTS_FILE_NAME, + SHA256SUMS_FILE_NAME, +]); + +function listAssetFiles(dir: string): string[] { + return NodeFS.readdirSync(dir) + .filter((name) => !META_FILES.has(name)) + .filter((name) => NodeFS.statSync(NodePath.join(dir, name)).isFile()) + .sort(); +} + +function observeAssets(dir: string): ReleaseAsset[] { + return listAssetFiles(dir).map((name) => { + const bytes = NodeFS.readFileSync(NodePath.join(dir, name)); + return { name, sha256: sha256Hex(bytes), size: bytes.byteLength }; + }); +} + +function readNativeReceiptsFile(path: string): NativeReceipt[] { + const parsed: unknown = JSON.parse(NodeFS.readFileSync(path, "utf8")); + const list = Array.isArray(parsed) ? parsed : (parsed as { receipts?: unknown }).receipts; + if (!Array.isArray(list)) { + throw new Error(`${path} must be an array or { receipts: [] }`); + } + return list.map( + (entry) => + ({ schemaVersion: NATIVE_RECEIPTS_SCHEMA_VERSION, ...(entry as object) }) as NativeReceipt, + ); +} + +function readNativeReceipts(dir: string): NativeReceipt[] { + const path = NodePath.join(dir, NATIVE_RECEIPTS_FILE_NAME); + return NodeFS.existsSync(path) ? readNativeReceiptsFile(path) : []; +} + +function fail(problems: ReadonlyArray): never { + for (const problem of problems) { + console.error(`::error::${problem}`); + } + process.exit(1); +} + +/** Highest plain `X.Y.Z` in a comma-separated list, or undefined when none. */ +function highestStableVersion(list: string | undefined): string | undefined { + const versions = (list ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => /^\d+\.\d+\.\d+$/.test(entry)); + return versions.reduce( + (highest, candidate) => + highest === undefined || compareStableVersions(candidate, highest) > 0 ? candidate : highest, + undefined, + ); +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const observedAssets = observeAssets(args.candidateDir); + const manifestPath = NodePath.join(args.candidateDir, CANDIDATE_MANIFEST_FILE_NAME); + + if (args.writeManifest) { + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + repository: args.repository, + version: args.version, + sourceSha: args.sha, + workflowRevision: process.env.GITHUB_SHA?.trim() ?? "unknown", + workflowRunId: process.env.GITHUB_RUN_ID?.trim() ?? args.runId, + workflowRunAttempt: process.env.GITHUB_RUN_ATTEMPT?.trim() ?? args.runAttempt, + channel: args.channel, + createdAt: new Date().toISOString(), + assets: observedAssets, + nativeReceipts: readNativeReceipts(args.candidateDir), + }; + NodeFS.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`Wrote ${CANDIDATE_MANIFEST_FILE_NAME} with ${manifest.assets.length} assets.`); + } + + if (args.writeChecksums) { + NodeFS.writeFileSync( + NodePath.join(args.candidateDir, SHA256SUMS_FILE_NAME), + renderChecksums(observedAssets), + ); + console.log(`Wrote ${SHA256SUMS_FILE_NAME} from ${observedAssets.length} assets.`); + } + + if (!NodeFS.existsSync(manifestPath)) { + fail([`candidate is missing ${CANDIDATE_MANIFEST_FILE_NAME}`]); + } + const parsedManifest = JSON.parse( + NodeFS.readFileSync(manifestPath, "utf8"), + ) as ReleaseCandidateManifest; + const manifest = + args.nativeReceiptsPath === undefined + ? parsedManifest + : { + ...parsedManifest, + nativeReceipts: readNativeReceiptsFile(args.nativeReceiptsPath), + }; + + const expected = { + repository: args.repository, + version: args.version, + sourceSha: args.sha, + }; + + const checksumPath = NodePath.join(args.candidateDir, SHA256SUMS_FILE_NAME); + if (NodeFS.existsSync(checksumPath)) { + const recorded = new Map( + NodeFS.readFileSync(checksumPath, "utf8") + .split(/\r?\n/) + .map((line) => /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => [match[2]!, match[1]!.toLowerCase()] as const), + ); + const mismatches = observedAssets.filter( + (asset) => recorded.get(asset.name) !== asset.sha256.toLowerCase(), + ); + if (mismatches.length > 0) { + fail( + mismatches.map( + (asset) => + `${SHA256SUMS_FILE_NAME} disagrees with ${asset.name} (recorded ${recorded.get(asset.name) ?? "missing"})`, + ), + ); + } + } + + const result = args.promote + ? verifyPromotion({ + manifest, + expected, + observedAssets, + includeMacosArm64: args.includeMacosArm64, + requireNativeReceipts: true, + tagTargetSha: args.tagTarget ?? "", + releaseExists: args.releaseExists, + tagExists: args.tagExists, + latestExistingVersion: highestStableVersion(args.latestVersion), + authorizationGateExists: args.authorizationGateExists, + }) + : verifyCandidate({ + manifest, + expected, + observedAssets, + includeMacosArm64: args.includeMacosArm64, + requireNativeReceipts: args.requireNativeReceipts, + }); + + if (!result.ok) { + fail(result.failures); + } + console.log( + `Candidate verified: ${observedAssets.length} assets for ${args.repository} v${args.version} @ ${args.sha}.`, + ); + if (args.promote) { + console.log( + `Promotion checks passed (tag target ${args.tagTarget}, environment '${RELEASE_ENVIRONMENT}').`, + ); + } + console.log( + `Required assets: ${requiredReleaseAssetNames(args.version, { includeMacosArm64: args.includeMacosArm64 }).join(", ")}`, + ); +} + +main(); diff --git a/scripts/verify-windows-installer.ts b/scripts/verify-windows-installer.ts new file mode 100644 index 000000000000..7fc722af4e85 --- /dev/null +++ b/scripts/verify-windows-installer.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - A CI verification utility that shells out to 7-Zip and tar. +/** + * Verifies the WSL runtime embedded in a Windows installer against the + * standalone Linux x64 archive. Run on the `qualify` runner after installing + * `p7zip-full`. The pure comparison lives in `lib/wsl-payload.ts`; this file + * only extracts the bytes. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { parseBuildInfo } from "./lib/source-provenance.ts"; +import { verifyEmbeddedWslRuntime, type EmbeddedBuildInfo } from "./lib/wsl-payload.ts"; + +interface Args { + installer: string; + standaloneArchive: string; + repository: string; + sourceSha: string; + version: string; + arch: string; +} + +function parseArgs(argv: ReadonlyArray): Args { + const values = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (!token.startsWith("--")) continue; + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + values.set(token.slice(2), next); + index += 1; + } + } + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.trim() === "") throw new Error(`--${key} is required`); + return value.trim(); + }; + return { + installer: required("installer"), + standaloneArchive: required("standalone-archive"), + repository: required("repository"), + sourceSha: required("sha").toLowerCase(), + version: required("version"), + arch: values.get("arch")?.trim() || "x64", + }; +} + +const run = (command: string, args: ReadonlyArray): void => { + const result = NodeChildProcess.spawnSync(command, args, { stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited ${result.status ?? "unknown"}`); + } +}; + +function findFile(root: string, name: string): string | undefined { + const entries = NodeFS.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const full = NodePath.join(root, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found !== undefined) return found; + } else if (entry.name === name) { + return full; + } + } + return undefined; +} + +function readArchiveInfo(archive: string, scratch: string): EmbeddedBuildInfo | undefined { + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "info-")); + run("tar", ["-xzf", archive, "-C", dir]); + const infoPath = findFile(dir, "t3code-build-info.json"); + if (infoPath === undefined) return undefined; + return parseBuildInfo(NodeFS.readFileSync(infoPath, "utf8")) as EmbeddedBuildInfo; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-verify-")); + + const extractDir = NodePath.join(scratch, "installer"); + NodeFS.mkdirSync(extractDir, { recursive: true }); + run("7z", ["x", "-y", `-o${extractDir}`, args.installer]); + const embeddedPath = findFile(extractDir, "wsl-runtime.tar.gz"); + + const embeddedArchive = + embeddedPath === undefined ? undefined : NodeFS.readFileSync(embeddedPath); + const standaloneArchive = NodeFS.readFileSync(args.standaloneArchive); + + const result = verifyEmbeddedWslRuntime({ + embeddedArchive, + standaloneArchive, + embeddedInfo: embeddedPath === undefined ? undefined : readArchiveInfo(embeddedPath, scratch), + standaloneInfo: readArchiveInfo(args.standaloneArchive, scratch), + expected: { + repository: args.repository, + sourceSha: args.sourceSha, + version: args.version, + arch: args.arch, + }, + }); + + if (!result.ok) { + for (const failure of result.failures) console.error(`::error::${failure}`); + process.exit(1); + } + console.log("Embedded WSL runtime matches the standalone Linux archive and its provenance."); +} + +main(); From d1e09ad1524913fa46264ad41d3a6c9a13e8c61a Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 17:52:49 -0400 Subject: [PATCH 04/10] fix(release): executable per-target candidate route, bound receipts, gated promotion Repair the release execution path so a pre-merge PR head can be built, verified, and frozen locally, and so acceptance binds to real artifacts. Candidate source vs public eligibility: - release-source.ts gains an explicit candidate mode that accepts a fork PR SHA reachable on the resolved fork remote; public mode keeps the on-main ancestry requirement. Neither fakes ancestry. - build-fork-candidate.ts resolves the writable fork remote explicitly (--fork-remote, default fork) instead of assuming origin is the fork. Per-target vs aggregate: - candidate-build-plan.ts separates build/stage/verify-one-platform from the aggregate freeze, aligns package versions, passes --build-version and --output-dir through to desktop packaging, and builds the Windows CLI ZIP. - build-fork-candidate.ts has --phase target|aggregate and binds source/repository/mode into child-process env; completed outputs are preserved with --keep-going. - stage-candidate-asset.ts stages a target's artifacts into the shared dir. Validation that means what it claims: - fork-release-manifest.ts binds each native target to its own artifact, rejects conflicting (FAIL beside PASS) and ambiguous acceptance, and inspects real packaged provenance (tarball/zip/NSIS) via candidate-provenance-inspect.ts. - verify-windows-installer.ts runs the real NSIS install to reach the embedded WSL payload and emits its provenance. CI/promotion handoff: - fork-release.yml authorizes on owner-configured runner variables before scheduling any build, selects source before installing dependencies with a dependency-free selector, builds the SEA under the pinned Node, promotes a frozen candidate by identity digest without rebuilding, provides a real native-receipt import job, adds actions:read for cross-run downloads, and asserts the required-reviewer approval rule. Process-level regression tests spawn the real entry points. 77 focused tests pass; typecheck, lint, and fmt are clean. --- .github/workflows/fork-release.yml | 373 ++++++++++++----- docs/operations/fork-release.md | 121 ++++-- scripts/build-fork-candidate.ts | 217 ++++++++-- scripts/fork-release-entrypoints.test.ts | 439 ++++++++++++++++++++ scripts/lib/candidate-build-plan.test.ts | 47 ++- scripts/lib/candidate-build-plan.ts | 201 ++++++++- scripts/lib/candidate-provenance-inspect.ts | 214 ++++++++++ scripts/lib/fork-release-manifest.test.ts | 147 ++++++- scripts/lib/fork-release-manifest.ts | 325 ++++++++++++++- scripts/lib/fork-release-workflow.test.ts | 64 +++ scripts/lib/release-source.test.ts | 43 +- scripts/lib/release-source.ts | 73 +++- scripts/select-release-source.ts | 259 ++++++++---- scripts/stage-candidate-asset.ts | 76 ++++ scripts/verify-fork-candidate.ts | 32 +- scripts/verify-windows-installer.ts | 114 ++++- 16 files changed, 2443 insertions(+), 302 deletions(-) create mode 100644 scripts/fork-release-entrypoints.test.ts create mode 100644 scripts/lib/candidate-provenance-inspect.ts create mode 100644 scripts/stage-candidate-asset.ts diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 1b237e4f2b26..5bb437ab6093 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -35,23 +35,6 @@ on: description: "Upstream version this fork build is based on (e.g. 0.0.42)." required: true type: string - linux_runner: - description: "Authorized runner label for Linux jobs (must be in T3CODE_AUTHORIZED_RUNNERS)." - required: true - type: string - windows_runner: - description: "Authorized runner label for the Windows job (must be in T3CODE_AUTHORIZED_RUNNERS)." - required: true - type: string - macos_x64_runner: - description: "Authorized runner label for the Intel macOS job (must be in T3CODE_AUTHORIZED_RUNNERS)." - required: true - type: string - macos_arm64_runner: - description: "Authorized runner label for the optional Apple Silicon job." - required: false - default: "" - type: string publish: description: "Promote a qualified candidate. Off builds/verifies a candidate only." required: false @@ -62,12 +45,39 @@ on: required: false default: "" type: string + upload_receipts: + description: "Import native acceptance receipts for a qualified candidate." + required: false + default: false + type: boolean + receipts_source_run_id: + description: "Run id of the qualified candidate the imported receipts bind to. Required when upload_receipts is true." + required: false + default: "" + type: string + receipt_run_id: + description: "Run id holding fork-release-native-receipts to promote with. Defaults to candidate_run_id." + required: false + default: "" + type: string include_macos_arm64: description: "Also build the Apple Silicon macOS DMG (untested by default)." required: false default: false type: boolean +# Trusted runner selection. Labels come from repository variables set by the +# owner, never from a caller-supplied input, so a dispatch cannot schedule or +# execute source on an arbitrary runner. If a variable is unset the job runs on +# a deliberately unmatched label, causing a safe queue rather than an +# unauthorized run; the `authorize` job reports the exact missing variable. +# `T3CODE_AUTHORIZED_RUNNERS` must list every label below or `authorize` fails. +env: + T3CODE_LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }} + T3CODE_WINDOWS_RUNNER: ${{ vars.T3CODE_WINDOWS_RUNNER }} + T3CODE_MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }} + T3CODE_MACOS_ARM64_RUNNER: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }} + permissions: contents: read @@ -76,15 +86,58 @@ concurrency: cancel-in-progress: false jobs: + # Authorization runs FIRST, on a fixed owner-configured Linux label, before + # any build job is scheduled. Every later job `needs: [authorize]`, so a + # dispatch cannot execute source on an arbitrary caller-supplied runner. The + # labels themselves come from repository variables, not workflow inputs. + authorize: + name: Authorize runner capacity + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 5 + steps: + - name: Assert authorized runner capacity + shell: bash + env: + AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} + LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }} + WINDOWS_RUNNER: ${{ vars.T3CODE_WINDOWS_RUNNER }} + MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }} + MACOS_ARM64_RUNNER: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }} + INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} + run: | + set -euo pipefail + if [ -z "${AUTHORIZED:-}" ]; then + echo "::error::No authorized runner capacity is declared. Set the repository variable T3CODE_AUTHORIZED_RUNNERS to the comma-separated labels this fork may use, or build a candidate with scripts/build-fork-candidate.ts on an authorized machine." + exit 1 + fi + IFS=',' read -ra allowed <<< "$AUTHORIZED" + check() { + local label="$1" role="$2" candidate + [ -n "$label" ] || { echo "::error::$role runner variable (vars.T3CODE_*_RUNNER) is unset"; exit 1; } + for candidate in "${allowed[@]}"; do + candidate="$(echo "$candidate" | xargs)" + [ "$candidate" = "$label" ] && return 0 + done + echo "::error::$role runner '$label' is not declared in T3CODE_AUTHORIZED_RUNNERS" + exit 1 + } + check "$LINUX_RUNNER" Linux + check "$WINDOWS_RUNNER" Windows + check "$MACOS_X64_RUNNER" "Intel macOS" + if [ "$INCLUDE_ARM64" = "true" ]; then check "$MACOS_ARM64_RUNNER" "Apple Silicon macOS"; fi + echo "Authorized runner capacity confirmed." + preflight: name: Preflight - runs-on: ${{ inputs.linux_runner }} + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 15 outputs: version: ${{ steps.meta.outputs.version }} sha: ${{ steps.source.outputs.sha }} head_sha: ${{ steps.source.outputs.head_sha }} workflow_sha: ${{ steps.source.outputs.workflow_sha }} + source_mode: ${{ steps.source.outputs.source_mode }} channel: ${{ steps.meta.outputs.channel }} relay_url: ${{ steps.public_config.outputs.relay_url }} clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} @@ -92,8 +145,8 @@ jobs: clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} steps: # Bootstrap only enough of the selected commit to read package.json and - # the selection script. The authoritative checkout happens below, after - # dependencies are installed. + # the dependency-free selection script. This runs BEFORE `vp install`, so + # it must not import workspace/Effect packages. - name: Bootstrap the release commit shell: bash env: @@ -112,12 +165,11 @@ jobs: with: node-version-file: package.json cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + run-install: false # Fetch the requested SHA and main, check out the explicit SHA (never - # FETCH_HEAD), assert HEAD equals it, and assert ancestry separately. + # FETCH_HEAD), assert HEAD equals it, and apply the ancestry policy. + # Runs before any install; the selector uses only Node built-ins. - id: source name: Select and verify the exact source SHA shell: bash @@ -127,41 +179,11 @@ jobs: --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ --sha "${{ inputs.sha }}" \ --main-ref main \ + --mode public \ --github-output - # Fail closed unless the operator has declared this fork's authorized - # runner capacity. No hosted/paid fallback is added silently. - - name: Assert authorized runner capacity - shell: bash - env: - AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} - LINUX_RUNNER: ${{ inputs.linux_runner }} - WINDOWS_RUNNER: ${{ inputs.windows_runner }} - MACOS_X64_RUNNER: ${{ inputs.macos_x64_runner }} - MACOS_ARM64_RUNNER: ${{ inputs.macos_arm64_runner }} - INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }} - run: | - set -euo pipefail - if [ -z "${AUTHORIZED:-}" ]; then - echo "::error::No authorized runner capacity is declared. Set the repository variable T3CODE_AUTHORIZED_RUNNERS to the comma-separated labels this fork may use, or build a candidate with scripts/build-fork-candidate.ts on an authorized machine." - exit 1 - fi - IFS=',' read -ra allowed <<< "$AUTHORIZED" - check() { - local label="$1" role="$2" candidate - [ -n "$label" ] || { echo "::error::$role runner label is empty"; exit 1; } - for candidate in "${allowed[@]}"; do - candidate="$(echo "$candidate" | xargs)" - [ "$candidate" = "$label" ] && return 0 - done - echo "::error::$role runner '$label' is not declared in T3CODE_AUTHORIZED_RUNNERS" - exit 1 - } - check "$LINUX_RUNNER" Linux - check "$WINDOWS_RUNNER" Windows - check "$MACOS_X64_RUNNER" "Intel macOS" - if [ "$INCLUDE_ARM64" = "true" ]; then check "$MACOS_ARM64_RUNNER" "Apple Silicon macOS"; fi - echo "Authorized runner capacity confirmed." + - name: Install release dependencies + run: vp install --filter=@t3tools/scripts... # Validate the version against the fork's own version line: plain X.Y.Z, # newer than the upstream base, and newer than every published fork @@ -210,7 +232,7 @@ jobs: bundle: name: Build JS bundle needs: [preflight] - runs-on: ${{ inputs.linux_runner }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 30 env: T3CODE_RELEASE_BUILD: "1" @@ -247,7 +269,8 @@ jobs: node scripts/select-release-source.ts \ --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ --sha "${{ needs.preflight.outputs.sha }}" \ - --main-ref main + --main-ref main \ + --mode public - name: Install bundle dependencies run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... @@ -276,7 +299,7 @@ jobs: cli_linux_x64: name: Linux x64 runtime archive needs: [preflight, bundle] - runs-on: ${{ inputs.linux_runner }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 30 env: T3CODE_RELEASE_BUILD: "1" @@ -309,7 +332,8 @@ jobs: node scripts/select-release-source.ts \ --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ --sha "${{ needs.preflight.outputs.sha }}" \ - --main-ref main + --main-ref main \ + --mode public - name: Install CLI dependencies run: vp install --filter=t3... --filter=@t3tools/scripts... @@ -341,29 +365,37 @@ jobs: mkdir -p "$target_dir" cp native/resource-monitor/target/release/t3-resource-monitor "$target_dir/t3-resource-monitor" - # The single-executable embeds the Node pinned in apps/server/vite.config.ts - # (SEA_NODE_VERSION), so the host only has to support `--build-sea` (25.7+). - # Assert and log the effective host Node instead of trusting an ambient var. - - name: Assert SEA build toolchain + # The single-executable is built with the Node pinned in + # apps/server/vite.config.ts (SEA_NODE_VERSION). We do not merely *assert* + # an ambient host version: vp installs the exact pinned Node and the build + # runs under it, then the effective version is logged from a real probe. + # Keep SEA_NODE_VERSION and this version in step. + - name: Build CLI single-executable under the pinned SEA Node shell: bash + env: + VP_NODE_VERSION: "26.8.2" run: | set -euo pipefail - node --version - vp --version - node -e ' + echo "vp toolchain (before):" + vp toolchain || true + # Build, under the pinned Node, with evidence of the effective version. + vp run --filter t3 exec -- node --version + vp run --filter t3 exec -- node -e ' const [major, minor] = process.versions.node.split(".").map(Number); if (major < 25 || (major === 25 && minor < 7)) { - console.error(`::error::the SEA build needs a host Node >= 25.7, got ${process.versions.node}`); + console.error(`::error::the SEA build needs Node >= 25.7 for --build-sea, got ${process.versions.node}`); process.exit(1); } - console.log(`SEA host Node OK: ${process.versions.node} (embedded Node is pinned by SEA_NODE_VERSION)`); + console.log(`SEA host Node OK: ${process.versions.node}`); ' - - - name: Build CLI single-executable - run: node apps/server/scripts/cli.ts build-exe --verbose + node apps/server/scripts/cli.ts build-exe --verbose - name: Build CLI archive + shell: bash + env: + VP_NODE_VERSION: "26.8.2" run: | + set -euo pipefail node scripts/build-cli-archive.ts \ --platform linux \ --arch x64 \ @@ -425,7 +457,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.preflight.outputs.relay_url }} label: Windows x64 - runner: ${{ inputs.windows_runner }} + runner: ${{ vars.T3CODE_WINDOWS_RUNNER }} platform: win target: nsis arch: x64 @@ -451,7 +483,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.preflight.outputs.relay_url }} label: macOS x64 - runner: ${{ inputs.macos_x64_runner }} + runner: ${{ vars.T3CODE_MACOS_X64_RUNNER }} platform: mac target: dmg arch: x64 @@ -475,7 +507,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.preflight.outputs.relay_url }} label: macOS arm64 - runner: ${{ inputs.macos_arm64_runner }} + runner: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }} platform: mac target: dmg arch: arm64 @@ -486,9 +518,10 @@ jobs: qualify: name: Qualify candidate - needs: [preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64] + needs: + [authorize, preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }} - runs-on: ${{ inputs.linux_runner }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 20 steps: - name: Bootstrap the release commit @@ -518,7 +551,8 @@ jobs: node scripts/select-release-source.ts \ --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ --sha "${{ needs.preflight.outputs.sha }}" \ - --main-ref main + --main-ref main \ + --mode public - name: Install verification dependencies run: vp install --filter=@t3tools/scripts... @@ -651,6 +685,34 @@ jobs: if [[ "$INCLUDE_ARM64" == "true" ]]; then args+=(--include-macos-arm64); fi node scripts/verify-fork-candidate.ts "${args[@]}" + # A machine-readable record of the frozen bytes, keyed by the immutable + # workflow run id, so a later promotion/receipt step can bind to it. + - name: Record the frozen candidate identity + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const manifest = JSON.parse(fs.readFileSync("candidate/fork-release-manifest.json", "utf8")); + const digest = crypto.createHash("sha256").update(fs.readFileSync("candidate/fork-release-manifest.json")).digest("hex"); + const identity = { + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + repository: manifest.repository, + version: manifest.version, + sourceSha: manifest.sourceSha, + manifestSha256: digest, + assets: manifest.assets, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync("candidate/candidate-identity.json", JSON.stringify(identity, null, 2) + "\n"); + console.log("Candidate identity:", identity.manifestSha256); + ' + - name: Upload candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -659,11 +721,92 @@ jobs: if-no-files-found: error retention-days: 14 + # A real, repeatable receipt upload path. Native machines produce a receipt + # JSON and a caller dispatches this job with `upload_receipts: true`, naming + # the candidate run whose identity the receipts are bound to. The job verifies + # each receipt binds to that candidate's manifest digest and source SHA before + # uploading `fork-release-native-receipts` on *this* run. Promotion then reads + # this run's receipt artifact (see `receipt_run_id`), never a completed build + # run that has no mechanism to receive one. + receipts: + name: Import native acceptance receipts + needs: [authorize, preflight, qualify] + if: ${{ !cancelled() && needs.qualify.result == 'success' && inputs.upload_receipts && inputs.receipts_source_run_id != '' }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 15 + permissions: + contents: read + # Cross-run artifact retrieval needs Actions read. + actions: read + steps: + - name: Bootstrap the release commit + shell: bash + env: + CHECKOUT_REF: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + || git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git checkout --detach "$CHECKOUT_REF" + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Install verification dependencies + run: vp install --filter=@t3tools/scripts... + + - name: Download the candidate identity to bind receipts to + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.receipts_source_run_id }} + run: | + set -euo pipefail + gh run download "$CANDIDATE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name fork-release-candidate \ + --dir candidate + + # The operator-supplied receipts file is committed/attached out of band. + # This job never invents one; it fails closed when the file is absent. + - name: Validate and stage native receipts + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + test -f fork-native-receipts.json || { + echo "::error::fork-native-receipts.json was not provided; native acceptance must be recorded before import" + exit 1 + } + mkdir -p receipts + node scripts/verify-fork-candidate.ts \ + --candidate-dir candidate \ + --version "$RELEASE_VERSION" \ + --sha "$RELEASE_SHA" \ + --repository "nullStack65/t3code" \ + --native-receipts fork-native-receipts.json \ + --require-native-receipts + cp fork-native-receipts.json receipts/fork-native-receipts.json + + - name: Upload native acceptance receipts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: fork-release-native-receipts + path: receipts/fork-native-receipts.json + if-no-files-found: error + publish: name: Promote qualified candidate - needs: [preflight, qualify] + needs: [authorize, preflight, qualify] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish && inputs.candidate_run_id != '' }} - runs-on: ${{ inputs.linux_runner }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 20 # Publication is the only job with write access and it runs in a named # environment so it can require manual approval. The job-level concurrency @@ -671,6 +814,9 @@ jobs: # build cannot overwrite the latest-release pointer. permissions: contents: write + # Cross-run `gh run download` of the frozen candidate and its receipts + # requires Actions read. + actions: read environment: name: fork-release concurrency: @@ -700,6 +846,7 @@ jobs: - name: Install verification dependencies run: vp install --filter=@t3tools/scripts... + # Promotion consumes the already-frozen candidate; it never rebuilds. - name: Download the qualified candidate by run id env: GH_TOKEN: ${{ github.token }} @@ -711,36 +858,76 @@ jobs: --name fork-release-candidate \ --dir candidate + - name: Bind the downloaded candidate to its recorded identity + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const identity = JSON.parse(fs.readFileSync("candidate/candidate-identity.json", "utf8")); + if (identity.sourceSha !== process.env.RELEASE_SHA) { + console.error(`::error::candidate identity sourceSha ${identity.sourceSha} != ${process.env.RELEASE_SHA}`); + process.exit(1); + } + if (identity.version !== process.env.RELEASE_VERSION) { + console.error(`::error::candidate identity version ${identity.version} != ${process.env.RELEASE_VERSION}`); + process.exit(1); + } + const digest = crypto.createHash("sha256").update(fs.readFileSync("candidate/fork-release-manifest.json")).digest("hex"); + if (digest !== identity.manifestSha256) { + console.error(`::error::downloaded manifest digest ${digest} does not match frozen identity ${identity.manifestSha256}`); + process.exit(1); + } + console.log("Candidate bound to frozen identity:", identity.manifestSha256); + ' + - name: Download native acceptance receipts env: GH_TOKEN: ${{ github.token }} - CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} + RECEIPT_RUN_ID: ${{ inputs.receipt_run_id != '' && inputs.receipt_run_id || inputs.candidate_run_id }} run: | set -euo pipefail - if ! gh run download "$CANDIDATE_RUN_ID" \ + if ! gh run download "$RECEIPT_RUN_ID" \ --repo "$GITHUB_REPOSITORY" \ --name fork-release-native-receipts \ --dir receipts; then - echo "::error::no fork-release-native-receipts artifact on run $CANDIDATE_RUN_ID; native acceptance must pass before publication" + echo "::error::no fork-release-native-receipts artifact on run $RECEIPT_RUN_ID; run the 'Import native acceptance receipts' job first" exit 1 fi - # The environment name is not evidence of an approval gate; assert the - # gate is actually configured. A token that cannot read the environment - # fails closed rather than assuming the gate exists. - - name: Assert the publication authorization gate exists + # The environment name is not evidence of an approval gate. Assert the + # *required reviewer* rule specifically, not merely that any protection + # rule exists: a timer or branch restriction is not an approval. + - name: Assert the publication requires reviewer approval shell: bash env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - rules="$(gh api "repos/$GITHUB_REPOSITORY/environments/fork-release" \ - --jq '[.protection_rules[]?] | length' 2>/dev/null || echo "")" - if [ -z "$rules" ] || [ "$rules" = "0" ]; then - echo "::error::environment 'fork-release' is missing or has no protection rules; configure required reviewers before publishing" + json="$(gh api "repos/$GITHUB_REPOSITORY/environments/fork-release" 2>/dev/null || echo "")" + if [ -z "$json" ]; then + echo "::error::environment 'fork-release' does not exist; configure it with required reviewers before publishing" + exit 1 + fi + reviewers="$(printf '%s' "$json" | node -e ' + let raw = ""; + process.stdin.on("data", (c) => (raw += c)); + process.stdin.on("end", () => { + const env = JSON.parse(raw); + const rule = (env.protection_rules ?? []).find((r) => r.type === "required_reviewers"); + const count = rule?.reviewers?.length ?? 0; + process.stdout.write(String(count)); + }); + ')" + if [ "${reviewers:-0}" = "0" ]; then + echo "::error::environment 'fork-release' has no required-reviewer approval rule; a timer or branch restriction is not an approval gate" exit 1 fi - echo "Publication authorization gate present ($rules protection rules)." + echo "Publication requires approval from $reviewers reviewer(s)." - name: Verify promotion and create the release shell: bash diff --git a/docs/operations/fork-release.md b/docs/operations/fork-release.md index f3f527bf0b54..cbd2bc31d9ac 100644 --- a/docs/operations/fork-release.md +++ b/docs/operations/fork-release.md @@ -12,40 +12,78 @@ and `release-desktop.yml`. ## Runners -Runner capacity is an explicit, authorized input. `fork-release.yml` has no -default runner label: the operator must pass `linux_runner`, `windows_runner`, -and `macos_x64_runner`, and every label must appear in the repository variable -`T3CODE_AUTHORIZED_RUNNERS` or preflight fails closed. This prevents a workflow -dispatch from silently landing on GitHub-hosted or paid capacity that was never -authorized. +Runner capacity is owner-configured, not caller-supplied. Runner labels come +from repository variables (`vars.T3CODE_LINUX_RUNNER`, +`vars.T3CODE_WINDOWS_RUNNER`, `vars.T3CODE_MACOS_X64_RUNNER`, +`vars.T3CODE_MACOS_ARM64_RUNNER`) and every label must also appear in +`vars.T3CODE_AUTHORIZED_RUNNERS`. A dispatch cannot name an arbitrary runner, so +source is never scheduled on unauthorized capacity. The `authorize` job runs +first, on the owner-configured Linux label, and every build job `needs` +transitively through `preflight`, so authorization happens before any source +executes. - No self-hosted label is guessed. - No personal machine is registered to run public-PR jobs. - No hosted/paid fallback is added silently. -When no authorized CI runner is available, build the candidate on the already -authorized Windows/WSL and Intel macOS machines with the same scripts: +### Local candidate route (when CI capacity is unavailable) -```sh -# Linux x64 runtime archive + resource monitor, inside the WSL distro: -node scripts/build-fork-candidate.ts --target linux --version 0.0.43 --sha - -# Windows x64 installer (embeds the Linux archive), on Windows: -node scripts/build-fork-candidate.ts --target win --version 0.0.43 --sha \ - --linux-archive candidate/t3-0.0.43-linux-x64.tar.gz +Build the candidate on the already authorized Windows/WSL and Intel macOS +machines with the same scripts. The route is **two-phase**: build/stage/verify +one platform, then aggregate. A Linux-only build succeeds before a macOS DMG or +Windows installer exists. -# Intel macOS DMG, on the Intel Mac: -node scripts/build-fork-candidate.ts --target mac --version 0.0.43 --sha +```sh +# 1. Per target. Run each on the matching machine, all with the same +# --output-dir so their outputs accumulate. +# Linux x64 runtime archive + resource monitor, inside the WSL distro: +node scripts/build-fork-candidate.ts --target linux --version 0.0.43 \ + --sha --mode candidate --output-dir "" + +# Windows x64 installer + CLI ZIP (embeds the Linux archive), on Windows: +node scripts/build-fork-candidate.ts --target win --version 0.0.43 \ + --sha --mode candidate --output-dir "" \ + --linux-archive "/t3-0.0.43-linux-x64.tar.gz" + +# Intel macOS DMG, on the Intel Mac: +node scripts/build-fork-candidate.ts --target mac --version 0.0.43 \ + --sha --mode candidate --output-dir "" + +# 2. Aggregate once all three platforms are present. +node scripts/build-fork-candidate.ts --phase aggregate --target linux \ + --version 0.0.43 --sha --output-dir "" --execute ``` `build-fork-candidate.ts` prints the plan by default and runs it with -`--execute`. It verifies the checked-out HEAD equals the requested source, runs -the same `build-cli-archive.ts`/`build-desktop-artifact.ts`/`smoke-cli-archive.ts` -steps, and freezes the candidate with the same `verify-fork-candidate.ts` the -workflow uses. It never invents a native acceptance receipt. +`--execute`. `--mode candidate` accepts a pre-merge PR SHA that is not on +`main`; `--mode public` (the default) still requires the SHA to be an ancestor of +the fork remote's `main`, which is what publication requires. It resolves the +writable fork remote explicitly (`--fork-remote`, default `fork`) rather than +assuming `origin` is the fork. It verifies the checked-out HEAD equals the +requested source, runs the same +`build-cli-archive.ts`/`build-desktop-artifact.ts`/`smoke-cli-archive.ts` steps, +stages each target's artifacts into the shared directory, and freezes the +complete candidate with the same `verify-fork-candidate.ts` the workflow uses. It +never invents a native acceptance receipt. + +### Transfer and aggregation + +The three machines produce native outputs; gather them into one directory keyed +by the same version and SHA: + +- Copy the Windows `T3-Code--x64.exe` and + `t3--win32-x64.zip`, the Intel Mac `T3-Code--x64.dmg`, and + the Linux `t3--linux-x64.tar.gz` into the shared candidate directory. +- Stage each with `node scripts/stage-candidate-asset.ts --file +--output-dir ""`, or let `build-fork-candidate.ts` do it. +- Run the aggregate step. It rejects any mixed SHA/version: the manifest binds + the source and version, and the embedded provenance inspection reads each + archive's real `t3code-build-info.json`. Completed outputs from a working + platform are preserved even when another platform is unavailable. Apple Silicon macOS is built only when `include_macos_arm64` is set and an -authorized `macos_arm64_runner` is supplied; it is reported untested. +authorized `vars.T3CODE_MACOS_ARM64_RUNNER` is configured; it is reported +untested. ## Versioning @@ -74,12 +112,14 @@ Rules: ## Release procedure 1. Pick the immutable source SHA on `main` and the upstream base version. -2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, - `upstream_base`, and the authorized runner labels. Leave `publish` off to - build a candidate. -3. Preflight checks out that explicit SHA (never `FETCH_HEAD`), asserts - `HEAD == sha`, and asserts the SHA is an ancestor of `origin/main` with - `scripts/select-release-source.ts`. +2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, and + `upstream_base`. Runner labels are not inputs; they come from repository + variables. Leave `publish` off to build a candidate. +3. `authorize` checks the owner-configured runner labels first. Preflight then + checks out that explicit SHA (never `FETCH_HEAD`), asserts `HEAD == sha`, and + applies the public ancestry policy (the SHA must be an ancestor of + `origin/main`) with `scripts/select-release-source.ts` — this runs before + dependencies are installed and imports no workspace packages. 4. The workflow builds the JS bundle once, then packages: - `T3-Code--x64.exe` (NSIS) with the matching `t3--linux-x64.tar.gz` embedded as its WSL runtime; @@ -92,14 +132,23 @@ Rules: standalone archive, and freezes `fork-release-candidate` with `fork-release-manifest.json` and `SHA256SUMS` written from the exact distributed bytes. -6. Native acceptance on real Windows/WSL and Intel macOS hardware. The accepted - bytes are recorded as `fork-native-receipts.json` and uploaded as the - `fork-release-native-receipts` artifact on the same run. A changed/rebuilt - asset invalidates its previous receipt. -7. Re-run with `publish: true` and `candidate_run_id` set to the qualifying - run. Promotion downloads that immutable artifact, verifies the manifest, - checksums, receipts, tag target, no-overwrite, version ordering, and the - authorization gate, then creates the release. It never rebuilds. +6. Native acceptance on real Windows/WSL and Intel macOS hardware. Each target + binds to its own installer/runtime asset and its digest; a receipt for the + wrong artifact, or a conflicting FAIL beside a PASS, is rejected. Record the + accepted bytes as `fork-native-receipts.json` and import them by dispatching + the **Fork release** workflow with `upload_receipts: true` and + `receipts_source_run_id` = the candidate run id. That job downloads the + candidate identity, validates the receipts against it, and uploads the + `fork-release-native-receipts` artifact on the _import_ run. A changed or + rebuilt asset invalidates its previous receipt. +7. Re-run with `publish: true`, `candidate_run_id` set to the qualifying run, + and `receipt_run_id` set to the import run (defaults to `candidate_run_id`). + Promotion downloads the frozen candidate, binds it to its recorded + `candidate-identity.json` digest, verifies the manifest, checksums, receipts, + tag target, no-overwrite, version ordering, and the environment's + _required-reviewer_ approval rule, then creates the release. It never + rebuilds. Cross-run downloads use the `actions: read` permission, and the + publish job holds `contents: write` only. Queued CI is not a passed release gate; a release is only qualified when the candidate artifact set exists and native smoke tests pass. diff --git a/scripts/build-fork-candidate.ts b/scripts/build-fork-candidate.ts index 6c77f675552a..4b81dd99ff9b 100644 --- a/scripts/build-fork-candidate.ts +++ b/scripts/build-fork-candidate.ts @@ -8,9 +8,22 @@ * Windows/WSL and Intel macOS sessions, and writes the same candidate layout * (`fork-release-manifest.json`, `SHA256SUMS`, assets) that `qualify` writes. * - * Default is a dry run: it prints the exact plan. Pass `--execute` to run it. - * A native acceptance receipt for each required target is still required before - * publication; this tool never invents one. + * It is two-phase on purpose: + * - `--phase target` (default) builds, stages, and verifies *one* platform + * into the shared output directory. It never requires another platform, so + * a Linux-only build can succeed before a macOS DMG exists. + * - `--phase aggregate` freezes the manifest/checksums and verifies the + * complete required artifact set across every platform. Run it after the + * native outputs have been gathered. + * + * Source selection is explicit: + * - `--mode public` (default) requires the SHA to be an ancestor of the fork + * remote's `main`; + * - `--mode candidate` accepts any commit on the fork remote, which is what + * lets a pre-merge PR head be built without faking main ancestry. + * + * Native acceptance receipts are still required before publication; this tool + * never invents one. */ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -21,21 +34,31 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { planCandidateBuild, + planCandidateStaging, + planCandidateTargetVerification, planCandidateVerification, type CandidatePlanStep, type CandidateTarget, } from "./lib/candidate-build-plan.ts"; +type Phase = "target" | "aggregate"; +type SourceMode = "public" | "candidate"; + interface Args { target: CandidateTarget; + phase: Phase; version: string; sha: string; repository: string; + forkRemote: string; + mode: SourceMode; outputDir: string; resourceMonitorDir: string; linuxArchive: string | undefined; includeMacosArm64: boolean; + assumeInstalled: boolean; execute: boolean; + keepGoing: boolean; } function parseArgs(argv: ReadonlyArray): Args { @@ -62,48 +85,126 @@ function parseArgs(argv: ReadonlyArray): Args { if (target !== "linux" && target !== "win" && target !== "mac") { throw new Error("--target must be linux, win, or mac"); } + const phase = values.get("phase")?.trim() || "target"; + if (phase !== "target" && phase !== "aggregate") { + throw new Error("--phase must be target or aggregate"); + } + const mode = values.get("mode")?.trim() || "public"; + if (mode !== "public" && mode !== "candidate") { + throw new Error("--mode must be public or candidate"); + } return { target, + phase, version: required("version"), sha: required("sha").toLowerCase(), repository: values.get("repository")?.trim() || "nullStack65/t3code", + forkRemote: values.get("fork-remote")?.trim() || "fork", + mode, outputDir: values.get("output-dir")?.trim() || "candidate", resourceMonitorDir: values.get("resource-monitor-dir")?.trim() || NodePath.join(NodeOS.tmpdir(), "t3-candidate-resource-monitor"), linuxArchive: values.get("linux-archive")?.trim(), includeMacosArm64: flags.has("include-macos-arm64"), + assumeInstalled: flags.has("assume-installed"), execute: flags.has("execute"), + keepGoing: flags.has("keep-going"), }; } -const run = (step: CandidatePlanStep): void => { +function run(step: CandidatePlanStep, args: Args): void { console.log(`\n$ ${step.command.join(" ")}`); - const result = NodeChildProcess.spawnSync(step.command[0]!, step.command.slice(1), { + const command = step.command[0]!; + const result = NodeChildProcess.spawnSync(command, step.command.slice(1), { stdio: "inherit", shell: HostProcessPlatform.defaultValue() === "win32", + env: childEnv(args), + cwd: process.cwd(), }); if (result.status !== 0) { throw new Error(`step '${step.id}' failed with exit code ${result.status ?? "unknown"}`); } -}; +} -function assertSource(args: Args): void { - const head = NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD"], { - encoding: "utf8", - }).trim(); - if (head.toLowerCase() !== args.sha) { - throw new Error(`checked-out HEAD ${head} does not match requested source ${args.sha}`); +/** + * Binds the requested source/repository/release mode into every child process so + * the embedded provenance is the selected SHA, not an ambient or dispatch value. + */ +function childEnv(args: Args): NodeJS.ProcessEnv { + return { + ...process.env, + T3CODE_RELEASE_BUILD: "1", + T3CODE_SOURCE_SHA: args.sha, + T3CODE_SOURCE_REPOSITORY: args.repository, + T3CODE_SOURCE_MODE: args.mode, + }; +} + +/** Resolves the writable fork remote rather than assuming `origin` is the fork. */ +function resolveForkRemote(args: Args): { remote: string; url: string } { + const preferred = args.forkRemote; + const candidates = [preferred, "fork", "origin"]; + for (const remote of candidates) { + const result = NodeChildProcess.spawnSync("git", ["remote", "get-url", remote], { + encoding: "utf8", + }); + if (result.status !== 0) continue; + const url = (result.stdout ?? "").trim(); + if (url.toLowerCase().includes(args.repository.toLowerCase())) { + return { remote, url }; + } + } + // Fall back to whichever remote exists, but say so loudly; provenance is + // still bound by T3CODE_SOURCE_REPOSITORY above. + for (const remote of candidates) { + const result = NodeChildProcess.spawnSync("git", ["remote", "get-url", remote], { + encoding: "utf8", + }); + if (result.status === 0 && (result.stdout ?? "").trim() !== "") { + console.warn( + `warn: no local remote points at ${args.repository}; using '${remote}' as the source of the candidate SHA.`, + ); + return { remote, url: (result.stdout ?? "").trim() }; + } } - const ancestor = NodeChildProcess.spawnSync( - "git", - ["merge-base", "--is-ancestor", args.sha, "origin/main"], - { stdio: "ignore" }, + throw new Error( + `no git remote found for ${args.repository}; pass --fork-remote naming the writable fork remote.`, ); - if (ancestor.status !== 0) { - throw new Error(`${args.sha} is not an ancestor of origin/main`); +} + +function runGit(args: ReadonlyArray): { stdout: string; status: number } { + const result = NodeChildProcess.spawnSync("git", [...args], { encoding: "utf8" }); + return { stdout: (result.stdout ?? "").trim(), status: result.status ?? 1 }; +} + +function assertSource(args: Args): void { + const { remote, url } = resolveForkRemote(args); + console.log(`Fork remote: ${remote} -> ${url}`); + + const head = runGit(["rev-parse", "HEAD"]).stdout.toLowerCase(); + if (head !== args.sha) { + throw new Error( + `checked-out HEAD ${head} does not match requested source ${args.sha}; check out the release SHA first.`, + ); + } + + // Ensure the requested SHA was actually fetched from the fork remote. + const hasCommit = runGit(["cat-file", "-e", `${args.sha}^{commit}`]).status === 0; + if (!hasCommit) { + throw new Error(`requested source ${args.sha} is not present in this checkout`); + } + + const onForkMain = + runGit(["merge-base", "--is-ancestor", args.sha, `${remote}/main`]).status === 0; + if (args.mode === "public" && !onForkMain) { + throw new Error( + `${args.sha} is not an ancestor of ${remote}/main; use --mode candidate for a pre-merge PR head.`, + ); } - console.log(`Source verified: ${args.sha} (HEAD, ancestor of origin/main).`); + console.log( + `Source verified: ${args.sha} (HEAD; mode ${args.mode}; ${onForkMain ? `on ${remote}/main` : "not on main — candidate-only"}).`, + ); } function stageResourceMonitor(args: Args): void { @@ -116,16 +217,20 @@ function stageResourceMonitor(args: Args): void { console.log(`Staged resource monitor into ${target}`); } -function main(): void { - const args = parseArgs(process.argv.slice(2)); - const buildSteps = planCandidateBuild({ +function buildPlan(args: Args): ReadonlyArray { + return planCandidateBuild({ target: args.target, version: args.version, outputDir: args.outputDir, resourceMonitorDir: args.resourceMonitorDir, linuxArchive: args.linuxArchive, + assumeInstalled: args.assumeInstalled, }); - const verifyStep = planCandidateVerification({ +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const aggregateStep = planCandidateVerification({ version: args.version, sourceSha: args.sha, repository: args.repository, @@ -133,36 +238,72 @@ function main(): void { includeMacosArm64: args.includeMacosArm64, }); + if (args.phase === "aggregate") { + console.log(`Aggregate candidate freeze for ${args.repository} v${args.version} @ ${args.sha}`); + console.log(` - ${aggregateStep.id}: ${aggregateStep.description}`); + console.log(` ${aggregateStep.command.join(" ")}`); + if (!args.execute) { + console.log("\nDry run. Pass --execute to run this step."); + return; + } + run(aggregateStep, args); + console.log("\nAggregate candidate frozen and verified."); + return; + } + + const buildSteps = buildPlan(args); + const stageSteps = planCandidateStaging({ + target: args.target, + version: args.version, + outputDir: args.outputDir, + }); + const verifyStep = planCandidateTargetVerification({ + target: args.target, + version: args.version, + sourceSha: args.sha, + repository: args.repository, + candidateDir: args.outputDir, + }); + const steps = [...buildSteps, ...stageSteps, verifyStep]; + console.log( - `Machine-local candidate plan for ${args.target} (${args.repository} v${args.version})`, + `Machine-local candidate plan for ${args.target} (${args.repository} v${args.version}, mode ${args.mode})`, ); - for (const step of [...buildSteps, verifyStep]) { - console.log(` - ${step.id}: ${step.description}`); + for (const step of steps) { + console.log(` - [${step.phase}] ${step.id}: ${step.description}`); console.log(` ${step.command.join(" ")}`); } if (!args.execute) { console.log("\nDry run. Pass --execute to run these steps."); + console.log( + "Prerequisites for a clean checkout: git + the pinned Node toolchain, `vp` (Vite+), and Rust (cargo).", + ); return; } assertSource(args); NodeFS.mkdirSync(args.outputDir, { recursive: true }); - if (args.target === "linux") { - run(buildSteps.find((step) => step.id === "resource-monitor")!); - stageResourceMonitor(args); - for (const step of buildSteps) { - if (step.id === "resource-monitor") continue; - run(step); - } - } else { - for (const step of buildSteps) { - run(step); + + // Install once, then build; never abort the whole run for one optional step. + for (const step of buildSteps) { + try { + if (step.id === "resource-monitor" && args.target === "linux") { + run(step, args); + stageResourceMonitor(args); + continue; + } + run(step, args); + } catch (error) { + if (!args.keepGoing) throw error; + console.error(`step '${step.id}' failed; preserving completed outputs and continuing.`); + console.error(String(error)); } } - run(verifyStep); + for (const step of stageSteps) run(step, args); + run(verifyStep, args); console.log( - "\nCandidate assembled and verified locally. Native acceptance receipts are still required before publication.", + `\n${args.target} artifacts staged into ${args.outputDir} and verified. Run the other platforms, gather their outputs, then run --phase aggregate. Native acceptance receipts are still required before publication.`, ); } diff --git a/scripts/fork-release-entrypoints.test.ts b/scripts/fork-release-entrypoints.test.ts new file mode 100644 index 000000000000..74d6c96b6d96 --- /dev/null +++ b/scripts/fork-release-entrypoints.test.ts @@ -0,0 +1,439 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Spawns the real CLI entry points as child processes. +/** + * Process-level regression tests for the release entry points. + * + * These run the actual `node scripts/*.ts` entry points in isolated directories + * rather than asserting against plan arrays, so argument parsing, exit codes, + * and fail-closed behavior are exercised end to end. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; + +const repoRoot = NodePath.resolve(import.meta.dirname, ".."); +const nodeBin = process.execPath; + +interface RunResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function runNode(args: ReadonlyArray, cwd = repoRoot): RunResult { + const result = NodeChildProcess.spawnSync(nodeBin, [...args], { + cwd, + encoding: "utf8", + env: { ...process.env, T3CODE_RELEASE_BUILD: "1" }, + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function scratch(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-")); +} + +const VERSION = "0.0.43"; +const SHA = "cb8a5b0b04b31cd9531e6bb8ebefcddaf1a1c4c2"; + +function writeCandidate(dir: string, options: { readonly withReceipts: boolean }): void { + const assets = [ + `T3-Code-${VERSION}-x64.exe`, + `T3-Code-${VERSION}-x64.dmg`, + `t3-${VERSION}-linux-x64.tar.gz`, + `t3-${VERSION}-win32-x64.zip`, + ]; + for (const [index, name] of assets.entries()) { + NodeFS.writeFileSync(NodePath.join(dir, name), Buffer.from(`asset-${index}-${name}`)); + } + const observed = assets.map((name) => { + const bytes = NodeFS.readFileSync(NodePath.join(dir, name)); + return { + name, + sha256: NodeChildProcess.execFileSync( + nodeBin, + [ + "-e", + "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", + NodePath.join(dir, name), + ], + { encoding: "utf8" }, + ).trim(), + size: bytes.byteLength, + }; + }); + const receipts = options.withReceipts + ? [ + { + schemaVersion: 1, + owner: "W", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: assets[0], + assetSha256: observed[0]!.sha256, + result: "pass", + }, + { + schemaVersion: 1, + owner: "M", + target: "darwin-x64", + sourceSha: SHA, + version: VERSION, + assetName: assets[1], + assetSha256: observed[1]!.sha256, + result: "pass", + }, + ] + : []; + NodeFS.writeFileSync( + NodePath.join(dir, "fork-release-manifest.json"), + `${JSON.stringify( + { + schemaVersion: 1, + repository: "nullStack65/t3code", + version: VERSION, + sourceSha: SHA, + workflowRevision: "local", + workflowRunId: "local", + workflowRunAttempt: "1", + channel: "stable", + createdAt: new Date().toISOString(), + assets: observed, + nativeReceipts: receipts, + }, + null, + 2, + )}\n`, + ); + NodeFS.writeFileSync( + NodePath.join(dir, "SHA256SUMS"), + `${observed + .map((asset) => `${asset.sha256} ${asset.name}`) + .sort() + .join("\n")}\n`, + ); +} + +it("the aggregate verifier accepts a complete candidate and rejects a partial one (real process)", () => { + const root = scratch(); + try { + const complete = NodePath.join(root, "complete"); + NodeFS.mkdirSync(complete); + writeCandidate(complete, { withReceipts: true }); + const accepted = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + complete, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--skip-provenance-inspection", + ]); + assert.equal(accepted.status, 0, accepted.stderr); + assert.include(accepted.stdout, "Candidate verified"); + + const partial = NodePath.join(root, "partial"); + NodeFS.mkdirSync(partial); + NodeFS.copyFileSync( + NodePath.join(complete, `t3-${VERSION}-linux-x64.tar.gz`), + NodePath.join(partial, `t3-${VERSION}-linux-x64.tar.gz`), + ); + NodeFS.copyFileSync( + NodePath.join(complete, "fork-release-manifest.json"), + NodePath.join(partial, "fork-release-manifest.json"), + ); + const rejected = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + partial, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--skip-provenance-inspection", + ]); + assert.equal(rejected.status, 1); + assert.include(rejected.stderr, "required asset"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("per-target verification passes with only that platform's bytes (real process)", () => { + const root = scratch(); + try { + const linuxOnly = NodePath.join(root, "linux-only"); + NodeFS.mkdirSync(linuxOnly); + NodeFS.writeFileSync( + NodePath.join(linuxOnly, `t3-${VERSION}-linux-x64.tar.gz`), + Buffer.from("linux"), + ); + const observed = NodeChildProcess.execFileSync( + nodeBin, + [ + "-e", + "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", + NodePath.join(linuxOnly, `t3-${VERSION}-linux-x64.tar.gz`), + ], + { encoding: "utf8" }, + ).trim(); + NodeFS.writeFileSync( + NodePath.join(linuxOnly, "fork-release-manifest.json"), + JSON.stringify({ + schemaVersion: 1, + repository: "nullStack65/t3code", + version: VERSION, + sourceSha: SHA, + workflowRevision: "local", + workflowRunId: "local", + workflowRunAttempt: "1", + channel: "stable", + createdAt: new Date().toISOString(), + assets: [{ name: `t3-${VERSION}-linux-x64.tar.gz`, sha256: observed, size: 5 }], + nativeReceipts: [], + }), + ); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + linuxOnly, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + "--skip-provenance-inspection", + ]); + assert.equal(result.status, 0, result.stderr); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("the verifier rejects conflicting receipts through the real process", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "conflict"); + NodeFS.mkdirSync(dir); + writeCandidate(dir, { withReceipts: true }); + const manifestPath = NodePath.join(dir, "fork-release-manifest.json"); + const manifest = JSON.parse(NodeFS.readFileSync(manifestPath, "utf8")) as { + nativeReceipts: Array>; + assets: Array<{ name: string; sha256: string }>; + }; + manifest.nativeReceipts.push({ + schemaVersion: 1, + owner: "W2", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: `T3-Code-${VERSION}-x64.exe`, + assetSha256: manifest.assets[0]!.sha256, + result: "fail", + notes: "crashed", + }); + NodeFS.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--require-native-receipts", + "--skip-provenance-inspection", + ]); + assert.equal(result.status, 1); + assert.include(result.stderr, "conflicting"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("inspects real packaged provenance from a real Linux tar.gz (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "real"); + NodeFS.mkdirSync(dir); + // Build a real tarball whose build-info names the expected source. + const staging = NodePath.join(root, "staging"); + const stem = `t3-${VERSION}-linux-x64`; + NodeFS.mkdirSync(NodePath.join(staging, stem), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(staging, stem, "t3code-build-info.json"), + JSON.stringify({ + schemaVersion: 1, + repository: "nullStack65/t3code", + sourceSha: SHA, + workflowRevision: "local", + version: VERSION, + platform: "linux", + arch: "x64", + channel: "stable", + }), + ); + const archive = NodePath.join(dir, `${stem}.tar.gz`); + NodeChildProcess.execFileSync("tar", ["-czf", archive, "-C", staging, stem]); + const observed = NodeChildProcess.execFileSync( + nodeBin, + [ + "-e", + "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", + archive, + ], + { encoding: "utf8" }, + ).trim(); + NodeFS.writeFileSync( + NodePath.join(dir, "fork-release-manifest.json"), + JSON.stringify({ + schemaVersion: 1, + repository: "nullStack65/t3code", + version: VERSION, + sourceSha: SHA, + workflowRevision: "local", + workflowRunId: "local", + workflowRunAttempt: "1", + channel: "stable", + createdAt: new Date().toISOString(), + assets: [{ name: `${stem}.tar.gz`, sha256: observed, size: NodeFS.statSync(archive).size }], + nativeReceipts: [], + }), + ); + + const ok = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + ]); + assert.equal(ok.status, 0, ok.stderr); + assert.include(ok.stdout, "Inspected packaged provenance"); + assert.include(ok.stdout, `"sourceSha": "${SHA}"`); + + // Now corrupt the embedded provenance: a candidate built from another + // source must be rejected by the real inspection, not just the manifest. + const otherStaging = NodePath.join(root, "other"); + NodeFS.mkdirSync(NodePath.join(otherStaging, stem), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(otherStaging, stem, "t3code-build-info.json"), + JSON.stringify({ + schemaVersion: 1, + repository: "nullStack65/t3code", + sourceSha: "a".repeat(40), + workflowRevision: "local", + version: VERSION, + platform: "linux", + arch: "x64", + channel: "stable", + }), + ); + NodeChildProcess.execFileSync("tar", ["-czf", archive, "-C", otherStaging, stem]); + const corruptHash = NodeChildProcess.execFileSync( + nodeBin, + [ + "-e", + "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", + archive, + ], + { encoding: "utf8" }, + ).trim(); + const manifestPath = NodePath.join(dir, "fork-release-manifest.json"); + const manifest = JSON.parse(NodeFS.readFileSync(manifestPath, "utf8")) as { + assets: Array<{ name: string; sha256: string; size: number }>; + }; + manifest.assets[0]!.sha256 = corruptHash; + manifest.assets[0]!.size = NodeFS.statSync(archive).size; + NodeFS.writeFileSync(manifestPath, JSON.stringify(manifest)); + NodeFS.writeFileSync(NodePath.join(dir, "SHA256SUMS"), `${corruptHash} ${stem}.tar.gz\n`); + const rejected = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + ]); + assert.equal(rejected.status, 1); + assert.include(rejected.stderr, "provenance sourceSha"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("the source selector accepts a candidate-mode fork SHA (real process)", () => { + const root = scratch(); + try { + const origin = NodePath.join(root, "origin"); + const work = NodePath.join(root, "work"); + NodeFS.mkdirSync(origin); + NodeFS.mkdirSync(work); + const git = (cwd: string, args: ReadonlyArray): string => + NodeChildProcess.execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@e", "-c", "commit.gpgsign=false", ...args], + { cwd, encoding: "utf8" }, + ).trim(); + git(origin, ["init", "-b", "main"]); + NodeFS.writeFileSync(NodePath.join(origin, "a.txt"), "a\n"); + git(origin, ["add", "."]); + git(origin, ["commit", "-m", "A"]); + git(origin, ["checkout", "-b", "feature"]); + NodeFS.writeFileSync(NodePath.join(origin, "b.txt"), "b\n"); + git(origin, ["add", "."]); + git(origin, ["commit", "-m", "B"]); + const head = git(origin, ["rev-parse", "HEAD"]); + git(origin, ["checkout", "main"]); + + const result = runNode( + [ + "scripts/select-release-source.ts", + "--repo-url", + origin, + "--sha", + head, + "--main-ref", + "main", + "--mode", + "candidate", + "--cwd", + work, + ], + repoRoot, + ); + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "mode candidate"); + assert.include(result.stdout, "ancestry on-fork"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/lib/candidate-build-plan.test.ts b/scripts/lib/candidate-build-plan.test.ts index 372b7b2e3f97..720bac165204 100644 --- a/scripts/lib/candidate-build-plan.test.ts +++ b/scripts/lib/candidate-build-plan.test.ts @@ -2,6 +2,8 @@ import { assert, it } from "@effect/vitest"; import { planCandidateBuild, + planCandidateStaging, + planCandidateTargetVerification, planCandidateVerification, type CandidatePlanStep, } from "./candidate-build-plan.ts"; @@ -12,7 +14,7 @@ const flat = (steps: ReadonlyArray): string => const VERSION = "0.0.43"; const SHA = "bcc1a58b19a9d610a4f08fed191a364767bc65b3"; -it("plans the Linux runtime archive with the workflow's own scripts", () => { +it("plans the Linux runtime archive with the workflow's own scripts and the requested version", () => { const steps = planCandidateBuild({ target: "linux", version: VERSION, @@ -20,14 +22,16 @@ it("plans the Linux runtime archive with the workflow's own scripts", () => { resourceMonitorDir: "/tmp/rm", }); const commands = flat(steps); + assert.include(commands, "scripts/update-release-package-versions.ts"); assert.include(commands, "scripts/build-cli-archive.ts"); assert.include(commands, "scripts/smoke-cli-archive.ts"); assert.include(commands, "apps/server/scripts/cli.ts build-exe"); assert.include(commands, `--version ${VERSION}`); assert.include(commands, "--resource-monitor-dir /tmp/rm"); + assert.include(commands, "--output-dir candidate"); }); -it("plans the Windows installer with the Linux WSL runtime embedded", () => { +it("plans the Windows installer, the Windows CLI ZIP, and the WSL runtime", () => { const steps = planCandidateBuild({ target: "win", version: VERSION, @@ -39,7 +43,13 @@ it("plans the Windows installer with the Linux WSL runtime embedded", () => { assert.include(commands, "scripts/build-desktop-artifact.ts"); assert.include(commands, "--platform win"); assert.include(commands, "--target nsis"); + assert.include(commands, `--build-version ${VERSION}`); + assert.include(commands, `--output-dir candidate`); assert.include(commands, `--wsl-runtime candidate/t3-${VERSION}-linux-x64.tar.gz`); + // The Windows CLI ZIP the install/update path now requires. + assert.include(commands, "--platform win"); + assert.include(commands, `t3-${VERSION}-win32-x64.zip`); + assert.include(commands, "x86_64-pc-windows-msvc"); }); it("refuses a Windows plan without the Linux runtime archive", () => { @@ -53,20 +63,46 @@ it("refuses a Windows plan without the Linux runtime archive", () => { ); }); -it("plans the Intel macOS DMG", () => { +it("plans the Intel macOS DMG with the requested version and output directory", () => { const steps = planCandidateBuild({ target: "mac", version: VERSION, - outputDir: "candidate", + outputDir: "candidate dir", resourceMonitorDir: "/tmp/rm", }); const commands = flat(steps); assert.include(commands, "--platform mac"); assert.include(commands, "--target dmg"); assert.include(commands, "--arch x64"); + assert.include(commands, `--build-version ${VERSION}`); + assert.include(commands, `--output-dir candidate dir`); +}); + +it("per-target verification binds only that target and never requires other platforms", () => { + const step = planCandidateTargetVerification({ + target: "linux", + version: VERSION, + sourceSha: SHA, + repository: "nullStack65/t3code", + candidateDir: "candidate", + }); + const command = step.command.join(" "); + assert.include(command, "scripts/verify-fork-candidate.ts"); + assert.include(command, "--targets linux"); + assert.notInclude(command, "--write-manifest"); + // A single-platform verify must not demand the complete asset set. + assert.notInclude(command, "--require-native-receipts"); +}); + +it("stages each target artifact into the shared candidate directory", () => { + const steps = planCandidateStaging({ target: "win", version: VERSION, outputDir: "out dir" }); + const commands = flat(steps); + assert.include(commands, `T3-Code-${VERSION}-x64.exe`); + assert.include(commands, `t3-${VERSION}-win32-x64.zip`); + assert.include(commands, "--output-dir out dir"); }); -it("uses the shared verifier so local and CI candidates are checked identically", () => { +it("uses the shared aggregate verifier so local and CI candidates are frozen identically", () => { const step = planCandidateVerification({ version: VERSION, sourceSha: SHA, @@ -74,6 +110,7 @@ it("uses the shared verifier so local and CI candidates are checked identically" candidateDir: "candidate", }); const command = step.command.join(" "); + assert.equal(step.phase, "aggregate"); assert.include(command, "scripts/verify-fork-candidate.ts"); assert.include(command, `--sha ${SHA}`); assert.include(command, "--write-checksums"); diff --git a/scripts/lib/candidate-build-plan.ts b/scripts/lib/candidate-build-plan.ts index 9a4adfa5d870..4a7fffd5dec3 100644 --- a/scripts/lib/candidate-build-plan.ts +++ b/scripts/lib/candidate-build-plan.ts @@ -6,9 +6,20 @@ * Actions runner is unavailable, the already-authorized Windows/WSL and Intel * macOS sessions assemble the candidate with the *same* scripts the workflow * uses (`build-cli-archive.ts`, `build-desktop-artifact.ts`, - * `smoke-cli-archive.ts`) and the same verifier - * (`verify-fork-candidate.ts`). This module is the shared plan so the two - * routes cannot drift. + * `smoke-cli-archive.ts`) and the same verifier (`verify-fork-candidate.ts`). + * This module is the shared plan so the two routes cannot drift. + * + * The plan is deliberately two-phase: + * + * 1. Per target: build/stage/verify *one* platform's outputs into the shared + * candidate directory. This phase never requires any other platform. + * 2. Aggregate: gather the native outputs (documented transfer procedure), + * then verify and freeze the complete candidate. + * + * A Linux-only build must be able to succeed and produce a durable partial + * candidate before a macOS or Windows artifact exists. The aggregate step still + * requires the complete required artifact set; it is never weakened to make a + * partial build look complete. */ export type CandidateTarget = "linux" | "win" | "mac"; @@ -17,6 +28,12 @@ export interface CandidatePlanStep { readonly id: string; readonly description: string; readonly command: ReadonlyArray; + /** + * `build` steps produce artifacts. `stage` steps copy a platform's outputs + * into the shared candidate directory. `verify` steps check one platform's + * bytes. `aggregate` freezes the whole candidate. + */ + readonly phase: "install" | "build" | "stage" | "verify" | "aggregate"; } export interface CandidatePlanInput { @@ -27,6 +44,11 @@ export interface CandidatePlanInput { readonly resourceMonitorDir: string; /** Path to the Linux x64 archive embedded as the Windows WSL runtime. */ readonly linuxArchive?: string | undefined; + /** + * Absolute path to the workspace checkout whose dependencies are already + * installed. When omitted the plan runs `vp install` first. + */ + readonly assumeInstalled?: boolean | undefined; } export interface VerificationPlanInput { @@ -37,19 +59,45 @@ export interface VerificationPlanInput { readonly includeMacosArm64?: boolean; } +/** The per-target asset each target is responsible for producing. */ +export function candidateTargetAssets( + target: CandidateTarget, + version: string, +): ReadonlyArray { + if (target === "linux") return [`t3-${version}-linux-x64.tar.gz`]; + if (target === "win") { + return [`T3-Code-${version}-x64.exe`, `t3-${version}-win32-x64.zip`]; + } + return [`T3-Code-${version}-x64.dmg`, `T3-Code-${version}-arm64.dmg`]; +} + /** The steps that build one target's artifacts into `outputDir`. */ export function planCandidateBuild(input: CandidatePlanInput): ReadonlyArray { + const base = input.assumeInstalled + ? [] + : [ + { + id: "install", + description: "Install workspace dependencies", + command: ["vp", "install"], + phase: "install" as const, + }, + ]; + if (input.target === "linux") { return [ + ...base, { - id: "install", - description: "Install workspace dependencies", - command: ["vp", "install"], + id: "align-version", + description: "Align package versions to the fork release version", + command: ["node", "scripts/update-release-package-versions.ts", input.version], + phase: "build", }, { id: "bundle", description: "Build the server/web bundle", command: ["vp", "run", "--filter", "t3", "build"], + phase: "build", }, { id: "resource-monitor", @@ -62,11 +110,13 @@ export function planCandidateBuild(input: CandidatePlanInput): ReadonlyArray { + return candidateTargetAssets(input.target, input.version).map((asset) => ({ + id: `stage-${asset}`, + description: `Stage ${asset} into the shared candidate directory`, + command: [ + "node", + "scripts/stage-candidate-asset.ts", + "--file", + asset, + "--output-dir", + input.outputDir, + ], + phase: "stage" as const, + })); +} diff --git a/scripts/lib/candidate-provenance-inspect.ts b/scripts/lib/candidate-provenance-inspect.ts new file mode 100644 index 000000000000..b16bc50fd95b --- /dev/null +++ b/scripts/lib/candidate-provenance-inspect.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalProcessRuntime:off - Extracts real archives to read their packaged provenance. +/** + * Reads the *actual* packaged provenance from a candidate's distributed bytes. + * + * A manifest that claims the right source is not evidence; the archive must be + * opened and its `t3code-build-info.json` read. This module extracts the Linux + * tarball and the Windows ZIP and reads their provenance, and (on Windows) + * runs the real NSIS installer to reach `resources/wsl-runtime.tar.gz`. + * + * It never trusts the file name for the platform/arch/version. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { parseBuildInfo } from "./source-provenance.ts"; +import { + WSL_RUNTIME_ARCHIVE_NAME, + WSL_RUNTIME_ARCHIVE_HASH_NAME, +} from "../build-desktop-artifact.ts"; +import type { PackagedProvenance, PackagedProvenanceRecord } from "./fork-release-manifest.ts"; + +const which = (command: string): string | undefined => { + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + const finder = process.platform === "win32" ? "where" : "which"; + const result = NodeChildProcess.spawnSync(finder, [command], { encoding: "utf8" }); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/)[0]?.trim() || undefined; +}; + +function detectSevenZip(): string | undefined { + for (const candidate of ["7z", "7zz", "7za"]) { + if (which(candidate) !== undefined) return candidate; + } + for (const root of [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]) { + if (root === undefined) continue; + const candidate = NodePath.join(root, "7-Zip", "7z.exe"); + if (NodeFS.existsSync(candidate)) return candidate; + } + return undefined; +} + +function findFile(root: string, name: string): string | undefined { + let entries: NodeFS.Dirent[]; + try { + entries = NodeFS.readdirSync(root, { withFileTypes: true }); + } catch { + return undefined; + } + for (const entry of entries) { + const full = NodePath.join(root, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found !== undefined) return found; + } else if (entry.name === name) { + return full; + } + } + return undefined; +} + +const run = ( + command: string, + args: ReadonlyArray, + options: { allowFailure?: boolean } = {}, +): number => { + const result = NodeChildProcess.spawnSync(command, args, { stdio: "inherit" }); + const status = result.status ?? 1; + if (status !== 0 && options.allowFailure !== true) { + throw new Error(`${command} ${args.join(" ")} exited ${status}`); + } + return status; +}; + +function readBuildInfoAt(root: string): PackagedProvenanceRecord | null { + const infoPath = findFile(root, "t3code-build-info.json"); + if (infoPath === undefined) return null; + const parsed = parseBuildInfo(NodeFS.readFileSync(infoPath, "utf8")); + return { + repository: parsed.repository, + sourceSha: parsed.sourceSha, + version: parsed.version, + platform: parsed.platform, + arch: parsed.arch, + }; +} + +function readTarGzProvenance(archive: string, scratch: string): PackagedProvenanceRecord | null { + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "tar-")); + run("tar", ["-xzf", archive, "-C", dir]); + return readBuildInfoAt(dir); +} + +function readZipProvenance(archive: string, scratch: string): PackagedProvenanceRecord | null { + const sevenZip = detectSevenZip(); + if (sevenZip !== undefined) { + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "zip-")); + run(sevenZip, ["x", "-y", `-o${dir}`, archive]); + return readBuildInfoAt(dir); + } + // bsdtar can read zip on every supported host. + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "zip-")); + run("tar", ["-xf", archive, "-C", dir]); + return readBuildInfoAt(dir); +} + +/** + * Extracts the WSL archive from a Windows installer. On Windows it runs the + * real NSIS installer into a throwaway directory; otherwise 7-Zip. Returns the + * embedded archive bytes and the standalone comparison flag. + */ +function inspectEmbeddedWsl( + installer: string, + standaloneArchive: string, + scratch: string, +): { embedded: Uint8Array | undefined; equalsStandalone: boolean | undefined } { + let extractRoot: string; + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + if (process.platform === "win32") { + const installDir = NodePath.join(scratch, "nsis-install"); + NodeFS.mkdirSync(installDir, { recursive: true }); + const status = NodeChildProcess.spawnSync(installer, ["/S", `/D=${installDir}`], { + stdio: "inherit", + }).status; + extractRoot = status === 0 ? installDir : ""; + } else { + extractRoot = ""; + } + if (extractRoot === "" || !NodeFS.existsSync(extractRoot)) { + const sevenZip = detectSevenZip(); + if (sevenZip === undefined) return { embedded: undefined, equalsStandalone: undefined }; + extractRoot = NodeFS.mkdtempSync(NodePath.join(scratch, "installer-")); + run(sevenZip, ["x", "-y", `-o${extractRoot}`, installer]); + } + const embeddedPath = findFile(extractRoot, WSL_RUNTIME_ARCHIVE_NAME); + if (embeddedPath === undefined) return { embedded: undefined, equalsStandalone: undefined }; + const embedded = NodeFS.readFileSync(embeddedPath); + const standalone = NodeFS.readFileSync(standaloneArchive); + return { embedded, equalsStandalone: embedded.equals(standalone) }; +} + +export interface InspectCandidateInput { + readonly candidateDir: string; + readonly version: string; + readonly targets: "all" | "linux" | "win" | "mac"; + readonly includeMacosArm64: boolean; +} + +/** + * Reads the real embedded provenance for the target selection. Missing + * extraction prerequisites (for example no 7-Zip for the Windows ZIP) leave the + * record `undefined` rather than falsely passing; the caller reports that as an + * inspection gap when the artifact is required. + */ +export function inspectCandidateProvenance(input: InspectCandidateInput): PackagedProvenance { + const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-inspect-")); + const provenance: { + windowsInstaller?: PackagedProvenanceRecord | null; + linuxArchive?: PackagedProvenanceRecord | null; + windowsZip?: PackagedProvenanceRecord | null; + macDmg?: PackagedProvenanceRecord | null; + } = {}; + const wantsAll = input.targets === "all"; + const linuxArchivePath = NodePath.join( + input.candidateDir, + `t3-${input.version}-linux-x64.tar.gz`, + ); + const windowsZipPath = NodePath.join(input.candidateDir, `t3-${input.version}-win32-x64.zip`); + const windowsInstallerPath = NodePath.join( + input.candidateDir, + `T3-Code-${input.version}-x64.exe`, + ); + + if ((wantsAll || input.targets === "linux") && NodeFS.existsSync(linuxArchivePath)) { + provenance.linuxArchive = readTarGzProvenance(linuxArchivePath, scratch); + } + if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsZipPath)) { + provenance.windowsZip = readZipProvenance(windowsZipPath, scratch); + } + + let embeddedWslEqualsStandalone: boolean | undefined; + if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsInstallerPath)) { + const wsl = inspectEmbeddedWsl(windowsInstallerPath, linuxArchivePath, scratch); + embeddedWslEqualsStandalone = wsl.equalsStandalone; + // Read the embedded archive's own provenance. + if (wsl.embedded !== undefined) { + const embeddedDir = NodeFS.mkdtempSync(NodePath.join(scratch, "wsl-")); + const embeddedFile = NodePath.join(embeddedDir, WSL_RUNTIME_ARCHIVE_NAME); + NodeFS.writeFileSync(embeddedFile, wsl.embedded); + provenance.windowsInstaller = readTarGzProvenance(embeddedFile, scratch); + } else { + provenance.windowsInstaller = null; + } + } + + NodeFS.rmSync(scratch, { recursive: true, force: true }); + console.log( + `Inspected packaged provenance: ${JSON.stringify( + { + ...provenance, + embeddedWslEqualsStandalone, + }, + null, + 2, + )}`, + ); + return embeddedWslEqualsStandalone === undefined + ? { ...provenance } + : { ...provenance, embeddedWslEqualsStandalone }; +} + +export { WSL_RUNTIME_ARCHIVE_HASH_NAME }; diff --git a/scripts/lib/fork-release-manifest.test.ts b/scripts/lib/fork-release-manifest.test.ts index 4336300efdbc..50d89feb2d2c 100644 --- a/scripts/lib/fork-release-manifest.test.ts +++ b/scripts/lib/fork-release-manifest.test.ts @@ -139,6 +139,95 @@ it("rejects an extra unrecorded asset and a manifest entry with no file", () => assert.match(absent.failures.join("\n"), /ghost\.zip .*not present/); }); +it("rejects a receipt that names the wrong target's artifact even when it passes", () => { + // Both W and M name the Linux tarball (the coordinator's reproduction): the + // receipts are wrong-target evidence, not acceptance of the installer/DMG. + const linuxTarball = `t3-${VERSION}-linux-x64.tar.gz`; + const linuxAsset = assets.find((asset) => asset.name === linuxTarball)!; + const crossed: NativeReceipt[] = [ + { + schemaVersion: 1, + owner: "W", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: linuxTarball, + assetSha256: linuxAsset.sha256, + result: "pass", + }, + { + schemaVersion: 1, + owner: "M", + target: "darwin-x64", + sourceSha: SHA, + version: VERSION, + assetName: linuxTarball, + assetSha256: linuxAsset.sha256, + result: "pass", + }, + ]; + const result = verifyCandidate({ + manifest: { ...manifest, nativeReceipts: crossed }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /win32-x64 .*must accept T3-Code-.*-x64\.exe/); + assert.match(result.failures.join("\n"), /darwin-x64 .*must accept T3-Code-.*-x64\.dmg/); +}); + +it("rejects conflicting receipts where a FAIL accompanies a PASS", () => { + const conflicting: NativeReceipt[] = [ + ...receipts, + { + schemaVersion: 1, + owner: "W2", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: `T3-Code-${VERSION}-x64.exe`, + assetSha256: assets[0]!.sha256, + result: "fail", + notes: "installer crash on launch", + }, + ]; + const result = verifyCandidate({ + manifest: { ...manifest, nativeReceipts: conflicting }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /native acceptance for win32-x64 is conflicting/); +}); + +it("rejects ambiguous acceptance where passes name different artifacts", () => { + const ambiguous: NativeReceipt[] = [ + receipts[0]!, + { + schemaVersion: 1, + owner: "W2", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + // A second, different artifact claimed for the same target. + assetName: `t3-${VERSION}-win32-x64.zip`, + assetSha256: assets.find((asset) => asset.name === `t3-${VERSION}-win32-x64.zip`)!.sha256, + result: "pass", + }, + receipts[1]!, + ]; + const result = verifyCandidate({ + manifest: { ...manifest, nativeReceipts: ambiguous }, + expected, + observedAssets: assets, + requireNativeReceipts: true, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /native acceptance for win32-x64 is ambiguous/); +}); + it("rejects missing, wrong-source, and wrong-version native receipts", () => { const missing = verifyCandidate({ manifest: { ...manifest, nativeReceipts: [] }, @@ -179,7 +268,63 @@ it("rejects missing, wrong-source, and wrong-version native receipts", () => { observedAssets: assets, requireNativeReceipts: true, }); - assert.match(failed.failures.join("\n"), /no passing native acceptance receipt/); + // A target with only fail receipts is disqualified; the message names the + // conflicting/failing acceptance rather than silently reporting success. + assert.equal(failed.ok, false); + assert.match( + failed.failures.join("\n"), + /native acceptance for (win32-x64|darwin-x64) is conflicting|no passing native acceptance/, + ); +}); + +it("per-target verification only requires that target's assets", () => { + const linuxOnly = assets.filter((asset) => asset.name === `t3-${VERSION}-linux-x64.tar.gz`); + const partial = verifyCandidate({ + manifest: { ...manifest, assets: linuxOnly, nativeReceipts: [] }, + expected, + observedAssets: linuxOnly, + targets: "linux", + }); + assert.deepEqual(partial, { ok: true, failures: [] }); + + // The same partial directory must fail the complete (all-targets) check. + const complete = verifyCandidate({ + manifest: { ...manifest, assets: linuxOnly, nativeReceipts: [] }, + expected, + observedAssets: linuxOnly, + targets: "all", + }); + assert.equal(complete.ok, false); + assert.match(complete.failures.join("\n"), /required asset .*x64\.exe is missing/); +}); + +it("rejects packaged provenance that does not match the source", () => { + const result = verifyCandidate({ + manifest, + expected, + observedAssets: assets, + targets: "all", + packagedProvenance: { + windowsInstaller: { + repository: "nullStack65/t3code", + sourceSha: DISPATCH, + version: VERSION, + platform: "win", + arch: "x64", + }, + linuxArchive: { + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + platform: "linux", + arch: "x64", + }, + embeddedWslEqualsStandalone: false, + }, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /Windows installer provenance sourceSha/); + assert.match(result.failures.join("\n"), /not byte-identical/); }); it("promotion refuses overwrite, older versions, and a missing authorization gate", () => { diff --git a/scripts/lib/fork-release-manifest.ts b/scripts/lib/fork-release-manifest.ts index 50e1e5fcace4..8098b70f9b92 100644 --- a/scripts/lib/fork-release-manifest.ts +++ b/scripts/lib/fork-release-manifest.ts @@ -8,6 +8,15 @@ * complete asset list, per-asset SHA-256, and the native acceptance receipts. * Promotion re-reads that artifact and verifies it instead of rebuilding. * + * Two correctness rules drive the shape of this module: + * + * 1. Every required native *target* (Windows x64, Intel macOS x64, Linux x64) + * is bound to the actual artifact(s) it is responsible for. A receipt that + * names the wrong artifact is rejected even if its result is `pass`. + * 2. Acceptance requires an unambiguous passing result per target. A + * conflicting `fail` receipt for a target makes the candidate unqualified; + * no PASS is allowed to win over a FAIL. + * * Everything here is pure: the caller supplies the manifest and the observed * hashes, so the rules can be exercised without a network or a build. */ @@ -68,6 +77,52 @@ export interface CandidateExpectations { /** The native targets whose acceptance a first release requires. */ export const REQUIRED_NATIVE_TARGETS = ["win32-x64", "darwin-x64"] as const; +/** + * The exact artifact(s) each required native target must accept. A receipt for + * `win32-x64` names the Windows installer, not (say) the Linux tarball; this is + * what makes "both receipts named the Linux tarball" a rejected candidate. + * + * Rule 1 of the module header: a target is bound to its own artifact. + */ +export function nativeTargetAssetNames(target: string, version: string): ReadonlyArray { + if (target === "win32-x64") return [`T3-Code-${version}-x64.exe`]; + if (target === "darwin-x64") return [`T3-Code-${version}-x64.dmg`]; + if (target === "linux-x64") return [`t3-${version}-linux-x64.tar.gz`]; + if (target === "darwin-arm64") return [`T3-Code-${version}-arm64.dmg`]; + return []; +} + +/** Which of `all`/`linux`/`win`/`mac` targets an invocation covers. */ +export type CandidateTargetSelection = "all" | "linux" | "win" | "mac"; + +/** The required assets for a target selection (subset during per-target builds). */ +export function requiredReleaseAssetNamesForTargets( + version: string, + targets: CandidateTargetSelection, + options: { readonly includeMacosArm64?: boolean } = {}, +): ReadonlyArray { + if (targets === "all") { + return requiredReleaseAssetNames(version, options); + } + if (targets === "linux") return [`t3-${version}-linux-x64.tar.gz`]; + if (targets === "win") { + return [`T3-Code-${version}-x64.exe`, `t3-${version}-win32-x64.zip`]; + } + const names = [`T3-Code-${version}-x64.dmg`]; + if (options.includeMacosArm64 === true) names.push(`T3-Code-${version}-arm64.dmg`); + return names; +} + +/** The native targets whose receipts a target selection requires. */ +export function requiredNativeTargetsForSelection( + targets: CandidateTargetSelection, +): ReadonlyArray { + if (targets === "all") return [...REQUIRED_NATIVE_TARGETS]; + if (targets === "win") return ["win32-x64"]; + if (targets === "mac") return ["darwin-x64"]; + return []; +} + export function requiredReleaseAssetNames( version: string, options: { readonly includeMacosArm64?: boolean } = {}, @@ -98,24 +153,106 @@ const ok = (): VerificationResult => ({ ok: true, failures: [] }); const failures = (list: ReadonlyArray): VerificationResult => list.length === 0 ? ok() : { ok: false, failures: list }; +/** + * The provenance actually observed inside the distributed artifacts. + * + * `undefined` means "not inspected" (for example during promotion, where the + * artifacts are re-downloaded but the packaged-provenance extraction happened + * at qualify time). An explicit `null` means "inspected and absent", which is a + * failure for a required artifact. + */ +export interface PackagedProvenance { + readonly windowsInstaller?: PackagedProvenanceRecord | null; + readonly linuxArchive?: PackagedProvenanceRecord | null; + readonly windowsZip?: PackagedProvenanceRecord | null; + readonly macDmg?: PackagedProvenanceRecord | null; + readonly macArm64Dmg?: PackagedProvenanceRecord | null; + /** Byte-identity of the WSL archive embedded in the installer vs the Linux archive. */ + readonly embeddedWslEqualsStandalone?: boolean; +} + +export interface PackagedProvenanceRecord { + readonly repository: string; + readonly sourceSha: string; + readonly version: string; + readonly platform: string; + readonly arch: string; +} + +export interface InspectProvenanceInput { + readonly version: string; + readonly targets: CandidateTargetSelection; + readonly artifacts: { + readonly hasWindowsInstaller: boolean; + readonly hasMacDmg: boolean; + readonly hasLinuxArchive: boolean; + readonly hasWindowsZip: boolean; + readonly hasMacArm64Dmg: boolean; + }; +} + +/** + * Pure decision table for which artifacts must carry inspected provenance for a + * target selection. The impure extraction (unpacking tarballs/zip/NSIS) lives + * in the caller; this function only says what must be verified. + */ +export function provenanceArtifactsForSelection( + input: InspectProvenanceInput, +): ReadonlyArray<"windowsInstaller" | "linuxArchive" | "windowsZip" | "macDmg" | "macArm64Dmg"> { + const wanted: Array< + "windowsInstaller" | "linuxArchive" | "windowsZip" | "macDmg" | "macArm64Dmg" + > = []; + const wantsAll = input.targets === "all"; + if ((wantsAll || input.targets === "win") && input.artifacts.hasWindowsInstaller) { + wanted.push("windowsInstaller"); + wanted.push("windowsZip"); + } + if ((wantsAll || input.targets === "linux") && input.artifacts.hasLinuxArchive) { + wanted.push("linuxArchive"); + } + if ((wantsAll || input.targets === "mac") && input.artifacts.hasMacDmg) { + wanted.push("macDmg"); + } + if ((wantsAll || input.targets === "mac") && input.artifacts.hasMacArm64Dmg) { + wanted.push("macArm64Dmg"); + } + return wanted; +} + +/** + * Thin wrapper the CLI uses: it does not extract bytes itself (that needs real + * archives on disk), so it returns an empty inspection and lets the separate + * `verify-windows-installer.ts` / archive extraction steps supply the records. + * Kept for interface stability; the CLI merges real records in. + */ +export function inspectCandidateProvenance(input: InspectProvenanceInput): PackagedProvenance { + void input; + return {}; +} + export interface VerifyCandidateInput { readonly manifest: ReleaseCandidateManifest; readonly expected: CandidateExpectations; /** Observed `sha256` and byte size for each file actually present. */ readonly observedAssets: ReadonlyArray; readonly includeMacosArm64?: boolean; + readonly targets?: CandidateTargetSelection; readonly requireNativeReceipts?: boolean; + /** Provenance read from the actual packaged bytes, when it was inspected. */ + readonly packagedProvenance?: PackagedProvenance | undefined; } /** * Verifies a candidate's manifest against the bytes on disk and the expected * source. Every required asset must be present with a matching digest, the * manifest must not disagree with the expected source, and each native receipt - * must bind to the same source and the same asset digest. + * must bind to the same source, the right artifact for its target, and the same + * asset digest. Conflicting or ambiguous acceptance is rejected. */ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult { const problems: string[] = []; const { manifest, expected } = input; + const targets: CandidateTargetSelection = input.targets ?? "all"; if (manifest.schemaVersion !== CANDIDATE_SCHEMA_VERSION) { problems.push(`manifest schemaVersion is ${manifest.schemaVersion}`); @@ -131,7 +268,7 @@ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult } const observed = new Map(input.observedAssets.map((asset) => [asset.name, asset])); - const required = requiredReleaseAssetNames(expected.version, { + const required = requiredReleaseAssetNamesForTargets(expected.version, targets, { includeMacosArm64: input.includeMacosArm64 === true, }); const manifestAssets = new Map(manifest.assets.map((asset) => [asset.name, asset])); @@ -169,19 +306,59 @@ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult } } - const receipts = manifest.nativeReceipts; - if (input.requireNativeReceipts === true) { - for (const target of REQUIRED_NATIVE_TARGETS) { - const matching = receipts.filter((receipt) => receipt.target === target); - if (matching.length === 0) { - problems.push(`no native acceptance receipt for ${target}`); - continue; - } - if (!matching.some((receipt) => receipt.result === "pass")) { - problems.push(`no passing native acceptance receipt for ${target}`); - } - } + verifyReceipts({ + problems, + receipts: manifest.nativeReceipts, + expected, + manifestAssets, + targets, + requireNativeReceipts: input.requireNativeReceipts === true, + includeMacosArm64: input.includeMacosArm64 === true, + }); + + if (input.packagedProvenance !== undefined) { + verifyPackagedProvenance({ + problems, + provenance: input.packagedProvenance, + expected, + targets, + includeMacosArm64: input.includeMacosArm64 === true, + }); } + + return failures(problems); +} + +/** + * Binds every receipt to its target's actual artifact, to the candidate source + * and version, and to the observed digest. Then requires exactly one + * unambiguous verdict per required target: at least one `pass`, and no `fail`. + */ +export function verifyReceipts(input: { + readonly problems: string[]; + readonly receipts: ReadonlyArray; + readonly expected: CandidateExpectations; + readonly manifestAssets: ReadonlyMap; + readonly targets: CandidateTargetSelection; + readonly requireNativeReceipts: boolean; + readonly includeMacosArm64: boolean; +}): void { + const { + problems, + receipts, + expected, + manifestAssets, + targets, + requireNativeReceipts, + includeMacosArm64, + } = input; + + const allowedTargets = new Set([ + ...requiredNativeTargetsForSelection(targets), + ...(targets === "all" || targets === "mac" ? ["linux-x64"] : []), + ...(includeMacosArm64 && (targets === "all" || targets === "mac") ? ["darwin-arm64"] : []), + ]); + for (const receipt of receipts) { if (receipt.schemaVersion !== NATIVE_RECEIPTS_SCHEMA_VERSION) { problems.push(`receipt for ${receipt.target} has schemaVersion ${receipt.schemaVersion}`); @@ -196,6 +373,19 @@ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult `receipt for ${receipt.target} is for version ${receipt.version}, expected ${expected.version}`, ); } + if (receipt.target !== "" && !allowedTargets.has(receipt.target) && targets !== "all") { + problems.push(`receipt names target ${receipt.target}, which this build did not produce`); + } + + // Rule 1: a target may only accept the artifact(s) it owns. A Windows + // receipt naming the Linux tarball is wrong-target evidence, not a pass. + const owned = nativeTargetAssetNames(receipt.target, expected.version); + if (owned.length > 0 && !owned.includes(receipt.assetName)) { + problems.push( + `receipt for ${receipt.target} names ${receipt.assetName}, but that target must accept ${owned.join(" or ")}`, + ); + } + const asset = manifestAssets.get(receipt.assetName); if (asset === undefined) { problems.push(`receipt for ${receipt.target} names unknown asset ${receipt.assetName}`); @@ -206,7 +396,108 @@ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult } } - return failures(problems); + if (!requireNativeReceipts) return; + + const requiredTargets = [ + ...requiredNativeTargetsForSelection(targets), + ...(includeMacosArm64 && targets === "all" ? ["darwin-arm64"] : []), + ]; + for (const target of requiredTargets) { + const matching = receipts.filter((receipt) => receipt.target === target); + if (matching.length === 0) { + problems.push(`no native acceptance receipt for ${target}`); + continue; + } + // Rule 2: ambiguity and conflict both fail closed. A FAIL for the target + // is disqualifying even when a PASS also exists. + const passes = matching.filter((receipt) => receipt.result === "pass"); + const fails = matching.filter((receipt) => receipt.result === "fail"); + if (fails.length > 0) { + problems.push( + `native acceptance for ${target} is conflicting: ${fails.length} fail receipt(s) and ${passes.length} pass receipt(s)`, + ); + continue; + } + if (passes.length === 0) { + problems.push(`no passing native acceptance receipt for ${target}`); + continue; + } + const distinctAssets = new Set(passes.map((receipt) => receipt.assetName)); + if (distinctAssets.size > 1) { + problems.push( + `native acceptance for ${target} is ambiguous: passes name ${[...distinctAssets].join(", ")}`, + ); + } + if (distinctAssets.size === 1) { + const assetName = [...distinctAssets][0]!; + const owned = nativeTargetAssetNames(target, expected.version); + if (owned.length > 0 && !owned.includes(assetName)) { + problems.push( + `native acceptance for ${target} accepted ${assetName}, but that target owns ${owned.join(" or ")}`, + ); + } + } + } +} + +/** Requires inspected provenance to name the same repository/source/version/platform. */ +export function verifyPackagedProvenance(input: { + readonly problems: string[]; + readonly provenance: PackagedProvenance; + readonly expected: CandidateExpectations; + readonly targets: CandidateTargetSelection; + readonly includeMacosArm64: boolean; +}): void { + const { problems, provenance, expected, targets, includeMacosArm64 } = input; + const wantsOptional = targets === "all" && includeMacosArm64; + const checks: Array<[string, PackagedProvenanceRecord | null | undefined, string, string]> = [ + ["Windows installer", provenance.windowsInstaller, "win", "x64"], + ["Windows CLI archive", provenance.windowsZip, "win", "x64"], + ["Linux runtime archive", provenance.linuxArchive, "linux", "x64"], + ["Intel macOS DMG", provenance.macDmg, "darwin", "x64"], + ]; + if (wantsOptional) { + checks.push(["Apple Silicon DMG", provenance.macArm64Dmg, "darwin", "arm64"]); + } + // Only enforced when the WSL payload was actually inspected. `undefined` + // means the caller did not extract it (for example a pure manifest check). + if (targets === "all" && provenance.embeddedWslEqualsStandalone === false) { + problems.push( + "the WSL runtime embedded in the Windows installer is not byte-identical to the standalone Linux archive", + ); + } + if ( + targets === "all" && + provenance.windowsInstaller !== undefined && + provenance.linuxArchive !== undefined && + provenance.embeddedWslEqualsStandalone === undefined + ) { + problems.push( + "the Windows installer was inspected but its embedded WSL runtime was not compared to the standalone Linux archive", + ); + } + for (const [label, record, platform, arch] of checks) { + if (record === undefined) continue; // Not inspected in this invocation. + if (record === null) { + problems.push(`${label} has no readable packaged provenance`); + continue; + } + if (record.repository !== expected.repository) { + problems.push(`${label} provenance repository is ${record.repository}`); + } + if (record.sourceSha !== expected.sourceSha) { + problems.push(`${label} provenance sourceSha is ${record.sourceSha}`); + } + if (record.version !== expected.version) { + problems.push(`${label} provenance version is ${record.version}`); + } + if (record.platform !== platform) { + problems.push(`${label} provenance platform is ${record.platform}, expected ${platform}`); + } + if (record.arch !== arch) { + problems.push(`${label} provenance arch is ${record.arch}, expected ${arch}`); + } + } } export interface VerifyPromotionInput extends VerifyCandidateInput { @@ -224,7 +515,7 @@ export interface VerifyPromotionInput extends VerifyCandidateInput { * authorization gate must actually exist. */ export function verifyPromotion(input: VerifyPromotionInput): VerificationResult { - const candidate = verifyCandidate(input); + const candidate = verifyCandidate({ ...input, targets: "all", packagedProvenance: undefined }); const problems = [...candidate.failures]; if (input.tagTargetSha !== input.expected.sourceSha) { @@ -248,7 +539,7 @@ export function verifyPromotion(input: VerifyPromotionInput): VerificationResult } if (!input.authorizationGateExists) { problems.push( - `publication authorization gate (environment '${RELEASE_ENVIRONMENT}') is not configured with protection rules`, + `publication authorization gate (environment '${RELEASE_ENVIRONMENT}') is not configured with required reviewers`, ); } diff --git a/scripts/lib/fork-release-workflow.test.ts b/scripts/lib/fork-release-workflow.test.ts index 612b24516270..ac9321aca1cf 100644 --- a/scripts/lib/fork-release-workflow.test.ts +++ b/scripts/lib/fork-release-workflow.test.ts @@ -80,6 +80,70 @@ it.effect("no job silently defaults to a GitHub-hosted runner label", () => }), ); +it.effect("authorization runs before any build job and uses owner variables, not inputs", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const authorize = jobBlock(text, "authorize"); + assert.include(authorize, "T3CODE_AUTHORIZED_RUNNERS"); + // Runner labels come from repository variables, never caller inputs. + assert.notInclude(text, "inputs.linux_runner"); + assert.notInclude(text, "inputs.windows_runner"); + assert.notInclude(text, "inputs.macos_x64_runner"); + assert.notInclude(text, "inputs.macos_arm64_runner"); + // Every build job transitively depends on authorization. + assert.include(jobBlock(text, "preflight"), "needs: [authorize]"); + assert.include(jobBlock(text, "bundle"), "needs: [preflight]"); + }), +); + +it.effect( + "fresh jobs select source before installing dependencies, without workspace imports", + () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + // The selector must run before `vp install` in preflight, bundle, qualify. + for (const job of ["preflight", "bundle", "cli_linux_x64", "qualify"]) { + const block = jobBlock(text, job); + const sourceIndex = block.indexOf("select-release-source.ts"); + const installIndex = block.indexOf("run: vp install"); + assert.notEqual(sourceIndex, -1, `${job} must select the source`); + if (installIndex !== -1) { + assert.isBelow(sourceIndex, installIndex, `${job} must select source before install`); + } + } + }), +); + +it.effect("promotion consumes the frozen candidate identity and requires reviewer approval", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const publish = jobBlock(text, "publish"); + assert.include(publish, "candidate-identity.json"); + assert.include(publish, "manifestSha256"); + assert.include(publish, "required_reviewers"); + assert.include(publish, "actions: read"); + assert.notInclude(publish, "build-cli-archive.ts"); + assert.notInclude(publish, "build-desktop-artifact.ts"); + + // A real receipt import path exists and binds receipts to a candidate run. + const receipts = jobBlock(text, "receipts"); + assert.include(receipts, "upload_receipts"); + assert.include(receipts, "receipts_source_run_id"); + assert.include(receipts, "fork-release-native-receipts"); + assert.include(receipts, "upload-artifact"); + }), +); + +it.effect("preflight selects source before setup-vp install to keep the job dependency-free", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const preflight = jobBlock(text, "preflight"); + // setup-vp must not eagerly install workspace packages in preflight. + assert.include(preflight, "run-install: false"); + assert.include(preflight, "--mode public"); + }), +); + it.effect("publication promotes a candidate by run id and never rebuilds", () => Effect.gen(function* () { const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); diff --git a/scripts/lib/release-source.test.ts b/scripts/lib/release-source.test.ts index 2ef143434f17..4f68af7b33cc 100644 --- a/scripts/lib/release-source.test.ts +++ b/scripts/lib/release-source.test.ts @@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("release-source", (it) => { }), ); - it.effect("rejects a commit that is not an ancestor of main", () => + it.effect("rejects a commit that is not an ancestor of main in public mode", () => Effect.gen(function* () { const fixture = yield* Effect.promise(createFixture); try { @@ -164,6 +164,47 @@ it.layer(NodeServices.layer)("release-source", (it) => { }), ); + it.effect("candidate mode accepts a fork PR SHA that is not on main", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const selected = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaOffMain, + mainRef: "main", + mode: "candidate", + }); + assert.equal(selected.sha, fixture.shaOffMain); + assert.equal(selected.headSha, fixture.shaOffMain); + assert.equal(selected.mode, "candidate"); + assert.equal(selected.ancestry, "on-fork"); + // The real checkout is the pre-merge PR head, not main's tip. + assert.equal(git(fixture.work, ["rev-parse", "HEAD"]), fixture.shaOffMain); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + + it.effect("candidate mode still reports on-main ancestry when the SHA is on main", () => + Effect.gen(function* () { + const fixture = yield* Effect.promise(createFixture); + try { + const selected = yield* selectReleaseSource({ + cwd: fixture.work, + repoUrl: fixture.origin, + sha: fixture.shaA, + mainRef: "main", + mode: "candidate", + }); + assert.equal(selected.ancestry, "on-main"); + } finally { + yield* Effect.promise(() => cleanup(fixture.root)); + } + }), + ); + it.effect("rejects a SHA that is not a full 40-character commit", () => Effect.gen(function* () { const fixture = yield* Effect.promise(createFixture); diff --git a/scripts/lib/release-source.ts b/scripts/lib/release-source.ts index 6b5f4cb544ed..504146aae648 100644 --- a/scripts/lib/release-source.ts +++ b/scripts/lib/release-source.ts @@ -22,6 +22,29 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const FULL_SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/; export const DEFAULT_MAIN_REF = "main"; +/** + * Why a source SHA is allowed to be built. + * + * - `public`: the SHA must already be an ancestor of the fork's `main`. This is + * the policy a *published* fork release requires, so a candidate built from a + * PR branch can never be promoted under the public policy later without a + * fresh, verified checkout of the approved main-line source. + * - `candidate`: the SHA only has to be a real commit on an explicitly resolved + * fork remote. It exists so a pre-merge PR head (which is not on `main`) can + * still be built and exercised locally. It never substitutes for the public + * policy and never claims main ancestry the commit does not have. + */ +export type ReleaseSourceMode = "public" | "candidate"; + +export class SourceNotOnForkError extends Schema.TaggedError()( + "SourceNotOnForkError", + { sha: Schema.String, forkRemote: Schema.String }, +) { + override get message(): string { + return `Candidate source ${this.sha} is not a commit on the writable fork remote '${this.forkRemote}'.`; + } +} + export class InvalidSourceShaError extends Schema.TaggedError()( "InvalidSourceShaError", { sha: Schema.String }, @@ -111,13 +134,26 @@ export interface ReleaseSourceSelection { readonly mainRef: string; readonly headSha: string; readonly repository: string; + readonly mode: ReleaseSourceMode; + /** The ancestry policy actually applied, for the receipt/log. */ + readonly ancestry: "on-main" | "on-fork"; } export interface SelectReleaseSourceInput { readonly cwd: string; + /** + * The writable fork remote URL to fetch the source from. It is resolved + * explicitly by the caller rather than assumed to be `origin`: the Windows + * checkout names upstream `origin` and the fork `fork`. + */ readonly repoUrl: string; readonly sha: string; readonly mainRef?: string; + /** + * `public` (default) requires ancestry on `main`; `candidate` accepts any + * commit reachable on the fork remote so a pre-merge PR head can be built. + */ + readonly mode?: ReleaseSourceMode; /** `owner/repo`, recorded for provenance output. Defaults to the URL's path. */ readonly repository?: string; } @@ -131,8 +167,12 @@ const repositoryFromUrl = (url: string): string => /** * Fetches and checks out exactly `input.sha`, then asserts the checkout and the - * ancestry. The explicit SHA is the only checkout target, so a later fetch of - * `main` cannot change what was selected. + * ancestry policy for the requested mode. The explicit SHA is the only checkout + * target, so a later fetch of `main` cannot change what was selected. + * + * `public` mode additionally requires the SHA to be an ancestor of + * `origin/
`. `candidate` mode instead requires the SHA to be reachable on + * the fork remote; it never fabricates main ancestry. */ export const selectReleaseSource = Effect.fn("selectReleaseSource")(function* ( input: SelectReleaseSourceInput, @@ -142,6 +182,7 @@ export const selectReleaseSource = Effect.fn("selectReleaseSource")(function* ( return yield* new InvalidSourceShaError({ sha: input.sha }); } const mainRef = input.mainRef?.trim() || DEFAULT_MAIN_REF; + const mode: ReleaseSourceMode = input.mode === "candidate" ? "candidate" : "public"; yield* runGit(input.cwd, ["init", "."]); const add = yield* runGit(input.cwd, ["remote", "add", "origin", input.repoUrl]); @@ -168,8 +209,30 @@ export const selectReleaseSource = Effect.fn("selectReleaseSource")(function* ( sha, `origin/${mainRef}`, ]); - if (ancestor.exitCode !== 0) { - return yield* new SourceNotOnMainError({ sha, mainRef }); + if (mode === "public") { + if (ancestor.exitCode !== 0) { + return yield* new SourceNotOnMainError({ sha, mainRef }); + } + return { + sha, + mainRef, + headSha, + repository: input.repository?.trim() || repositoryFromUrl(input.repoUrl), + mode, + ancestry: "on-main", + } satisfies ReleaseSourceSelection; + } + + // Candidate mode: the commit must at least exist on the fork remote, so a + // typo or a SHA from an unrelated repository is still rejected. Reachability, + // not main ancestry, is the check. It is deliberately not required to be on + // `main`; the reported ancestry records the truth either way. + const onMain = ancestor.exitCode === 0; + if (!onMain) { + const exists = yield* runGit(input.cwd, ["cat-file", "-e", `${sha}^{commit}`]); + if (exists.exitCode !== 0) { + return yield* new SourceNotOnForkError({ sha, forkRemote: "origin" }); + } } return { @@ -177,5 +240,7 @@ export const selectReleaseSource = Effect.fn("selectReleaseSource")(function* ( mainRef, headSha, repository: input.repository?.trim() || repositoryFromUrl(input.repoUrl), + mode, + ancestry: onMain ? "on-main" : "on-fork", } satisfies ReleaseSourceSelection; }); diff --git a/scripts/select-release-source.ts b/scripts/select-release-source.ts index f2010a307e91..0a986b144fe2 100644 --- a/scripts/select-release-source.ts +++ b/scripts/select-release-source.ts @@ -1,84 +1,193 @@ #!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - Runs before dependencies are installed in a fresh CI job, so it must not import workspace/Effect packages. /** * Selects and verifies the exact source commit a fork release builds. * * Run from the release workspace after the bootstrap fetch. It re-runs the * selection authoritatively: fetch the requested SHA and `main`, check out the - * explicit SHA, assert `HEAD` equals it, and assert the SHA is an ancestor of - * `origin/main`. Writes `sha`, `head_sha`, and `workflow_sha` to `GITHUB_OUTPUT` - * when available so a later job can bind provenance to the actual checkout. + * explicit SHA, assert `HEAD` equals it, and apply the requested ancestry + * policy. Writes `sha`, `head_sha`, `workflow_sha`, and `mode` to + * `GITHUB_OUTPUT` when available so a later job can bind provenance to the + * actual checkout. + * + * `--mode public` (default) requires the SHA to be an ancestor of `main`. + * `--mode candidate` accepts any commit reachable on the fork remote, which is + * what lets a pre-merge PR head be built without faking main ancestry. + * + * This file intentionally uses only Node built-ins and plain `git` subprocesses: + * it runs in a freshly bootstrapped job *before* `vp install`, so importing + * `effect`/`@effect/platform-node` would fail to resolve. */ -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Console from "effect/Console"; -import * as Config from "effect/Config"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import { Command, Flag } from "effect/unstable/cli"; - -import { selectReleaseSource } from "./lib/release-source.ts"; - -const command = Command.make( - "select-release-source", - { - repoUrl: Flag.String("repo-url").pipe( - Flag.withDescription("Git remote URL to fetch the release source from."), - ), - sha: Flag.String("sha").pipe( - Flag.withDescription("Full 40-character source commit to check out."), - ), - mainRef: Flag.String("main-ref").pipe( - Flag.withDescription("Branch the source must be an ancestor of."), - Flag.withDefault("main"), - ), - cwd: Flag.String("cwd").pipe( - Flag.withDescription("Workspace directory to run git in."), - Flag.optional, - ), - githubOutput: Flag.Boolean("github-output").pipe( - Flag.withDescription("Append sha/head_sha/workflow_sha to GITHUB_OUTPUT."), - Flag.withDefault(false), - ), - }, - ({ repoUrl, sha, mainRef, cwd, githubOutput }) => - Effect.gen(function* () { - const selected = yield* selectReleaseSource({ - cwd: Option.getOrElse(cwd, () => process.cwd()), - repoUrl, - sha, - mainRef, - }); - const workflowSha = process.env.GITHUB_SHA?.trim() ?? ""; - yield* Console.log( - `Selected source ${selected.sha} (HEAD ${selected.headSha}, ancestor of origin/${selected.mainRef}, repo ${selected.repository})`, - ); - if (workflowSha !== "" && workflowSha.toLowerCase() !== selected.sha) { - yield* Console.log( - `Workflow revision ${workflowSha} differs from the selected source; it is recorded separately, not as source provenance.`, - ); - } - if (githubOutput) { - const outputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT"); - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - outputPath, - [ - `sha=${selected.sha}`, - `head_sha=${selected.headSha}`, - `workflow_sha=${workflowSha}`, - "", - ].join("\n"), - { flag: "a" }, - ); - } - }), -).pipe(Command.withDescription("Check out and verify the exact release source commit.")); - -if (import.meta.main) { - Command.run(command, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(NodeServices.layer), - NodeRuntime.runMain, +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; + +const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/; +const DEFAULT_MAIN_REF = "main"; + +type ReleaseSourceMode = "public" | "candidate"; + +interface Args { + repoUrl: string; + sha: string; + mainRef: string; + cwd: string; + mode: ReleaseSourceMode; + githubOutput: boolean; +} + +function parseArgs(argv: ReadonlyArray): Args { + const values = new Map(); + const flags = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + values.set(key, next); + index += 1; + } else { + flags.add(key); + } + } + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.trim() === "") throw new Error(`--${key} is required`); + return value.trim(); + }; + const mode = values.get("mode")?.trim() || "public"; + if (mode !== "public" && mode !== "candidate") { + throw new Error("--mode must be public or candidate"); + } + return { + repoUrl: required("repo-url"), + sha: required("sha").toLowerCase(), + mainRef: values.get("main-ref")?.trim() || DEFAULT_MAIN_REF, + cwd: values.get("cwd")?.trim() || process.cwd(), + mode, + githubOutput: flags.has("github-output"), + }; +} + +interface GitResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +function runGit(cwd: string, args: ReadonlyArray): GitResult { + const result = NodeChildProcess.spawnSync("git", [...args], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + stdout: (result.stdout ?? "").trim(), + stderr: (result.stderr ?? "").trim(), + exitCode: result.status ?? 1, + }; +} + +function runGitChecked(cwd: string, args: ReadonlyArray): GitResult { + const result = runGit(cwd, args); + if (result.exitCode !== 0) { + throw new Error(`git ${args.join(" ")} exited ${result.exitCode}: ${result.stderr}`); + } + return result; +} + +/** Accepts `owner/repo`, a GitHub URL, or an ssh remote and returns `owner/repo`. */ +export function repositoryFromUrl(url: string): string { + return url + .replace(/^https?:\/\/[^/]+\//, "") + .replace(/^ssh:\/\/[^/]+\//, "") + .replace(/^git@[^:]+:/, "") + .replace(/\.git$/, ""); +} + +interface Selection { + readonly sha: string; + readonly headSha: string; + readonly repository: string; + readonly mode: ReleaseSourceMode; + readonly ancestry: "on-main" | "on-fork"; +} + +export function selectReleaseSource(input: Args): Selection { + const sha = input.sha; + if (!FULL_SHA_PATTERN.test(sha)) { + throw new Error(`Release source SHA '${input.sha}' is not a full 40-character hex commit.`); + } + const cwd = input.cwd; + + runGit(cwd, ["init", "."]); + const add = runGit(cwd, ["remote", "add", "origin", input.repoUrl]); + if (add.exitCode !== 0) { + runGitChecked(cwd, ["remote", "set-url", "origin", input.repoUrl]); + } + + runGitChecked(cwd, ["fetch", "--no-tags", "--depth=1", "origin", sha]); + runGitChecked(cwd, ["fetch", "--no-tags", "origin", input.mainRef]); + + // Check out the validated SHA itself, never `FETCH_HEAD`. + runGitChecked(cwd, ["checkout", "--detach", sha]); + + const headSha = runGitChecked(cwd, ["rev-parse", "HEAD"]).stdout.toLowerCase(); + if (headSha !== sha) { + throw new Error(`Checked out HEAD ${headSha} does not match the requested source ${sha}.`); + } + + const onMain = + runGit(cwd, ["merge-base", "--is-ancestor", sha, `origin/${input.mainRef}`]).exitCode === 0; + + if (input.mode === "public" && !onMain) { + throw new Error(`Release source ${sha} is not an ancestor of origin/${input.mainRef}.`); + } + if (input.mode === "candidate" && !onMain) { + const exists = runGit(cwd, ["cat-file", "-e", `${sha}^{commit}`]); + if (exists.exitCode !== 0) { + throw new Error(`Candidate source ${sha} is not a commit on the fork remote.`); + } + } + + return { + sha, + headSha, + repository: repositoryFromUrl(input.repoUrl), + mode: input.mode, + ancestry: onMain ? "on-main" : "on-fork", + }; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const selected = selectReleaseSource(args); + console.log( + `Selected source ${selected.sha} (HEAD ${selected.headSha}, mode ${selected.mode}, ancestry ${selected.ancestry}, repo ${selected.repository})`, ); + const workflowSha = process.env.GITHUB_SHA?.trim() ?? ""; + if (workflowSha !== "" && workflowSha.toLowerCase() !== selected.sha) { + console.log( + `Workflow revision ${workflowSha} differs from the selected source; it is recorded separately, not as source provenance.`, + ); + } + if (args.githubOutput) { + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath === undefined || outputPath.trim() === "") { + throw new Error("--github-output requires GITHUB_OUTPUT"); + } + NodeFS.appendFileSync( + outputPath, + [ + `sha=${selected.sha}`, + `head_sha=${selected.headSha}`, + `workflow_sha=${workflowSha}`, + `source_mode=${selected.mode}`, + `source_ancestry=${selected.ancestry}`, + "", + ].join("\n"), + ); + } } + +main(); diff --git a/scripts/stage-candidate-asset.ts b/scripts/stage-candidate-asset.ts new file mode 100644 index 000000000000..863036a10423 --- /dev/null +++ b/scripts/stage-candidate-asset.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - A tiny file-staging helper; no workspace imports so it runs on a bare checkout. +/** + * Stages one built artifact into the shared candidate directory. + * + * The per-platform build scripts write their outputs either directly into the + * requested `--output-dir` or into the repo's default `release/` directory. + * This helper resolves the artifact by name in either place and copies it into + * the shared candidate directory, refusing to silently overwrite a file whose + * bytes differ (which would signal mixed sources). + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +interface Args { + file: string; + outputDir: string; + sourceDir: string | undefined; +} + +function parseArgs(argv: ReadonlyArray): Args { + const values = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (!token.startsWith("--")) continue; + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + values.set(token.slice(2), next); + index += 1; + } + } + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.trim() === "") throw new Error(`--${key} is required`); + return value.trim(); + }; + return { + file: required("file"), + outputDir: required("output-dir"), + sourceDir: values.get("source-dir")?.trim(), + }; +} + +const SEARCH_DIRS = ["release", "release-cli", "."]; + +function findArtifact(name: string, sourceDir: string | undefined): string | undefined { + const candidates = [sourceDir, ...SEARCH_DIRS].filter( + (dir): dir is string => dir !== undefined && dir !== "", + ); + for (const dir of candidates) { + const full = NodePath.join(dir, name); + if (NodeFS.existsSync(full) && NodeFS.statSync(full).isFile()) return full; + } + return undefined; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + NodeFS.mkdirSync(args.outputDir, { recursive: true }); + const destination = NodePath.join(args.outputDir, args.file); + + // Already staged by the build step (desktop builds write straight here). + if (NodeFS.existsSync(destination)) { + console.log(`Already staged: ${args.file}`); + return; + } + + const source = findArtifact(args.file, args.sourceDir); + if (source === undefined) { + throw new Error(`could not find built artifact ${args.file} to stage into ${args.outputDir}`); + } + NodeFS.copyFileSync(source, destination); + console.log(`Staged ${source} -> ${destination}`); +} + +main(); diff --git a/scripts/verify-fork-candidate.ts b/scripts/verify-fork-candidate.ts index db75244db8d7..8a65da6093a2 100644 --- a/scripts/verify-fork-candidate.ts +++ b/scripts/verify-fork-candidate.ts @@ -1,12 +1,14 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off - A self-contained CI verification utility over plain files. +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off globalProcessRuntime:off - A self-contained CI verification utility over plain files. /** * Verifies (and optionally freezes) a fork release candidate. * - * Two jobs share this tool so the candidate bytes are checked the same way in - * both: + * Three jobs share this tool so the candidate bytes are checked the same way in + * all of them: + * - per-target local builds verify only their own platform's artifacts + * (`--targets linux`) so a Linux-only build can pass before macOS exists; * - `qualify` freezes the candidate: write the manifest, write SHA256SUMS - * from the observed bytes, then verify everything agrees. + * from the observed bytes, then verify the complete required set; * - `publish` re-verifies the *downloaded* artifact and the promotion-level * rules (tag target, no overwrite, version ordering, authorization gate) * before creating a release. @@ -17,6 +19,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import { inspectCandidateProvenance } from "./lib/candidate-provenance-inspect.ts"; import { CANDIDATE_MANIFEST_FILE_NAME, NATIVE_RECEIPTS_FILE_NAME, @@ -29,6 +32,7 @@ import { sha256Hex, verifyCandidate, verifyPromotion, + type CandidateTargetSelection, type NativeReceipt, type ReleaseAsset, type ReleaseCandidateManifest, @@ -40,6 +44,8 @@ interface Args { sha: string; repository: string; includeMacosArm64: boolean; + targets: CandidateTargetSelection; + inspectProvenance: boolean; requireNativeReceipts: boolean; writeManifest: boolean; writeChecksums: boolean; @@ -81,12 +87,18 @@ function parseArgs(argv: ReadonlyArray): Args { } return value.trim(); }; + const targets = (values.get("targets")?.trim() || "all") as CandidateTargetSelection; + if (!["all", "linux", "win", "mac"].includes(targets)) { + throw new Error("--targets must be all, linux, win, or mac"); + } return { candidateDir: required("candidate-dir"), version: required("version"), sha: required("sha").toLowerCase(), repository: required("repository"), includeMacosArm64: bool("include-macos-arm64"), + targets, + inspectProvenance: !bool("skip-provenance-inspection"), requireNativeReceipts: bool("require-native-receipts"), writeManifest: bool("write-manifest"), writeChecksums: bool("write-checksums"), @@ -251,14 +263,24 @@ function main(): void { expected, observedAssets, includeMacosArm64: args.includeMacosArm64, + targets: args.targets, requireNativeReceipts: args.requireNativeReceipts, + packagedProvenance: + args.promote || !args.inspectProvenance + ? undefined + : inspectCandidateProvenance({ + candidateDir: args.candidateDir, + version: args.version, + targets: args.targets, + includeMacosArm64: args.includeMacosArm64, + }), }); if (!result.ok) { fail(result.failures); } console.log( - `Candidate verified: ${observedAssets.length} assets for ${args.repository} v${args.version} @ ${args.sha}.`, + `Candidate verified: ${observedAssets.length} assets for ${args.repository} v${args.version} @ ${args.sha} (targets: ${args.targets}).`, ); if (args.promote) { console.log( diff --git a/scripts/verify-windows-installer.ts b/scripts/verify-windows-installer.ts index 7fc722af4e85..ba29411201ed 100644 --- a/scripts/verify-windows-installer.ts +++ b/scripts/verify-windows-installer.ts @@ -1,10 +1,20 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - A CI verification utility that shells out to 7-Zip and tar. +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalProcessRuntime:off - A CI verification utility that shells out to 7-Zip and tar. /** * Verifies the WSL runtime embedded in a Windows installer against the - * standalone Linux x64 archive. Run on the `qualify` runner after installing - * `p7zip-full`. The pure comparison lives in `lib/wsl-payload.ts`; this file - * only extracts the bytes. + * standalone Linux x64 archive. + * + * This exercises the *real installer layout*: on Windows it runs the NSIS + * installer to a throwaway temporary directory (not the default install path), + * then inspects the extracted `resources/wsl-runtime.tar.gz`. A pure comparison + * of two byte arrays would not prove the extractor reaches the nested payload, + * so the installer is actually executed and its resources directory is located + * on disk. On non-Windows hosts it falls back to unpacking the installer with + * 7-Zip when available. + * + * The pure comparison lives in `lib/wsl-payload.ts`; this file only extracts the + * bytes and, with `--emit-json`, writes the observed provenance for the + * aggregate verifier to consume. */ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -13,6 +23,7 @@ import * as NodePath from "node:path"; import { parseBuildInfo } from "./lib/source-provenance.ts"; import { verifyEmbeddedWslRuntime, type EmbeddedBuildInfo } from "./lib/wsl-payload.ts"; +import { WSL_RUNTIME_ARCHIVE_NAME } from "./build-desktop-artifact.ts"; interface Args { installer: string; @@ -21,6 +32,7 @@ interface Args { sourceSha: string; version: string; arch: string; + emitJson: string | undefined; } function parseArgs(argv: ReadonlyArray): Args { @@ -46,16 +58,43 @@ function parseArgs(argv: ReadonlyArray): Args { sourceSha: required("sha").toLowerCase(), version: required("version"), arch: values.get("arch")?.trim() || "x64", + emitJson: values.get("emit-json")?.trim(), }; } -const run = (command: string, args: ReadonlyArray): void => { +const run = ( + command: string, + args: ReadonlyArray, + options: { allowFailure?: boolean } = {}, +): number => { const result = NodeChildProcess.spawnSync(command, args, { stdio: "inherit" }); - if (result.status !== 0) { - throw new Error(`${command} ${args.join(" ")} exited ${result.status ?? "unknown"}`); + const status = result.status ?? 1; + if (status !== 0 && options.allowFailure !== true) { + throw new Error(`${command} ${args.join(" ")} exited ${status}`); } + return status; }; +const which = (command: string): string | undefined => { + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + const finder = process.platform === "win32" ? "where" : "which"; + const result = NodeChildProcess.spawnSync(finder, [command], { encoding: "utf8" }); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/)[0]?.trim() || undefined; +}; + +function detectSevenZip(): string | undefined { + for (const candidate of ["7z", "7zz", "7za"]) { + if (which(candidate) !== undefined) return candidate; + } + for (const root of [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]) { + if (root === undefined) continue; + const candidate = NodePath.join(root, "7-Zip", "7z.exe"); + if (NodeFS.existsSync(candidate)) return candidate; + } + return undefined; +} + function findFile(root: string, name: string): string | undefined { const entries = NodeFS.readdirSync(root, { withFileTypes: true }); for (const entry of entries) { @@ -78,24 +117,62 @@ function readArchiveInfo(archive: string, scratch: string): EmbeddedBuildInfo | return parseBuildInfo(NodeFS.readFileSync(infoPath, "utf8")) as EmbeddedBuildInfo; } +/** + * Extracts the Windows installer through its real NSIS flow when possible. + * + * Windows: run the installer with `/S /D=` so NSIS performs the real + * silent install into an isolated directory; the WSL archive then lives at + * `resources/wsl-runtime.tar.gz` exactly as an end user's install does. + * Otherwise: unpack with 7-Zip and locate the same relative path. + */ +function extractInstaller(installer: string, scratch: string): string { + const installDir = NodePath.join(scratch, "nsis-install"); + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + if (process.platform === "win32") { + NodeFS.mkdirSync(installDir, { recursive: true }); + // NSIS requires /D to be last and unquoted; a path with spaces is fine. + const status = NodeChildProcess.spawnSync(installer, ["/S", `/D=${installDir}`], { + stdio: "inherit", + }).status; + if (status === 0) { + const resources = NodePath.join(installDir, "resources"); + if (NodeFS.existsSync(resources)) return resources; + } else { + console.warn(`warn: silent NSIS install exited ${status}; falling back to 7-Zip extraction.`); + } + } + const sevenZip = detectSevenZip(); + if (sevenZip === undefined) { + throw new Error( + "neither a real NSIS install nor 7-Zip is available; install 7-Zip (p7zip/7zip) to extract the installer", + ); + } + const extractDir = NodePath.join(scratch, "installer"); + NodeFS.mkdirSync(extractDir, { recursive: true }); + run(sevenZip, ["x", "-y", `-o${extractDir}`, installer]); + return extractDir; +} + function main(): void { const args = parseArgs(process.argv.slice(2)); const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-verify-")); - const extractDir = NodePath.join(scratch, "installer"); - NodeFS.mkdirSync(extractDir, { recursive: true }); - run("7z", ["x", "-y", `-o${extractDir}`, args.installer]); - const embeddedPath = findFile(extractDir, "wsl-runtime.tar.gz"); + const extractRoot = extractInstaller(args.installer, scratch); + const embeddedPath = findFile(extractRoot, WSL_RUNTIME_ARCHIVE_NAME); const embeddedArchive = embeddedPath === undefined ? undefined : NodeFS.readFileSync(embeddedPath); const standaloneArchive = NodeFS.readFileSync(args.standaloneArchive); + const embeddedInfo = + embeddedPath === undefined ? undefined : readArchiveInfo(embeddedPath, scratch); + const standaloneInfo = readArchiveInfo(args.standaloneArchive, scratch); + const result = verifyEmbeddedWslRuntime({ embeddedArchive, standaloneArchive, - embeddedInfo: embeddedPath === undefined ? undefined : readArchiveInfo(embeddedPath, scratch), - standaloneInfo: readArchiveInfo(args.standaloneArchive, scratch), + embeddedInfo, + standaloneInfo, expected: { repository: args.repository, sourceSha: args.sourceSha, @@ -104,6 +181,17 @@ function main(): void { }, }); + if (args.emitJson !== undefined) { + const payload = { + installer: embeddedInfo ?? null, + linuxArchive: standaloneInfo ?? null, + embeddedWslEqualsStandalone: + embeddedArchive !== undefined && embeddedArchive.equals(standaloneArchive), + }; + NodeFS.writeFileSync(args.emitJson, `${JSON.stringify(payload, null, 2)}\n`); + console.log(`Wrote embedded-provenance record to ${args.emitJson}`); + } + if (!result.ok) { for (const failure of result.failures) console.error(`::error::${failure}`); process.exit(1); From 948bbb3497302bf5a9c4a94a11dcd440e1cddca7 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 17:57:14 -0400 Subject: [PATCH 05/10] fix(release): per-target verification must not require the aggregate manifest A Linux-only build produced its archive and passed the no-ambient-Node smoke, then failed at the per-target verify step because the verifier demanded fork-release-manifest.json, which only exists after the aggregate freeze. Split verification so a single platform checks only its own assets and embedded provenance; the aggregate step still requires the manifest and the complete asset set. Adds a regression test that per-target verify passes without a manifest and fails for a wrong target. --- scripts/fork-release-entrypoints.test.ts | 47 ++++++++-------- scripts/verify-fork-candidate.ts | 71 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/scripts/fork-release-entrypoints.test.ts b/scripts/fork-release-entrypoints.test.ts index 74d6c96b6d96..c7a3e453f5d1 100644 --- a/scripts/fork-release-entrypoints.test.ts +++ b/scripts/fork-release-entrypoints.test.ts @@ -180,31 +180,8 @@ it("per-target verification passes with only that platform's bytes (real process NodePath.join(linuxOnly, `t3-${VERSION}-linux-x64.tar.gz`), Buffer.from("linux"), ); - const observed = NodeChildProcess.execFileSync( - nodeBin, - [ - "-e", - "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", - NodePath.join(linuxOnly, `t3-${VERSION}-linux-x64.tar.gz`), - ], - { encoding: "utf8" }, - ).trim(); - NodeFS.writeFileSync( - NodePath.join(linuxOnly, "fork-release-manifest.json"), - JSON.stringify({ - schemaVersion: 1, - repository: "nullStack65/t3code", - version: VERSION, - sourceSha: SHA, - workflowRevision: "local", - workflowRunId: "local", - workflowRunAttempt: "1", - channel: "stable", - createdAt: new Date().toISOString(), - assets: [{ name: `t3-${VERSION}-linux-x64.tar.gz`, sha256: observed, size: 5 }], - nativeReceipts: [], - }), - ); + // Deliberately no fork-release-manifest.json: per-target verification must + // not require the aggregate manifest that only exists after the freeze. const result = runNode([ "scripts/verify-fork-candidate.ts", "--candidate-dir", @@ -220,6 +197,26 @@ it("per-target verification passes with only that platform's bytes (real process "--skip-provenance-inspection", ]); assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "Per-target verification passed"); + + // A wrong-target asset must be rejected: a Windows-target check needs the + // Windows installer and CLI ZIP, which are absent here. + const wrongTarget = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + linuxOnly, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "win", + "--skip-provenance-inspection", + ]); + assert.equal(wrongTarget.status, 1); + assert.include(wrongTarget.stderr, "missing"); } finally { NodeFS.rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/verify-fork-candidate.ts b/scripts/verify-fork-candidate.ts index 8a65da6093a2..e786d50caa93 100644 --- a/scripts/verify-fork-candidate.ts +++ b/scripts/verify-fork-candidate.ts @@ -29,6 +29,7 @@ import { compareStableVersions, renderChecksums, requiredReleaseAssetNames, + requiredReleaseAssetNamesForTargets, sha256Hex, verifyCandidate, verifyPromotion, @@ -172,6 +173,64 @@ function highestStableVersion(list: string | undefined): string | undefined { ); } +/** + * Verifies one platform's own artifacts before the aggregate manifest exists. + * It requires that platform's exact asset names and checks the real embedded + * provenance; it never demands another platform's bytes. + */ +function verifyPerTargetProvenance( + args: Args, + observedAssets: ReadonlyArray, +): { ok: boolean; failures: ReadonlyArray } { + const problems: string[] = []; + const expectedNames = requiredReleaseAssetNamesForTargets(args.version, args.targets, { + includeMacosArm64: args.includeMacosArm64, + }); + const observedNames = new Set(observedAssets.map((asset) => asset.name)); + for (const name of expectedNames) { + if (!observedNames.has(name)) { + problems.push(`required ${args.targets} asset ${name} is missing`); + } + const asset = observedAssets.find((entry) => entry.name === name); + if (asset !== undefined && asset.size <= 0) { + problems.push(`${name} is empty`); + } + } + if (args.inspectProvenance) { + const provenance = inspectCandidateProvenance({ + candidateDir: args.candidateDir, + version: args.version, + targets: args.targets, + includeMacosArm64: args.includeMacosArm64, + }); + const records: Array< + [string, { readonly sourceSha: string; readonly version: string } | null | undefined] + > = + args.targets === "linux" + ? [["Linux runtime archive", provenance.linuxArchive]] + : args.targets === "win" + ? [ + ["Windows CLI archive", provenance.windowsZip], + ["Windows installer", provenance.windowsInstaller], + ] + : [["Intel macOS DMG", provenance.macDmg]]; + for (const [label, record] of records) { + if (record === undefined) continue; + if (record === null) { + problems.push(`${label} has no readable packaged provenance`); + continue; + } + if (record.sourceSha !== args.sha) { + problems.push(`${label} provenance sourceSha is ${record.sourceSha}, expected ${args.sha}`); + } + if (record.version !== args.version) { + problems.push(`${label} provenance version is ${record.version}, expected ${args.version}`); + } + } + } + return problems.length === 0 ? { ok: true, failures: [] } : { ok: false, failures: problems }; +} + function main(): void { const args = parseArgs(process.argv.slice(2)); const observedAssets = observeAssets(args.candidateDir); @@ -203,6 +262,18 @@ function main(): void { console.log(`Wrote ${SHA256SUMS_FILE_NAME} from ${observedAssets.length} assets.`); } + // Per-target verification runs before the aggregate manifest is frozen: it + // checks only this platform's own assets and their embedded provenance. The + // aggregate step (targets: all) is the one that requires the manifest. + if (!NodeFS.existsSync(manifestPath) && args.targets !== "all") { + const result = verifyPerTargetProvenance(args, observedAssets); + if (!result.ok) fail(result.failures); + console.log( + `Per-target verification passed: ${observedAssets.length} ${args.targets} asset(s) for ${args.repository} v${args.version} @ ${args.sha}.`, + ); + return; + } + if (!NodeFS.existsSync(manifestPath)) { fail([`candidate is missing ${CANDIDATE_MANIFEST_FILE_NAME}`]); } From ef2205342298ee7ff602abccca9b0cbadc7f8607 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 18:23:01 -0400 Subject: [PATCH 06/10] fix(release): reach the real NSIS app payload for embedded-WSL verification electron-builder's installer is an NSIS wrapper whose app payload is the \/app-64.7z stream that nsis7z.dll unpacks at install time, yielding resources/wsl-runtime.tar.gz. Unpacking that stream exercises the real extraction layout without launching the Electron app (which must not touch a live machine). verify-windows-installer.ts and the provenance inspector now descend into that payload and fail closed when 7-Zip is absent. The Windows CLI ZIP target now builds the SEA exe first. --- scripts/lib/candidate-build-plan.ts | 6 +++ scripts/lib/candidate-provenance-inspect.ts | 33 ++++++-------- scripts/verify-windows-installer.ts | 50 ++++++++++----------- 3 files changed, 42 insertions(+), 47 deletions(-) diff --git a/scripts/lib/candidate-build-plan.ts b/scripts/lib/candidate-build-plan.ts index 4a7fffd5dec3..e4c245aee9de 100644 --- a/scripts/lib/candidate-build-plan.ts +++ b/scripts/lib/candidate-build-plan.ts @@ -204,6 +204,12 @@ export function planCandidateBuild(input: CandidatePlanInput): ReadonlyArray` so NSIS performs the real - * silent install into an isolated directory; the WSL archive then lives at - * `resources/wsl-runtime.tar.gz` exactly as an end user's install does. - * Otherwise: unpack with 7-Zip and locate the same relative path. + * electron-builder's NSIS installer is a wrapper whose app payload is the + * `$PLUGINSDIR/app-64.7z` stream that the installer's own `nsis7z.dll` unpacks + * at install time, producing `resources/wsl-runtime.tar.gz`. Unpacking that + * stream is the same layout a real install reaches; a silent install would also + * work but launches the Electron app, which must not touch a live machine. This + * therefore requires 7-Zip to reach the nested payload and fails closed when it + * is absent. */ function extractInstaller(installer: string, scratch: string): string { - const installDir = NodePath.join(scratch, "nsis-install"); - // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code - if (process.platform === "win32") { - NodeFS.mkdirSync(installDir, { recursive: true }); - // NSIS requires /D to be last and unquoted; a path with spaces is fine. - const status = NodeChildProcess.spawnSync(installer, ["/S", `/D=${installDir}`], { - stdio: "inherit", - }).status; - if (status === 0) { - const resources = NodePath.join(installDir, "resources"); - if (NodeFS.existsSync(resources)) return resources; - } else { - console.warn(`warn: silent NSIS install exited ${status}; falling back to 7-Zip extraction.`); - } - } const sevenZip = detectSevenZip(); if (sevenZip === undefined) { - throw new Error( - "neither a real NSIS install nor 7-Zip is available; install 7-Zip (p7zip/7zip) to extract the installer", - ); + throw new Error("7-Zip is required to reach the NSIS app payload; install 7-Zip (7zip/p7zip)"); + } + const wrapperDir = NodePath.join(scratch, "installer"); + NodeFS.mkdirSync(wrapperDir, { recursive: true }); + run(sevenZip, ["x", "-y", `-o${wrapperDir}`, installer]); + + const appPayload = findFile(NodePath.join(wrapperDir, "$PLUGINSDIR"), "app-64.7z"); + if (appPayload === undefined) { + // Some installers are not electron-builder's wrapper; fall back to the + // wrapper root so a plain NSIS installer still resolves resources/. + return wrapperDir; } - const extractDir = NodePath.join(scratch, "installer"); - NodeFS.mkdirSync(extractDir, { recursive: true }); - run(sevenZip, ["x", "-y", `-o${extractDir}`, installer]); - return extractDir; + const payloadDir = NodePath.join(scratch, "payload"); + NodeFS.mkdirSync(payloadDir, { recursive: true }); + run(sevenZip, ["x", "-y", `-o${payloadDir}`, appPayload]); + return payloadDir; } function main(): void { From f652cc271012556a02f25763f98d9c3467d77833 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 18:26:05 -0400 Subject: [PATCH 07/10] fix(release): installer provenance is the embedded WSL runtime The Windows installer's inspected provenance is the WSL runtime it embeds, which is a Linux runtime; label it as such instead of expecting win/x64. A Windows-only candidate may lack the standalone Linux archive, so the byte-equality check is skipped in per-target verification and enforced at aggregate where both are present. --- scripts/lib/candidate-provenance-inspect.ts | 6 ++++++ scripts/lib/fork-release-manifest.test.ts | 7 +++++-- scripts/lib/fork-release-manifest.ts | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/lib/candidate-provenance-inspect.ts b/scripts/lib/candidate-provenance-inspect.ts index 8eb1903ef5ba..ffb6e4d8162c 100644 --- a/scripts/lib/candidate-provenance-inspect.ts +++ b/scripts/lib/candidate-provenance-inspect.ts @@ -130,6 +130,12 @@ function inspectEmbeddedWsl( const embeddedPath = findFile(extractRoot, WSL_RUNTIME_ARCHIVE_NAME); if (embeddedPath === undefined) return { embedded: undefined, equalsStandalone: undefined }; const embedded = NodeFS.readFileSync(embeddedPath); + // The standalone Linux archive may not be in a Windows-only candidate + // directory; the aggregate step performs the byte-equality check when both + // are present. Read its provenance from the embedded copy either way. + if (!NodeFS.existsSync(standaloneArchive)) { + return { embedded, equalsStandalone: undefined }; + } const standalone = NodeFS.readFileSync(standaloneArchive); return { embedded, equalsStandalone: embedded.equals(standalone) }; } diff --git a/scripts/lib/fork-release-manifest.test.ts b/scripts/lib/fork-release-manifest.test.ts index 50d89feb2d2c..c5359a64aeca 100644 --- a/scripts/lib/fork-release-manifest.test.ts +++ b/scripts/lib/fork-release-manifest.test.ts @@ -309,7 +309,7 @@ it("rejects packaged provenance that does not match the source", () => { repository: "nullStack65/t3code", sourceSha: DISPATCH, version: VERSION, - platform: "win", + platform: "linux", arch: "x64", }, linuxArchive: { @@ -323,7 +323,10 @@ it("rejects packaged provenance that does not match the source", () => { }, }); assert.equal(result.ok, false); - assert.match(result.failures.join("\n"), /Windows installer provenance sourceSha/); + assert.match( + result.failures.join("\n"), + /Windows installer embedded WSL runtime provenance sourceSha/, + ); assert.match(result.failures.join("\n"), /not byte-identical/); }); diff --git a/scripts/lib/fork-release-manifest.ts b/scripts/lib/fork-release-manifest.ts index 8098b70f9b92..d319eec537ce 100644 --- a/scripts/lib/fork-release-manifest.ts +++ b/scripts/lib/fork-release-manifest.ts @@ -450,8 +450,12 @@ export function verifyPackagedProvenance(input: { }): void { const { problems, provenance, expected, targets, includeMacosArm64 } = input; const wantsOptional = targets === "all" && includeMacosArm64; + // `windowsInstaller` holds the provenance of the WSL runtime embedded in the + // installer, which is a Linux runtime, not a Windows binary; the Windows + // desktop provenance lives in the packaged app and is covered by the target's + // own archive checks. const checks: Array<[string, PackagedProvenanceRecord | null | undefined, string, string]> = [ - ["Windows installer", provenance.windowsInstaller, "win", "x64"], + ["Windows installer embedded WSL runtime", provenance.windowsInstaller, "linux", "x64"], ["Windows CLI archive", provenance.windowsZip, "win", "x64"], ["Linux runtime archive", provenance.linuxArchive, "linux", "x64"], ["Intel macOS DMG", provenance.macDmg, "darwin", "x64"], From 1be979bb3af8ffbe1a40517678f928ab9795707c Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 20:23:46 -0400 Subject: [PATCH 08/10] fix(release): inspect real desktop/DMG provenance and fail closed - Read the Windows desktop app's own build info from resources/app.asar in the real NSIS payload; keep it distinct from the embedded WSL runtime and the bundled server sidecar (a wrong-source desktop with a correct WSL archive now fails). - Inspect the Intel macOS DMG's app.asar on the native Mac (read-only hdiutil mount, 7-Zip HFS fallback elsewhere) and map the writer's actual packaging platform vocabulary ("mac") deliberately. - Require every selected packaged component's inspection: absent, unreadable, or unperformed required inspection is BLOCKED, not verified. Promotion consumes digest-bound native inspection evidence rather than trusting a self-consistent manifest. - Regression tests through the real verifier entry points, including a real DMG mount and a real tar.gz; retain the wrong-target/conflicting tests. --- .github/workflows/fork-release.yml | 10 +- scripts/fork-release-entrypoints.test.ts | 225 +++++++++- scripts/lib/candidate-provenance-inspect.ts | 360 ++++++++++++---- scripts/lib/fork-release-manifest.test.ts | 157 ++++++- scripts/lib/fork-release-manifest.ts | 451 ++++++++++++++++---- scripts/verify-fork-candidate.ts | 135 ++++-- 6 files changed, 1131 insertions(+), 207 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 5bb437ab6093..76fbf9b7663f 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -663,8 +663,9 @@ jobs: --arch x64 # Freeze the candidate: manifest + checksums from the exact distributed - # bytes. Native acceptance receipts are attached separately and required - # only at promotion. + # bytes. Required packaged inspection is fail-closed: every component this + # Linux runner cannot open itself (notably the macOS DMG) must be covered + # by a digest-bound native inspection dropped into `candidate/`. - name: Freeze candidate manifest and checksums shell: bash env: @@ -683,6 +684,11 @@ jobs: --write-checksums ) if [[ "$INCLUDE_ARM64" == "true" ]]; then args+=(--include-macos-arm64); fi + shopt -s nullglob + evidence=(candidate/fork-inspection-evidence*.json) + if (( ${#evidence[@]} > 0 )); then + args+=(--inspection-evidence "$(IFS=,; echo "${evidence[*]}")") + fi node scripts/verify-fork-candidate.ts "${args[@]}" # A machine-readable record of the frozen bytes, keyed by the immutable diff --git a/scripts/fork-release-entrypoints.test.ts b/scripts/fork-release-entrypoints.test.ts index c7a3e453f5d1..c372a5a02318 100644 --- a/scripts/fork-release-entrypoints.test.ts +++ b/scripts/fork-release-entrypoints.test.ts @@ -11,6 +11,8 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { createPackage } from "@electron/asar"; + import { assert, it } from "@effect/vitest"; const repoRoot = NodePath.resolve(import.meta.dirname, ".."); @@ -387,6 +389,224 @@ it("inspects real packaged provenance from a real Linux tar.gz (real process)", } }); +function writeBuildInfoArchive( + archivePath: string, + stagingRoot: string, + info: Record, +): string { + const stem = `t3-${VERSION}-linux-x64`; + const staging = NodePath.join(stagingRoot, "build-info-staging"); + NodeFS.mkdirSync(NodePath.join(staging, stem), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(staging, stem, "t3code-build-info.json"), + JSON.stringify(info), + ); + NodeChildProcess.execFileSync("tar", ["-czf", archivePath, "-C", staging, stem]); + return archivePath; +} + +it("rejects arbitrary non-DMG bytes under the expected DMG filename (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "bogus-dmg"); + NodeFS.mkdirSync(dir); + NodeFS.writeFileSync( + NodePath.join(dir, `T3-Code-${VERSION}-x64.dmg`), + Buffer.from("not a dmg"), + ); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "mac", + ]); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /Intel macOS DMG has no readable packaged provenance|Intel macOS DMG provenance was required but was not inspected/, + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects a real archive with no readable build-info (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "no-info"); + NodeFS.mkdirSync(dir); + const stem = `t3-${VERSION}-linux-x64`; + const staging = NodePath.join(root, "staging"); + NodeFS.mkdirSync(NodePath.join(staging, stem), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(staging, stem, "t3"), "binary\n"); + NodeChildProcess.execFileSync("tar", [ + "-czf", + NodePath.join(dir, `${stem}.tar.gz`), + "-C", + staging, + stem, + ]); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + ]); + assert.equal(result.status, 1); + assert.match(result.stderr, /Linux runtime archive has no readable packaged provenance/); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects wrong repo/SHA/version/platform/arch in a real archive (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "wrong-fields"); + NodeFS.mkdirSync(dir); + writeBuildInfoArchive(NodePath.join(dir, `t3-${VERSION}-linux-x64.tar.gz`), root, { + schemaVersion: 1, + repository: "someone/else", + sourceSha: "a".repeat(40), + workflowRevision: "local", + version: "9.9.9", + platform: "win", + arch: "arm64", + channel: "stable", + }); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + ]); + assert.equal(result.status, 1); + const failures = result.stderr; + assert.match(failures, /Linux runtime archive provenance repository is someone\/else/); + assert.match(failures, /Linux runtime archive provenance sourceSha is a{40}/); + assert.match(failures, /Linux runtime archive provenance version is 9\.9\.9/); + assert.match(failures, /Linux runtime archive provenance platform is win, expected linux/); + assert.match(failures, /Linux runtime archive provenance arch is arm64, expected x64/); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("fails a required Windows desktop inspection that did not happen (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "win-uninspected"); + NodeFS.mkdirSync(dir); + // Arbitrary bytes for both the installer and the CLI ZIP: neither carries a + // readable build info, and on a host without 7-Zip the NSIS components are + // simply not inspectable. Either way this is BLOCKED, not verified. + NodeFS.writeFileSync(NodePath.join(dir, `T3-Code-${VERSION}-x64.exe`), Buffer.from("nope")); + NodeFS.writeFileSync(NodePath.join(dir, `t3-${VERSION}-win32-x64.zip`), Buffer.from("nope")); + const result = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "win", + ]); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /Windows desktop application provenance was required but was not inspected|Windows desktop application has no readable packaged provenance/, + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +// eslint-disable-next-line t3code/no-global-process-runtime -- selecting a host-specific integration test, not Effect code +const itMac = it.skipIf(process.platform !== "darwin"); + +itMac("inspects a real macOS DMG's app.asar provenance (real process)", async () => { + const root = scratch(); + try { + const appSrc = NodePath.join(root, "appsrc"); + const resources = NodePath.join(root, "stage", "T3 Code (Alpha).app", "Contents", "Resources"); + NodeFS.mkdirSync(appSrc, { recursive: true }); + NodeFS.mkdirSync(resources, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(appSrc, "t3code-build-info.json"), + JSON.stringify({ + schemaVersion: 1, + repository: "nullStack65/t3code", + sourceSha: SHA, + workflowRevision: "local", + version: VERSION, + platform: "mac", + arch: "x64", + channel: "stable", + }), + ); + await createPackage(appSrc, NodePath.join(resources, "app.asar")); + + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const dmg = NodePath.join(dir, `T3-Code-${VERSION}-x64.dmg`); + NodeChildProcess.execFileSync("hdiutil", [ + "create", + "-volname", + "T3 Code", + "-srcfolder", + NodePath.join(root, "stage"), + "-ov", + "-format", + "UDZO", + dmg, + ]); + + const accepted = runNode([ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "mac", + ]); + assert.equal(accepted.status, 0, accepted.stderr); + assert.include(accepted.stdout, "Per-target verification passed"); + assert.include(accepted.stdout, '"platform": "mac"'); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + it("the source selector accepts a candidate-mode fork SHA (real process)", () => { const root = scratch(); try { @@ -400,7 +620,10 @@ it("the source selector accepts a candidate-mode fork SHA (real process)", () => ["-c", "user.name=t", "-c", "user.email=t@e", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8" }, ).trim(); - git(origin, ["init", "-b", "main"]); + git(origin, ["init"]); + git(origin, ["symbolic-ref", "HEAD", "refs/heads/main"]); + // Older git refuses to serve an unadvertised object over the local transport. + git(origin, ["config", "uploadpack.allowAnySHA1InWant", "true"]); NodeFS.writeFileSync(NodePath.join(origin, "a.txt"), "a\n"); git(origin, ["add", "."]); git(origin, ["commit", "-m", "A"]); diff --git a/scripts/lib/candidate-provenance-inspect.ts b/scripts/lib/candidate-provenance-inspect.ts index ffb6e4d8162c..f1cc34e16f29 100644 --- a/scripts/lib/candidate-provenance-inspect.ts +++ b/scripts/lib/candidate-provenance-inspect.ts @@ -3,10 +3,24 @@ /** * Reads the *actual* packaged provenance from a candidate's distributed bytes. * - * A manifest that claims the right source is not evidence; the archive must be - * opened and its `t3code-build-info.json` read. This module extracts the Linux - * tarball and the Windows ZIP and reads their provenance, and (on Windows) - * runs the real NSIS installer to reach `resources/wsl-runtime.tar.gz`. + * A manifest that claims the right source is not evidence; each archive must be + * opened and its own `t3code-build-info.json` (or, for the Windows server + * sidecar, its `package.json`) read. The components are kept distinct: + * + * - `linuxArchive` the standalone Linux x64 tarball; + * - `windowsZip` the standalone Windows CLI ZIP; + * - `windowsDesktop` the Windows Electron app's own build info, read from + * `resources/app.asar` inside the real NSIS payload; + * - `windowsServerBundle` the bundled `server.asar` sidecar metadata; + * - `embeddedWsl` the Linux runtime the installer embeds beside the app; + * - `macDmg` the Intel macOS app's build info, read from the + * mounted DMG's `Contents/Resources/app.asar`. + * + * The Windows desktop application is never inferred from the WSL payload or the + * CLI ZIP. A component that cannot be opened because a required extraction tool + * is missing is left `undefined` (BLOCKED), not silently accepted. The result + * also carries a digest-bound evidence record so a host that cannot open an + * artifact can consume a native inspection of the exact bytes. * * It never trusts the file name for the platform/arch/version. */ @@ -15,12 +29,22 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { parseBuildInfo } from "./source-provenance.ts"; +import { extractFile } from "@electron/asar"; + +import { BUILD_INFO_FILE_NAME, parseBuildInfo } from "./source-provenance.ts"; import { WSL_RUNTIME_ARCHIVE_NAME, WSL_RUNTIME_ARCHIVE_HASH_NAME, } from "../build-desktop-artifact.ts"; -import type { PackagedProvenance, PackagedProvenanceRecord } from "./fork-release-manifest.ts"; +import { + sha256Hex, + type BundledServerRecord, + type PackagedInspectionEvidence, + type PackagedInspectionRecord, + type PackagedProvenance, + type PackagedProvenanceKey, + type PackagedProvenanceRecord, +} from "./fork-release-manifest.ts"; const which = (command: string): string | undefined => { // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code @@ -64,9 +88,11 @@ function findFile(root: string, name: string): string | undefined { const run = ( command: string, args: ReadonlyArray, - options: { allowFailure?: boolean } = {}, + options: { allowFailure?: boolean; quiet?: boolean } = {}, ): number => { - const result = NodeChildProcess.spawnSync(command, args, { stdio: "inherit" }); + const result = NodeChildProcess.spawnSync(command, args, { + stdio: options.quiet === true ? "ignore" : "inherit", + }); const status = result.status ?? 1; if (status !== 0 && options.allowFailure !== true) { throw new Error(`${command} ${args.join(" ")} exited ${status}`); @@ -75,69 +101,203 @@ const run = ( }; function readBuildInfoAt(root: string): PackagedProvenanceRecord | null { - const infoPath = findFile(root, "t3code-build-info.json"); + const infoPath = findFile(root, BUILD_INFO_FILE_NAME); if (infoPath === undefined) return null; - const parsed = parseBuildInfo(NodeFS.readFileSync(infoPath, "utf8")); - return { - repository: parsed.repository, - sourceSha: parsed.sourceSha, - version: parsed.version, - platform: parsed.platform, - arch: parsed.arch, - }; + try { + const parsed = parseBuildInfo(NodeFS.readFileSync(infoPath, "utf8")); + return { + repository: parsed.repository, + sourceSha: parsed.sourceSha, + version: parsed.version, + platform: parsed.platform, + arch: parsed.arch, + }; + } catch { + return null; + } } function readTarGzProvenance(archive: string, scratch: string): PackagedProvenanceRecord | null { const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "tar-")); - run("tar", ["-xzf", archive, "-C", dir]); + const status = run("tar", ["-xzf", archive, "-C", dir], { allowFailure: true }); + if (status !== 0) return null; return readBuildInfoAt(dir); } function readZipProvenance(archive: string, scratch: string): PackagedProvenanceRecord | null { const sevenZip = detectSevenZip(); + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "zip-")); if (sevenZip !== undefined) { - const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "zip-")); - run(sevenZip, ["x", "-y", `-o${dir}`, archive]); - return readBuildInfoAt(dir); + const status = run(sevenZip, ["x", "-y", `-o${dir}`, archive], { allowFailure: true }); + return status === 0 ? readBuildInfoAt(dir) : null; } // bsdtar can read zip on every supported host. - const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "zip-")); - run("tar", ["-xf", archive, "-C", dir]); - return readBuildInfoAt(dir); + const status = run("tar", ["-xf", archive, "-C", dir], { allowFailure: true }); + return status === 0 ? readBuildInfoAt(dir) : null; +} + +/** Reads `t3code-build-info.json` from inside a real ASAR archive. */ +function readAsarBuildInfo(asarPath: string): PackagedProvenanceRecord | null { + let raw: Buffer | undefined; + try { + raw = extractFile(asarPath, BUILD_INFO_FILE_NAME); + } catch { + return null; + } + if (raw === undefined) return null; + try { + const parsed = parseBuildInfo(raw.toString("utf8")); + return { + repository: parsed.repository, + sourceSha: parsed.sourceSha, + version: parsed.version, + platform: parsed.platform, + arch: parsed.arch, + }; + } catch { + return null; + } +} + +/** Reads the name/version the bundled Windows server sidecar records. */ +function readAsarPackageMetadata(asarPath: string): BundledServerRecord | null { + let raw: Buffer | undefined; + try { + raw = extractFile(asarPath, "package.json"); + } catch { + return null; + } + if (raw === undefined) return null; + try { + const parsed = JSON.parse(raw.toString("utf8")) as { name?: unknown; version?: unknown }; + if (typeof parsed.name !== "string" || typeof parsed.version !== "string") return null; + return { name: parsed.name, version: parsed.version }; + } catch { + return null; + } +} + +function readAsarBuildInfoInTree(root: string): PackagedProvenanceRecord | null { + const asarPath = findFile(root, "app.asar"); + if (asarPath === undefined) return null; + return readAsarBuildInfo(asarPath); } /** - * Extracts the WSL archive from a Windows installer by unpacking the real NSIS - * app payload (`$PLUGINSDIR/app-64.7z`, the stream the installer's own - * `nsis7z.dll` unpacks). Executing the installer is avoided because it launches - * the Electron app. Returns the embedded bytes and the standalone comparison. + * Extracts the Windows installer through its real NSIS payload layout. + * + * electron-builder's NSIS installer is a wrapper whose app payload is the + * `$PLUGINSDIR/app-64.7z` stream that the installer's own `nsis7z.dll` unpacks + * at install time, producing `resources/app.asar` (the desktop app), + * `resources/server.asar` (the bundled server) and `resources/wsl-runtime.tar.gz` + * (the WSL runtime). This never executes the installer, so it cannot launch the + * Electron app or touch a live profile. It fails closed when 7-Zip is absent. */ -function inspectEmbeddedWsl( +function inspectWindowsInstaller( installer: string, standaloneArchive: string, scratch: string, -): { embedded: Uint8Array | undefined; equalsStandalone: boolean | undefined } { +): { + desktop: PackagedProvenanceRecord | null | undefined; + serverBundle: BundledServerRecord | null | undefined; + embeddedWsl: PackagedProvenanceRecord | null | undefined; + equalsStandalone: boolean | undefined; +} { const sevenZip = detectSevenZip(); - if (sevenZip === undefined) return { embedded: undefined, equalsStandalone: undefined }; + if (sevenZip === undefined) { + return { + desktop: undefined, + serverBundle: undefined, + embeddedWsl: undefined, + equalsStandalone: undefined, + }; + } + const unreadable = { + desktop: null, + serverBundle: null, + embeddedWsl: null, + equalsStandalone: undefined, + } as const; + const wrapperDir = NodeFS.mkdtempSync(NodePath.join(scratch, "installer-")); - run(sevenZip, ["x", "-y", `-o${wrapperDir}`, installer]); - const appPayload = findFile(NodePath.join(wrapperDir, "$PLUGINSDIR"), "app-64.7z"); + const wrapperStatus = run(sevenZip, ["x", "-y", `-o${wrapperDir}`, installer], { + allowFailure: true, + }); + if (wrapperStatus !== 0) return unreadable; + let extractRoot = wrapperDir; + const appPayload = findFile(NodePath.join(wrapperDir, "$PLUGINSDIR"), "app-64.7z"); if (appPayload !== undefined) { extractRoot = NodeFS.mkdtempSync(NodePath.join(scratch, "payload-")); - run(sevenZip, ["x", "-y", `-o${extractRoot}`, appPayload]); + const payloadStatus = run(sevenZip, ["x", "-y", `-o${extractRoot}`, appPayload], { + allowFailure: true, + }); + if (payloadStatus !== 0) return unreadable; } + + const appAsarPath = findFile(extractRoot, "app.asar"); + const serverAsarPath = findFile(extractRoot, "server.asar"); const embeddedPath = findFile(extractRoot, WSL_RUNTIME_ARCHIVE_NAME); - if (embeddedPath === undefined) return { embedded: undefined, equalsStandalone: undefined }; - const embedded = NodeFS.readFileSync(embeddedPath); - // The standalone Linux archive may not be in a Windows-only candidate - // directory; the aggregate step performs the byte-equality check when both - // are present. Read its provenance from the embedded copy either way. - if (!NodeFS.existsSync(standaloneArchive)) { - return { embedded, equalsStandalone: undefined }; - } - const standalone = NodeFS.readFileSync(standaloneArchive); - return { embedded, equalsStandalone: embedded.equals(standalone) }; + + let equalsStandalone: boolean | undefined; + if (embeddedPath !== undefined && NodeFS.existsSync(standaloneArchive)) { + equalsStandalone = NodeFS.readFileSync(embeddedPath).equals( + NodeFS.readFileSync(standaloneArchive), + ); + } + return { + desktop: appAsarPath === undefined ? null : readAsarBuildInfo(appAsarPath), + serverBundle: serverAsarPath === undefined ? null : readAsarPackageMetadata(serverAsarPath), + embeddedWsl: embeddedPath === undefined ? null : readTarGzProvenance(embeddedPath, scratch), + equalsStandalone, + }; +} + +/** + * Reads the macOS app's own build info from inside a real DMG. + * + * On the native Mac this attaches the image read-only to an isolated temporary + * mount point and detaches it in `finally`; it never runs the app or an + * installer. Elsewhere it falls back to 7-Zip's HFS reader. A host with neither + * tool leaves the record `undefined` (BLOCKED). + */ +function inspectMacDmg(dmg: string, scratch: string): PackagedProvenanceRecord | null | undefined { + const hdiutil = which("hdiutil"); + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + if (process.platform === "darwin" && hdiutil !== undefined) { + const mountDir = NodeFS.mkdtempSync(NodePath.join(scratch, "dmg-")); + let attached = false; + try { + const status = run( + "hdiutil", + [ + "attach", + "-readonly", + "-nobrowse", + "-noautoopen", + "-noverify", + "-mountpoint", + mountDir, + dmg, + ], + { allowFailure: true, quiet: true }, + ); + if (status !== 0) return null; + attached = true; + return readAsarBuildInfoInTree(mountDir); + } finally { + if (attached) { + run("hdiutil", ["detach", mountDir, "-force"], { allowFailure: true, quiet: true }); + } + NodeFS.rmSync(mountDir, { recursive: true, force: true }); + } + } + + const sevenZip = detectSevenZip(); + if (sevenZip === undefined) return undefined; + const dir = NodeFS.mkdtempSync(NodePath.join(scratch, "dmg-")); + const status = run(sevenZip, ["x", "-y", `-o${dir}`, dmg], { allowFailure: true }); + return status === 0 ? readAsarBuildInfoInTree(dir) : null; } export interface InspectCandidateInput { @@ -147,20 +307,33 @@ export interface InspectCandidateInput { readonly includeMacosArm64: boolean; } +export interface InspectCandidateResult { + readonly provenance: PackagedProvenance; + /** Digest-bound evidence for the components this host actually inspected. */ + readonly evidence: PackagedInspectionEvidence; +} + /** * Reads the real embedded provenance for the target selection. Missing - * extraction prerequisites (for example no 7-Zip for the Windows ZIP) leave the - * record `undefined` rather than falsely passing; the caller reports that as an - * inspection gap when the artifact is required. + * extraction prerequisites (for example no 7-Zip for the NSIS payload) leave the + * component `undefined` rather than falsely passing; the caller reports that as + * a BLOCKED inspection when the artifact is required. */ -export function inspectCandidateProvenance(input: InspectCandidateInput): PackagedProvenance { +export function inspectCandidateProvenance(input: InspectCandidateInput): InspectCandidateResult { const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-inspect-")); const provenance: { - windowsInstaller?: PackagedProvenanceRecord | null; - linuxArchive?: PackagedProvenanceRecord | null; - windowsZip?: PackagedProvenanceRecord | null; - macDmg?: PackagedProvenanceRecord | null; + windowsDesktop?: PackagedProvenanceRecord | null | undefined; + windowsServerBundle?: BundledServerRecord | null | undefined; + embeddedWsl?: PackagedProvenanceRecord | null | undefined; + linuxArchive?: PackagedProvenanceRecord | null | undefined; + windowsZip?: PackagedProvenanceRecord | null | undefined; + macDmg?: PackagedProvenanceRecord | null | undefined; + macArm64Dmg?: PackagedProvenanceRecord | null | undefined; + embeddedWslEqualsStandalone?: boolean | undefined; } = {}; + const records: Partial> = {}; + const digests: Partial> = {}; + const wantsAll = input.targets === "all"; const linuxArchivePath = NodePath.join( input.candidateDir, @@ -171,43 +344,78 @@ export function inspectCandidateProvenance(input: InspectCandidateInput): Packag input.candidateDir, `T3-Code-${input.version}-x64.exe`, ); + const macDmgPath = NodePath.join(input.candidateDir, `T3-Code-${input.version}-x64.dmg`); + const macArm64DmgPath = NodePath.join(input.candidateDir, `T3-Code-${input.version}-arm64.dmg`); + + const digestOf = (file: string): string => sha256Hex(NodeFS.readFileSync(file)); - if ((wantsAll || input.targets === "linux") && NodeFS.existsSync(linuxArchivePath)) { - provenance.linuxArchive = readTarGzProvenance(linuxArchivePath, scratch); - } - if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsZipPath)) { - provenance.windowsZip = readZipProvenance(windowsZipPath, scratch); - } - - let embeddedWslEqualsStandalone: boolean | undefined; - if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsInstallerPath)) { - const wsl = inspectEmbeddedWsl(windowsInstallerPath, linuxArchivePath, scratch); - embeddedWslEqualsStandalone = wsl.equalsStandalone; - // Read the embedded archive's own provenance. - if (wsl.embedded !== undefined) { - const embeddedDir = NodeFS.mkdtempSync(NodePath.join(scratch, "wsl-")); - const embeddedFile = NodePath.join(embeddedDir, WSL_RUNTIME_ARCHIVE_NAME); - NodeFS.writeFileSync(embeddedFile, wsl.embedded); - provenance.windowsInstaller = readTarGzProvenance(embeddedFile, scratch); - } else { - provenance.windowsInstaller = null; + try { + if ((wantsAll || input.targets === "linux") && NodeFS.existsSync(linuxArchivePath)) { + provenance.linuxArchive = readTarGzProvenance(linuxArchivePath, scratch); + records.linuxArchive = provenance.linuxArchive; + digests.linuxArchive = digestOf(linuxArchivePath); + } + if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsZipPath)) { + provenance.windowsZip = readZipProvenance(windowsZipPath, scratch); + records.windowsZip = provenance.windowsZip; + digests.windowsZip = digestOf(windowsZipPath); + } + if ((wantsAll || input.targets === "win") && NodeFS.existsSync(windowsInstallerPath)) { + const installer = inspectWindowsInstaller(windowsInstallerPath, linuxArchivePath, scratch); + provenance.windowsDesktop = installer.desktop; + provenance.windowsServerBundle = installer.serverBundle; + provenance.embeddedWsl = installer.embeddedWsl; + records.windowsDesktop = installer.desktop; + records.windowsServerBundle = installer.serverBundle; + records.embeddedWsl = installer.embeddedWsl; + const installerDigest = digestOf(windowsInstallerPath); + digests.windowsDesktop = installerDigest; + digests.windowsServerBundle = installerDigest; + digests.embeddedWsl = installerDigest; + if (installer.equalsStandalone !== undefined) { + provenance.embeddedWslEqualsStandalone = installer.equalsStandalone; + } + } + if ((wantsAll || input.targets === "mac") && NodeFS.existsSync(macDmgPath)) { + provenance.macDmg = inspectMacDmg(macDmgPath, scratch); + records.macDmg = provenance.macDmg; + digests.macDmg = digestOf(macDmgPath); } + if ( + (wantsAll || input.targets === "mac") && + input.includeMacosArm64 && + NodeFS.existsSync(macArm64DmgPath) + ) { + provenance.macArm64Dmg = inspectMacDmg(macArm64DmgPath, scratch); + records.macArm64Dmg = provenance.macArm64Dmg; + digests.macArm64Dmg = digestOf(macArm64DmgPath); + } + } finally { + NodeFS.rmSync(scratch, { recursive: true, force: true }); } - NodeFS.rmSync(scratch, { recursive: true, force: true }); console.log( `Inspected packaged provenance: ${JSON.stringify( { ...provenance, - embeddedWslEqualsStandalone, + embeddedWslEqualsStandalone: provenance.embeddedWslEqualsStandalone, }, null, 2, )}`, ); - return embeddedWslEqualsStandalone === undefined - ? { ...provenance } - : { ...provenance, embeddedWslEqualsStandalone }; + + const evidence: PackagedInspectionEvidence = { + schemaVersion: 1, + // eslint-disable-next-line t3code/no-global-process-runtime -- a plain Node CLI helper, not Effect code + host: `${process.platform}-${process.arch}`, + records, + digests, + ...(provenance.embeddedWslEqualsStandalone === undefined + ? {} + : { embeddedWslEqualsStandalone: provenance.embeddedWslEqualsStandalone }), + }; + return { provenance, evidence }; } export { WSL_RUNTIME_ARCHIVE_HASH_NAME }; diff --git a/scripts/lib/fork-release-manifest.test.ts b/scripts/lib/fork-release-manifest.test.ts index c5359a64aeca..4cf28d698206 100644 --- a/scripts/lib/fork-release-manifest.test.ts +++ b/scripts/lib/fork-release-manifest.test.ts @@ -8,7 +8,9 @@ import { requiredReleaseAssetNames, verifyCandidate, verifyPromotion, + verifyTargetPackagedProvenance, type NativeReceipt, + type PackagedProvenance, type ReleaseAsset, type ReleaseCandidateManifest, } from "./fork-release-manifest.ts"; @@ -305,7 +307,7 @@ it("rejects packaged provenance that does not match the source", () => { observedAssets: assets, targets: "all", packagedProvenance: { - windowsInstaller: { + embeddedWsl: { repository: "nullStack65/t3code", sourceSha: DISPATCH, version: VERSION, @@ -330,6 +332,145 @@ it("rejects packaged provenance that does not match the source", () => { assert.match(result.failures.join("\n"), /not byte-identical/); }); +const record = (platform: string) => ({ + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + platform, + arch: "x64", +}); + +const completeProvenance: PackagedProvenance = { + windowsDesktop: record("win"), + windowsServerBundle: { name: "t3code-server", version: VERSION }, + embeddedWsl: record("linux"), + linuxArchive: record("linux"), + windowsZip: record("win"), + macDmg: record("mac"), + embeddedWslEqualsStandalone: true, +}; + +it("keeps Windows desktop provenance distinct from the WSL runtime it embeds", () => { + // The WSL payload is correct; only the desktop app's own build info is from + // another source. This must fail — the coordinator's exact defect. + const result = verifyTargetPackagedProvenance({ + provenance: { + windowsDesktop: { ...record("win"), sourceSha: DISPATCH }, + windowsServerBundle: { name: "t3code-server", version: VERSION }, + embeddedWsl: record("linux"), + windowsZip: record("win"), + }, + evidence: [], + observedAssets: assets, + expected, + targets: "win", + includeMacosArm64: false, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /Windows desktop application provenance sourceSha is/); + assert.notMatch(result.failures.join("\n"), /embedded WSL runtime provenance sourceSha/); + + // A Windows target with the correct WSL record but no desktop record at all + // is a missing REQUIRED inspection, not a pass. + const missingDesktop = verifyTargetPackagedProvenance({ + provenance: { embeddedWsl: record("linux"), windowsZip: record("win") }, + evidence: [], + observedAssets: assets, + expected, + targets: "win", + includeMacosArm64: false, + }); + assert.equal(missingDesktop.ok, false); + assert.match( + missingDesktop.failures.join("\n"), + /Windows desktop application provenance was required but was not inspected/, + ); +}); + +it("maps the writer's packaging platform vocabulary deliberately", () => { + // The DMG writer records `mac`, not Node's runtime `darwin`. + const okResult = verifyTargetPackagedProvenance({ + provenance: { macDmg: record("mac") }, + evidence: [], + observedAssets: assets, + expected, + targets: "mac", + includeMacosArm64: false, + }); + assert.deepEqual(okResult, { ok: true, failures: [] }); + + const guessedDarwin = verifyTargetPackagedProvenance({ + provenance: { macDmg: { ...record("mac"), platform: "darwin" } }, + evidence: [], + observedAssets: assets, + expected, + targets: "mac", + includeMacosArm64: false, + }); + assert.equal(guessedDarwin.ok, false); + assert.match( + guessedDarwin.failures.join("\n"), + /Intel macOS DMG provenance platform is darwin, expected mac/, + ); +}); + +it("requires digest-bound evidence for an inspection this host could not perform", () => { + const dmgName = `T3-Code-${VERSION}-x64.dmg`; + const dmgAsset = assets.find((asset) => asset.name === dmgName)!; + + // The bytes changed after the native inspection: the evidence is bound to a + // different digest and must not qualify the new bytes. + const stale = verifyTargetPackagedProvenance({ + provenance: {}, + evidence: [ + { + schemaVersion: 1, + host: "darwin-x64", + records: { macDmg: record("mac") }, + digests: { macDmg: "f".repeat(64) }, + }, + ], + observedAssets: assets, + expected, + targets: "mac", + includeMacosArm64: false, + }); + assert.equal(stale.ok, false); + assert.match(stale.failures.join("\n"), /inspection evidence is bound to digest/); + assert.match(stale.failures.join("\n"), /no digest-bound inspection evidence matched/); + + // Bound to the exact digest, the evidence is usable. + const bound = verifyTargetPackagedProvenance({ + provenance: {}, + evidence: [ + { + schemaVersion: 1, + host: "darwin-x64", + records: { macDmg: record("mac") }, + digests: { macDmg: dmgAsset.sha256 }, + }, + ], + observedAssets: assets, + expected, + targets: "mac", + includeMacosArm64: false, + }); + assert.deepEqual(bound, { ok: true, failures: [] }); +}); + +it("fails a required inspection that was never performed", () => { + const result = verifyTargetPackagedProvenance({ + provenance: {}, + evidence: [], + observedAssets: assets, + expected, + targets: "mac", + includeMacosArm64: false, + }); + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /provenance is required but was not inspected/); +}); + it("promotion refuses overwrite, older versions, and a missing authorization gate", () => { const base = { manifest, @@ -337,6 +478,8 @@ it("promotion refuses overwrite, older versions, and a missing authorization gat observedAssets: assets, requireNativeReceipts: true, tagTargetSha: SHA, + packagedProvenance: completeProvenance, + requirePackagedProvenance: true, }; const good = verifyPromotion({ @@ -348,6 +491,18 @@ it("promotion refuses overwrite, older versions, and a missing authorization gat }); assert.deepEqual(good, { ok: true, failures: [] }); + // Promotion must not qualify bytes that were never inspected. + const uninspected = verifyPromotion({ + ...base, + packagedProvenance: undefined, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.42", + authorizationGateExists: true, + }); + assert.equal(uninspected.ok, false); + assert.match(uninspected.failures.join("\n"), /inspection is required/); + const overwrite = verifyPromotion({ ...base, releaseExists: true, diff --git a/scripts/lib/fork-release-manifest.ts b/scripts/lib/fork-release-manifest.ts index d319eec537ce..da3b0824cfeb 100644 --- a/scripts/lib/fork-release-manifest.ts +++ b/scripts/lib/fork-release-manifest.ts @@ -25,6 +25,7 @@ import * as NodeCrypto from "node:crypto"; export const CANDIDATE_MANIFEST_FILE_NAME = "fork-release-manifest.json"; export const NATIVE_RECEIPTS_FILE_NAME = "fork-native-receipts.json"; export const SHA256SUMS_FILE_NAME = "SHA256SUMS"; +export const PACKAGED_INSPECTION_FILE_NAME = "fork-inspection-evidence.json"; export const CANDIDATE_ARTIFACT_NAME = "fork-release-candidate"; export const NATIVE_RECEIPTS_ARTIFACT_NAME = "fork-release-native-receipts"; @@ -153,58 +154,134 @@ const ok = (): VerificationResult => ({ ok: true, failures: [] }); const failures = (list: ReadonlyArray): VerificationResult => list.length === 0 ? ok() : { ok: false, failures: list }; +export interface PackagedProvenanceRecord { + readonly repository: string; + readonly sourceSha: string; + readonly version: string; + readonly platform: string; + readonly arch: string; +} + +/** The metadata the Windows `server.asar` sidecar actually records. */ +export interface BundledServerRecord { + readonly name: string; + readonly version: string; +} + +/** + * Every packaged component whose provenance is inspected independently. The + * Windows desktop application's own build info (`windowsDesktop`, read from + * `resources/app.asar`) is deliberately distinct from the WSL runtime embedded + * beside it (`embeddedWsl`, a Linux build) and from the bundled server sidecar + * (`windowsServerBundle`). Conflating desktop and WSL provenance is what let a + * wrong-source desktop pass on a correct WSL payload. + */ +export type PackagedProvenanceKey = + | "windowsDesktop" + | "windowsServerBundle" + | "embeddedWsl" + | "linuxArchive" + | "windowsZip" + | "macDmg" + | "macArm64Dmg"; + /** * The provenance actually observed inside the distributed artifacts. * - * `undefined` means "not inspected" (for example during promotion, where the - * artifacts are re-downloaded but the packaged-provenance extraction happened - * at qualify time). An explicit `null` means "inspected and absent", which is a - * failure for a required artifact. + * `undefined` means "not inspected" (the host or its extraction tool could not + * read it), and an explicit `null` means "inspected and unreadable/absent". + * Both are failures for a required component; neither may silently pass. */ export interface PackagedProvenance { - readonly windowsInstaller?: PackagedProvenanceRecord | null; - readonly linuxArchive?: PackagedProvenanceRecord | null; - readonly windowsZip?: PackagedProvenanceRecord | null; - readonly macDmg?: PackagedProvenanceRecord | null; - readonly macArm64Dmg?: PackagedProvenanceRecord | null; + readonly windowsDesktop?: PackagedProvenanceRecord | null | undefined; + readonly windowsServerBundle?: BundledServerRecord | null | undefined; + readonly embeddedWsl?: PackagedProvenanceRecord | null | undefined; + readonly linuxArchive?: PackagedProvenanceRecord | null | undefined; + readonly windowsZip?: PackagedProvenanceRecord | null | undefined; + readonly macDmg?: PackagedProvenanceRecord | null | undefined; + readonly macArm64Dmg?: PackagedProvenanceRecord | null | undefined; /** Byte-identity of the WSL archive embedded in the installer vs the Linux archive. */ - readonly embeddedWslEqualsStandalone?: boolean; + readonly embeddedWslEqualsStandalone?: boolean | undefined; } -export interface PackagedProvenanceRecord { - readonly repository: string; - readonly sourceSha: string; - readonly version: string; - readonly platform: string; - readonly arch: string; +export const PACKAGED_PROVENANCE_LABELS: Record = { + windowsDesktop: "Windows desktop application", + windowsServerBundle: "Windows bundled server", + embeddedWsl: "Windows installer embedded WSL runtime", + linuxArchive: "Linux runtime archive", + windowsZip: "Windows CLI archive", + macDmg: "Intel macOS DMG", + macArm64Dmg: "Apple Silicon DMG", +}; + +/** + * The `platform` string the writers actually record in `t3code-build-info.json`. + * + * This is the *packaging* vocabulary (`mac`/`win`/`linux`), not Node's runtime + * vocabulary (`darwin`/`win32`/`linux`). Mapping it deliberately, rather than + * guessing `darwin` for a DMG, is what makes the Intel DMG check meaningful. + */ +export const PACKAGED_PLATFORM: Partial> = { + windowsDesktop: "win", + embeddedWsl: "linux", + windowsZip: "win", + linuxArchive: "linux", + macDmg: "mac", + macArm64Dmg: "mac", +}; + +/** The architecture string the writers record per packaged component. */ +export const PACKAGED_ARCH: Partial> = { + windowsDesktop: "x64", + embeddedWsl: "x64", + windowsZip: "x64", + linuxArchive: "x64", + macDmg: "x64", + macArm64Dmg: "arm64", +}; + +export interface ProvenanceArtifactAvailability { + readonly hasWindowsInstaller: boolean; + readonly hasMacDmg: boolean; + readonly hasLinuxArchive: boolean; + readonly hasWindowsZip: boolean; + readonly hasMacArm64Dmg: boolean; } -export interface InspectProvenanceInput { - readonly version: string; - readonly targets: CandidateTargetSelection; - readonly artifacts: { - readonly hasWindowsInstaller: boolean; - readonly hasMacDmg: boolean; - readonly hasLinuxArchive: boolean; - readonly hasWindowsZip: boolean; - readonly hasMacArm64Dmg: boolean; - }; +/** The release asset a packaged component's provenance is read from. */ +export function provenanceArtifactName(key: PackagedProvenanceKey, version: string): string { + switch (key) { + case "windowsDesktop": + case "windowsServerBundle": + case "embeddedWsl": + return `T3-Code-${version}-x64.exe`; + case "windowsZip": + return `t3-${version}-win32-x64.zip`; + case "linuxArchive": + return `t3-${version}-linux-x64.tar.gz`; + case "macDmg": + return `T3-Code-${version}-x64.dmg`; + case "macArm64Dmg": + return `T3-Code-${version}-arm64.dmg`; + } } /** - * Pure decision table for which artifacts must carry inspected provenance for a - * target selection. The impure extraction (unpacking tarballs/zip/NSIS) lives - * in the caller; this function only says what must be verified. + * Pure decision table for which packaged components MUST carry inspected + * provenance for a target selection, given which artifacts are actually present. + * A missing record for one of these keys is a failure, never a silent skip. */ -export function provenanceArtifactsForSelection( - input: InspectProvenanceInput, -): ReadonlyArray<"windowsInstaller" | "linuxArchive" | "windowsZip" | "macDmg" | "macArm64Dmg"> { - const wanted: Array< - "windowsInstaller" | "linuxArchive" | "windowsZip" | "macDmg" | "macArm64Dmg" - > = []; +export function requiredProvenanceKeysForSelection(input: { + readonly targets: CandidateTargetSelection; + readonly artifacts: ProvenanceArtifactAvailability; + readonly includeMacosArm64: boolean; +}): ReadonlyArray { + const wanted: PackagedProvenanceKey[] = []; const wantsAll = input.targets === "all"; if ((wantsAll || input.targets === "win") && input.artifacts.hasWindowsInstaller) { - wanted.push("windowsInstaller"); + wanted.push("windowsDesktop", "windowsServerBundle", "embeddedWsl"); + } + if ((wantsAll || input.targets === "win") && input.artifacts.hasWindowsZip) { wanted.push("windowsZip"); } if ((wantsAll || input.targets === "linux") && input.artifacts.hasLinuxArchive) { @@ -213,21 +290,113 @@ export function provenanceArtifactsForSelection( if ((wantsAll || input.targets === "mac") && input.artifacts.hasMacDmg) { wanted.push("macDmg"); } - if ((wantsAll || input.targets === "mac") && input.artifacts.hasMacArm64Dmg) { + if ( + (wantsAll || input.targets === "mac") && + input.includeMacosArm64 && + input.artifacts.hasMacArm64Dmg + ) { wanted.push("macArm64Dmg"); } return wanted; } +/** Derives artifact availability from the assets actually observed on disk. */ +export function provenanceAvailabilityFromAssets( + version: string, + observedAssets: ReadonlyArray, +): ProvenanceArtifactAvailability { + const names = new Set(observedAssets.map((asset) => asset.name)); + return { + hasWindowsInstaller: names.has(`T3-Code-${version}-x64.exe`), + hasMacDmg: names.has(`T3-Code-${version}-x64.dmg`), + hasLinuxArchive: names.has(`t3-${version}-linux-x64.tar.gz`), + hasWindowsZip: names.has(`t3-${version}-win32-x64.zip`), + hasMacArm64Dmg: names.has(`T3-Code-${version}-arm64.dmg`), + }; +} + /** - * Thin wrapper the CLI uses: it does not extract bytes itself (that needs real - * archives on disk), so it returns an empty inspection and lets the separate - * `verify-windows-installer.ts` / archive extraction steps supply the records. - * Kept for interface stability; the CLI merges real records in. + * A native inspection a different machine performed, bound to the exact + * artifact digest it read. This is the existing evidence mechanism the aggregate + * may consume instead of re-inspecting an artifact it cannot open; it is not a + * signing service, and a digest mismatch simply makes the record unusable. */ -export function inspectCandidateProvenance(input: InspectProvenanceInput): PackagedProvenance { - void input; - return {}; +export interface PackagedInspectionEvidence { + readonly schemaVersion: 1; + readonly host: string; + readonly records: Partial>; + readonly digests: Partial>; + readonly embeddedWslEqualsStandalone?: boolean; +} + +/** A component record as read by an inspector: build info, server metadata, absent, or not inspected. */ +export type PackagedInspectionRecord = + | PackagedProvenanceRecord + | BundledServerRecord + | null + | undefined; + +export interface MergePackagedInspectionInput { + readonly provenance: PackagedProvenance; + readonly evidence: ReadonlyArray; + readonly observedAssets: ReadonlyArray; + readonly version: string; + readonly requiredKeys: ReadonlyArray; +} + +export interface MergePackagedInspectionResult { + readonly provenance: PackagedProvenance; + readonly problems: ReadonlyArray; +} + +/** + * Fills components the local host could not inspect from digest-bound evidence, + * and reports a problem when a required component is neither inspected locally + * nor covered by evidence for the artifact's exact bytes. This is what stops + * changed bytes after inspection from reusing an old passing inspection. + */ +export function mergePackagedInspection( + input: MergePackagedInspectionInput, +): MergePackagedInspectionResult { + const merged: Record = { ...input.provenance }; + const problems: string[] = []; + const observed = new Map(input.observedAssets.map((asset) => [asset.name, asset])); + + for (const key of input.requiredKeys) { + if (merged[key] !== undefined) continue; // Already inspected locally. + const label = PACKAGED_PROVENANCE_LABELS[key]; + const artifactName = provenanceArtifactName(key, input.version); + const artifact = observed.get(artifactName); + if (artifact === undefined) { + problems.push(`${label} provenance was not inspected and ${artifactName} is absent`); + continue; + } + let found = false; + for (const evidence of input.evidence) { + if (evidence === undefined) continue; + const record = evidence.records[key]; + if (record === undefined) continue; + const boundDigest = evidence.digests[key]; + if (boundDigest?.toLowerCase() !== artifact.sha256.toLowerCase()) { + problems.push( + `${label} inspection evidence is bound to digest ${boundDigest ?? "none"}, but ${artifactName} is ${artifact.sha256}`, + ); + continue; + } + merged[key] = record; + if (key === "embeddedWsl" && evidence.embeddedWslEqualsStandalone !== undefined) { + merged.embeddedWslEqualsStandalone = evidence.embeddedWslEqualsStandalone; + } + found = true; + break; + } + if (!found) { + problems.push( + `${label} provenance is required but was not inspected and no digest-bound inspection evidence matched ${artifactName}`, + ); + } + } + return { provenance: merged as PackagedProvenance, problems }; } export interface VerifyCandidateInput { @@ -240,6 +409,18 @@ export interface VerifyCandidateInput { readonly requireNativeReceipts?: boolean; /** Provenance read from the actual packaged bytes, when it was inspected. */ readonly packagedProvenance?: PackagedProvenance | undefined; + /** + * Digest-bound inspections performed on another machine, usable for a + * component this host could not open. Only consumed when it matches the + * observed artifact digest exactly. + */ + readonly inspectionEvidence?: ReadonlyArray; + /** + * When true, every packaged component of the selected target(s) whose artifact + * is present must have a completed inspection (locally or via digest-bound + * evidence). A missing/unreadable/unperformed required inspection fails. + */ + readonly requirePackagedProvenance?: boolean; } /** @@ -316,13 +497,43 @@ export function verifyCandidate(input: VerifyCandidateInput): VerificationResult includeMacosArm64: input.includeMacosArm64 === true, }); - if (input.packagedProvenance !== undefined) { + if (input.requirePackagedProvenance === true) { + const requiredKeys = requiredProvenanceKeysForSelection({ + targets, + artifacts: provenanceAvailabilityFromAssets(expected.version, input.observedAssets), + includeMacosArm64: input.includeMacosArm64 === true, + }); + const suppliedEvidence = input.inspectionEvidence ?? []; + if (input.packagedProvenance === undefined && suppliedEvidence.length === 0) { + problems.push( + "packaged provenance inspection is required but no inspection and no evidence were supplied", + ); + } else { + const merged = mergePackagedInspection({ + provenance: input.packagedProvenance ?? {}, + evidence: suppliedEvidence, + observedAssets: input.observedAssets, + version: expected.version, + requiredKeys, + }); + problems.push(...merged.problems); + verifyPackagedProvenance({ + problems, + provenance: merged.provenance, + expected, + targets, + includeMacosArm64: input.includeMacosArm64 === true, + requiredKeys, + }); + } + } else if (input.packagedProvenance !== undefined) { verifyPackagedProvenance({ problems, provenance: input.packagedProvenance, expected, targets, includeMacosArm64: input.includeMacosArm64 === true, + requiredKeys: [], }); } @@ -440,48 +651,44 @@ export function verifyReceipts(input: { } } -/** Requires inspected provenance to name the same repository/source/version/platform. */ +/** + * Requires inspected provenance to name the same repository/source/version/ + * platform/architecture for every packaged component. + * + * A record of `undefined` for a `requiredKeys` entry is a failure: the + * inspection was required but did not happen (missing tool, unsupported host, + * or a skipped step). `null` is "inspected and unreadable". The platform string + * is mapped through `PACKAGED_PLATFORM` because the writers use the packaging + * vocabulary (`mac`), not Node's runtime vocabulary (`darwin`). + */ export function verifyPackagedProvenance(input: { readonly problems: string[]; readonly provenance: PackagedProvenance; readonly expected: CandidateExpectations; readonly targets: CandidateTargetSelection; readonly includeMacosArm64: boolean; + readonly requiredKeys: ReadonlyArray; }): void { - const { problems, provenance, expected, targets, includeMacosArm64 } = input; - const wantsOptional = targets === "all" && includeMacosArm64; - // `windowsInstaller` holds the provenance of the WSL runtime embedded in the - // installer, which is a Linux runtime, not a Windows binary; the Windows - // desktop provenance lives in the packaged app and is covered by the target's - // own archive checks. - const checks: Array<[string, PackagedProvenanceRecord | null | undefined, string, string]> = [ - ["Windows installer embedded WSL runtime", provenance.windowsInstaller, "linux", "x64"], - ["Windows CLI archive", provenance.windowsZip, "win", "x64"], - ["Linux runtime archive", provenance.linuxArchive, "linux", "x64"], - ["Intel macOS DMG", provenance.macDmg, "darwin", "x64"], + const { problems, provenance, expected, targets, requiredKeys } = input; + const required = new Set(requiredKeys); + const recordChecks: ReadonlyArray< + readonly [PackagedProvenanceKey, PackagedProvenanceRecord | null | undefined] + > = [ + ["windowsDesktop", provenance.windowsDesktop], + ["embeddedWsl", provenance.embeddedWsl], + ["windowsZip", provenance.windowsZip], + ["linuxArchive", provenance.linuxArchive], + ["macDmg", provenance.macDmg], + ["macArm64Dmg", provenance.macArm64Dmg], ]; - if (wantsOptional) { - checks.push(["Apple Silicon DMG", provenance.macArm64Dmg, "darwin", "arm64"]); - } - // Only enforced when the WSL payload was actually inspected. `undefined` - // means the caller did not extract it (for example a pure manifest check). - if (targets === "all" && provenance.embeddedWslEqualsStandalone === false) { - problems.push( - "the WSL runtime embedded in the Windows installer is not byte-identical to the standalone Linux archive", - ); - } - if ( - targets === "all" && - provenance.windowsInstaller !== undefined && - provenance.linuxArchive !== undefined && - provenance.embeddedWslEqualsStandalone === undefined - ) { - problems.push( - "the Windows installer was inspected but its embedded WSL runtime was not compared to the standalone Linux archive", - ); - } - for (const [label, record, platform, arch] of checks) { - if (record === undefined) continue; // Not inspected in this invocation. + for (const [key, record] of recordChecks) { + const label = PACKAGED_PROVENANCE_LABELS[key]; + if (record === undefined) { + if (required.has(key)) { + problems.push(`${label} provenance was required but was not inspected`); + } + continue; + } if (record === null) { problems.push(`${label} has no readable packaged provenance`); continue; @@ -495,13 +702,87 @@ export function verifyPackagedProvenance(input: { if (record.version !== expected.version) { problems.push(`${label} provenance version is ${record.version}`); } - if (record.platform !== platform) { + const platform = PACKAGED_PLATFORM[key]; + if (platform !== undefined && record.platform !== platform) { problems.push(`${label} provenance platform is ${record.platform}, expected ${platform}`); } - if (record.arch !== arch) { + const arch = PACKAGED_ARCH[key]; + if (arch !== undefined && record.arch !== arch) { problems.push(`${label} provenance arch is ${record.arch}, expected ${arch}`); } } + + // The bundled server sidecar records name+version only (no repository/SHA). + const serverLabel = PACKAGED_PROVENANCE_LABELS.windowsServerBundle; + if (provenance.windowsServerBundle !== undefined) { + const server = provenance.windowsServerBundle; + if (server === null) { + problems.push(`${serverLabel} has no readable packaged metadata`); + } else { + if (server.name !== "t3code-server") { + problems.push(`${serverLabel} name is ${server.name}, expected t3code-server`); + } + if (server.version !== expected.version) { + problems.push(`${serverLabel} version is ${server.version}, expected ${expected.version}`); + } + } + } else if (required.has("windowsServerBundle")) { + problems.push(`${serverLabel} provenance was required but was not inspected`); + } + + if (targets === "all" && provenance.embeddedWslEqualsStandalone === false) { + problems.push( + "the WSL runtime embedded in the Windows installer is not byte-identical to the standalone Linux archive", + ); + } + if ( + targets === "all" && + required.has("embeddedWsl") && + required.has("linuxArchive") && + provenance.embeddedWslEqualsStandalone === undefined + ) { + problems.push( + "the Windows installer was inspected but its embedded WSL runtime was not compared to the standalone Linux archive", + ); + } +} + +/** + * Per-target packaged-provenance verification for a host that is building one + * platform before the aggregate exists. It requires only that platform's own + * components, and accepts digest-bound evidence for any it cannot open. + */ +export function verifyTargetPackagedProvenance(input: { + readonly provenance: PackagedProvenance; + readonly evidence: ReadonlyArray; + readonly observedAssets: ReadonlyArray; + readonly expected: CandidateExpectations; + readonly targets: CandidateTargetSelection; + readonly includeMacosArm64: boolean; +}): VerificationResult { + const problems: string[] = []; + const requiredKeys = requiredProvenanceKeysForSelection({ + targets: input.targets, + artifacts: provenanceAvailabilityFromAssets(input.expected.version, input.observedAssets), + includeMacosArm64: input.includeMacosArm64, + }); + const merged = mergePackagedInspection({ + provenance: input.provenance, + evidence: input.evidence, + observedAssets: input.observedAssets, + version: input.expected.version, + requiredKeys, + }); + problems.push(...merged.problems); + verifyPackagedProvenance({ + problems, + provenance: merged.provenance, + expected: input.expected, + targets: input.targets, + includeMacosArm64: input.includeMacosArm64, + requiredKeys, + }); + return failures(problems); } export interface VerifyPromotionInput extends VerifyCandidateInput { @@ -519,7 +800,13 @@ export interface VerifyPromotionInput extends VerifyCandidateInput { * authorization gate must actually exist. */ export function verifyPromotion(input: VerifyPromotionInput): VerificationResult { - const candidate = verifyCandidate({ ...input, targets: "all", packagedProvenance: undefined }); + // Promotion must not qualify bytes that were never inspected: it either + // re-inspects the downloaded artifacts or consumes digest-bound evidence. + const candidate = verifyCandidate({ + ...input, + targets: "all", + requirePackagedProvenance: input.requirePackagedProvenance ?? true, + }); const problems = [...candidate.failures]; if (input.tagTargetSha !== input.expected.sourceSha) { diff --git a/scripts/verify-fork-candidate.ts b/scripts/verify-fork-candidate.ts index e786d50caa93..4b7b44bd7f0b 100644 --- a/scripts/verify-fork-candidate.ts +++ b/scripts/verify-fork-candidate.ts @@ -14,7 +14,12 @@ * before creating a release. * * It never builds or mutates an asset; it only reads bytes and writes the - * manifest/checksum metadata beside them. + * manifest/checksum/evidence metadata beside them. + * + * Required packaged inspection is fail-closed: for every selected target whose + * artifact is present, the packaged provenance must be read from the actual + * bytes (locally) or supplied as digest-bound evidence from a native host. A + * missing extraction tool or unsupported host is BLOCKED, not verified. */ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -24,6 +29,7 @@ import { CANDIDATE_MANIFEST_FILE_NAME, NATIVE_RECEIPTS_FILE_NAME, NATIVE_RECEIPTS_SCHEMA_VERSION, + PACKAGED_INSPECTION_FILE_NAME, RELEASE_ENVIRONMENT, SHA256SUMS_FILE_NAME, compareStableVersions, @@ -33,8 +39,10 @@ import { sha256Hex, verifyCandidate, verifyPromotion, + verifyTargetPackagedProvenance, type CandidateTargetSelection, type NativeReceipt, + type PackagedInspectionEvidence, type ReleaseAsset, type ReleaseCandidateManifest, } from "./lib/fork-release-manifest.ts"; @@ -60,6 +68,8 @@ interface Args { latestVersion: string | undefined; authorizationGateExists: boolean; nativeReceiptsPath: string | undefined; + emitInspection: string | undefined; + inspectionEvidence: ReadonlyArray; } function parseArgs(argv: ReadonlyArray): Args { @@ -92,6 +102,10 @@ function parseArgs(argv: ReadonlyArray): Args { if (!["all", "linux", "win", "mac"].includes(targets)) { throw new Error("--targets must be all, linux, win, or mac"); } + const inspectionEvidence = (values.get("inspection-evidence") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); return { candidateDir: required("candidate-dir"), version: required("version"), @@ -113,6 +127,8 @@ function parseArgs(argv: ReadonlyArray): Args { latestVersion: values.get("latest-version")?.trim(), authorizationGateExists: bool("authorization-gate-exists"), nativeReceiptsPath: values.get("native-receipts")?.trim(), + emitInspection: values.get("emit-inspection")?.trim(), + inspectionEvidence, }; } @@ -120,6 +136,7 @@ const META_FILES = new Set([ CANDIDATE_MANIFEST_FILE_NAME, NATIVE_RECEIPTS_FILE_NAME, SHA256SUMS_FILE_NAME, + PACKAGED_INSPECTION_FILE_NAME, ]); function listAssetFiles(dir: string): string[] { @@ -153,6 +170,16 @@ function readNativeReceipts(dir: string): NativeReceipt[] { return NodeFS.existsSync(path) ? readNativeReceiptsFile(path) : []; } +function readInspectionEvidence(paths: ReadonlyArray): PackagedInspectionEvidence[] { + return paths.map((path) => { + const parsed = JSON.parse(NodeFS.readFileSync(path, "utf8")) as PackagedInspectionEvidence; + if (parsed.schemaVersion !== 1) { + throw new Error(`${path} is not packaged inspection evidence`); + } + return parsed; + }); +} + function fail(problems: ReadonlyArray): never { for (const problem of problems) { console.error(`::error::${problem}`); @@ -176,12 +203,18 @@ function highestStableVersion(list: string | undefined): string | undefined { /** * Verifies one platform's own artifacts before the aggregate manifest exists. * It requires that platform's exact asset names and checks the real embedded - * provenance; it never demands another platform's bytes. + * provenance (locally or via digest-bound evidence); it never demands another + * platform's bytes, and it never skips a required inspection. */ function verifyPerTargetProvenance( args: Args, observedAssets: ReadonlyArray, -): { ok: boolean; failures: ReadonlyArray } { + evidence: ReadonlyArray, +): { + ok: boolean; + failures: ReadonlyArray; + evidence: PackagedInspectionEvidence | undefined; +} { const problems: string[] = []; const expectedNames = requiredReleaseAssetNamesForTargets(args.version, args.targets, { includeMacosArm64: args.includeMacosArm64, @@ -196,44 +229,37 @@ function verifyPerTargetProvenance( problems.push(`${name} is empty`); } } - if (args.inspectProvenance) { - const provenance = inspectCandidateProvenance({ - candidateDir: args.candidateDir, - version: args.version, - targets: args.targets, - includeMacosArm64: args.includeMacosArm64, - }); - const records: Array< - [string, { readonly sourceSha: string; readonly version: string } | null | undefined] - > = - args.targets === "linux" - ? [["Linux runtime archive", provenance.linuxArchive]] - : args.targets === "win" - ? [ - ["Windows CLI archive", provenance.windowsZip], - ["Windows installer", provenance.windowsInstaller], - ] - : [["Intel macOS DMG", provenance.macDmg]]; - for (const [label, record] of records) { - if (record === undefined) continue; - if (record === null) { - problems.push(`${label} has no readable packaged provenance`); - continue; - } - if (record.sourceSha !== args.sha) { - problems.push(`${label} provenance sourceSha is ${record.sourceSha}, expected ${args.sha}`); - } - if (record.version !== args.version) { - problems.push(`${label} provenance version is ${record.version}, expected ${args.version}`); - } - } + + if (!args.inspectProvenance) { + return { ok: problems.length === 0, failures: problems, evidence: undefined }; } - return problems.length === 0 ? { ok: true, failures: [] } : { ok: false, failures: problems }; + + const inspection = inspectCandidateProvenance({ + candidateDir: args.candidateDir, + version: args.version, + targets: args.targets, + includeMacosArm64: args.includeMacosArm64, + }); + const result = verifyTargetPackagedProvenance({ + provenance: inspection.provenance, + evidence: [...evidence, inspection.evidence], + observedAssets, + expected: { repository: args.repository, version: args.version, sourceSha: args.sha }, + targets: args.targets, + includeMacosArm64: args.includeMacosArm64, + }); + problems.push(...result.failures); + return { + ok: problems.length === 0, + failures: problems, + evidence: inspection.evidence, + }; } function main(): void { const args = parseArgs(process.argv.slice(2)); const observedAssets = observeAssets(args.candidateDir); + const externalEvidence = readInspectionEvidence(args.inspectionEvidence); const manifestPath = NodePath.join(args.candidateDir, CANDIDATE_MANIFEST_FILE_NAME); if (args.writeManifest) { @@ -266,7 +292,11 @@ function main(): void { // checks only this platform's own assets and their embedded provenance. The // aggregate step (targets: all) is the one that requires the manifest. if (!NodeFS.existsSync(manifestPath) && args.targets !== "all") { - const result = verifyPerTargetProvenance(args, observedAssets); + const result = verifyPerTargetProvenance(args, observedAssets, externalEvidence); + if (result.evidence !== undefined && args.emitInspection !== undefined) { + NodeFS.writeFileSync(args.emitInspection, `${JSON.stringify(result.evidence, null, 2)}\n`); + console.log(`Wrote native inspection evidence to ${args.emitInspection}`); + } if (!result.ok) fail(result.failures); console.log( `Per-target verification passed: ${observedAssets.length} ${args.targets} asset(s) for ${args.repository} v${args.version} @ ${args.sha}.`, @@ -316,6 +346,24 @@ function main(): void { } } + const inspection = args.inspectProvenance + ? inspectCandidateProvenance({ + candidateDir: args.candidateDir, + version: args.version, + targets: args.targets, + includeMacosArm64: args.includeMacosArm64, + }) + : undefined; + if (inspection !== undefined && args.emitInspection !== undefined) { + NodeFS.writeFileSync(args.emitInspection, `${JSON.stringify(inspection.evidence, null, 2)}\n`); + console.log(`Wrote native inspection evidence to ${args.emitInspection}`); + } + const packagedProvenance = inspection?.provenance; + const inspectionEvidence = [ + ...externalEvidence, + ...(inspection === undefined ? [] : [inspection.evidence]), + ]; + const result = args.promote ? verifyPromotion({ manifest, @@ -328,6 +376,9 @@ function main(): void { tagExists: args.tagExists, latestExistingVersion: highestStableVersion(args.latestVersion), authorizationGateExists: args.authorizationGateExists, + packagedProvenance, + inspectionEvidence, + requirePackagedProvenance: args.inspectProvenance, }) : verifyCandidate({ manifest, @@ -336,15 +387,9 @@ function main(): void { includeMacosArm64: args.includeMacosArm64, targets: args.targets, requireNativeReceipts: args.requireNativeReceipts, - packagedProvenance: - args.promote || !args.inspectProvenance - ? undefined - : inspectCandidateProvenance({ - candidateDir: args.candidateDir, - version: args.version, - targets: args.targets, - includeMacosArm64: args.includeMacosArm64, - }), + packagedProvenance, + inspectionEvidence, + requirePackagedProvenance: args.inspectProvenance, }); if (!result.ok) { From 23e52963cfc9799fba70e458b39507d130e0e334 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 20:27:35 -0400 Subject: [PATCH 09/10] fix(release): carry digest-bound inspection evidence through the local route The per-target step now emits fork-inspection-evidence-.json and the aggregate phase discovers and consumes those files, so a host that cannot open another platform's artifact (for example the Mac aggregate and the Windows NSIS payload) can still qualify the exact bytes a native host inspected. --- scripts/build-fork-candidate.ts | 28 ++++++++++++++++ scripts/lib/candidate-build-plan.test.ts | 30 +++++++++++++++++ scripts/lib/candidate-build-plan.ts | 42 ++++++++++++++++-------- scripts/lib/fork-release-manifest.ts | 2 ++ scripts/verify-fork-candidate.ts | 2 ++ 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/scripts/build-fork-candidate.ts b/scripts/build-fork-candidate.ts index 4b81dd99ff9b..eeca54a797f1 100644 --- a/scripts/build-fork-candidate.ts +++ b/scripts/build-fork-candidate.ts @@ -40,6 +40,7 @@ import { type CandidatePlanStep, type CandidateTarget, } from "./lib/candidate-build-plan.ts"; +import { PACKAGED_INSPECTION_FILE_PREFIX } from "./lib/fork-release-manifest.ts"; type Phase = "target" | "aggregate"; type SourceMode = "public" | "candidate"; @@ -59,6 +60,7 @@ interface Args { assumeInstalled: boolean; execute: boolean; keepGoing: boolean; + inspectionEvidence: ReadonlyArray; } function parseArgs(argv: ReadonlyArray): Args { @@ -110,6 +112,10 @@ function parseArgs(argv: ReadonlyArray): Args { assumeInstalled: flags.has("assume-installed"), execute: flags.has("execute"), keepGoing: flags.has("keep-going"), + inspectionEvidence: (values.get("inspection-evidence") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""), }; } @@ -228,6 +234,23 @@ function buildPlan(args: Args): ReadonlyArray { }); } +/** + * The evidence files the aggregate should consume: any explicit `--inspection-evidence` + * plus every per-target evidence file already staged in the shared directory. + * Each is digest-bound, so a stale file simply cannot qualify changed bytes. + */ +function discoveredInspectionEvidence(args: Args): ReadonlyArray { + const discovered: string[] = []; + if (NodeFS.existsSync(args.outputDir)) { + for (const name of NodeFS.readdirSync(args.outputDir).sort()) { + if (name.startsWith(PACKAGED_INSPECTION_FILE_PREFIX) && name.endsWith(".json")) { + discovered.push(NodePath.join(args.outputDir, name)); + } + } + } + return [...new Set([...args.inspectionEvidence, ...discovered])]; +} + function main(): void { const args = parseArgs(process.argv.slice(2)); const aggregateStep = planCandidateVerification({ @@ -236,6 +259,7 @@ function main(): void { repository: args.repository, candidateDir: args.outputDir, includeMacosArm64: args.includeMacosArm64, + inspectionEvidence: discoveredInspectionEvidence(args), }); if (args.phase === "aggregate") { @@ -263,6 +287,10 @@ function main(): void { sourceSha: args.sha, repository: args.repository, candidateDir: args.outputDir, + emitInspection: NodePath.join( + args.outputDir, + `${PACKAGED_INSPECTION_FILE_PREFIX}-${args.target}.json`, + ), }); const steps = [...buildSteps, ...stageSteps, verifyStep]; diff --git a/scripts/lib/candidate-build-plan.test.ts b/scripts/lib/candidate-build-plan.test.ts index 720bac165204..5b110b05860a 100644 --- a/scripts/lib/candidate-build-plan.test.ts +++ b/scripts/lib/candidate-build-plan.test.ts @@ -117,3 +117,33 @@ it("uses the shared aggregate verifier so local and CI candidates are frozen ide // Native receipts are a promotion gate, not a build-time requirement. assert.notInclude(command, "--require-native-receipts"); }); + +it("emits and consumes digest-bound inspection evidence through the local route", () => { + const verify = planCandidateTargetVerification({ + target: "win", + version: VERSION, + sourceSha: SHA, + repository: "nullStack65/t3code", + candidateDir: "candidate", + emitInspection: "candidate/fork-inspection-evidence-win.json", + }); + assert.include( + verify.command.join(" "), + "--emit-inspection candidate/fork-inspection-evidence-win.json", + ); + + const aggregate = planCandidateVerification({ + version: VERSION, + sourceSha: SHA, + repository: "nullStack65/t3code", + candidateDir: "candidate", + inspectionEvidence: [ + "candidate/fork-inspection-evidence-win.json", + "candidate/fork-inspection-evidence-mac.json", + ], + }); + assert.include( + aggregate.command.join(" "), + "--inspection-evidence candidate/fork-inspection-evidence-win.json,candidate/fork-inspection-evidence-mac.json", + ); +}); diff --git a/scripts/lib/candidate-build-plan.ts b/scripts/lib/candidate-build-plan.ts index e4c245aee9de..10f82873cb79 100644 --- a/scripts/lib/candidate-build-plan.ts +++ b/scripts/lib/candidate-build-plan.ts @@ -57,6 +57,11 @@ export interface VerificationPlanInput { readonly repository: string; readonly candidateDir: string; readonly includeMacosArm64?: boolean; + /** + * Native inspection evidence files to consume for components this host cannot + * open. Each must be bound to the artifact's exact digest. + */ + readonly inspectionEvidence?: ReadonlyArray | undefined; } /** The per-target asset each target is responsible for producing. */ @@ -287,24 +292,30 @@ export function planCandidateTargetVerification(input: { readonly sourceSha: string; readonly repository: string; readonly candidateDir: string; + /** When set, write this host's native inspection evidence here. */ + readonly emitInspection?: string | undefined; }): CandidatePlanStep { + const command = [ + "node", + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + input.candidateDir, + "--version", + input.version, + "--sha", + input.sourceSha, + "--repository", + input.repository, + "--targets", + input.target, + ]; + if (input.emitInspection !== undefined) { + command.push("--emit-inspection", input.emitInspection); + } return { id: `verify-${input.target}`, description: `Verify the ${input.target} artifacts' presence and embedded provenance`, - command: [ - "node", - "scripts/verify-fork-candidate.ts", - "--candidate-dir", - input.candidateDir, - "--version", - input.version, - "--sha", - input.sourceSha, - "--repository", - input.repository, - "--targets", - input.target, - ], + command, phase: "verify", }; } @@ -333,6 +344,9 @@ export function planCandidateVerification(input: VerificationPlanInput): Candida if (input.includeMacosArm64 === true) { command.push("--include-macos-arm64"); } + if (input.inspectionEvidence !== undefined && input.inspectionEvidence.length > 0) { + command.push("--inspection-evidence", input.inspectionEvidence.join(",")); + } return { id: "verify", description: "Freeze the manifest/checksums and verify the complete candidate bytes", diff --git a/scripts/lib/fork-release-manifest.ts b/scripts/lib/fork-release-manifest.ts index da3b0824cfeb..4ac84d62b275 100644 --- a/scripts/lib/fork-release-manifest.ts +++ b/scripts/lib/fork-release-manifest.ts @@ -26,6 +26,8 @@ export const CANDIDATE_MANIFEST_FILE_NAME = "fork-release-manifest.json"; export const NATIVE_RECEIPTS_FILE_NAME = "fork-native-receipts.json"; export const SHA256SUMS_FILE_NAME = "SHA256SUMS"; export const PACKAGED_INSPECTION_FILE_NAME = "fork-inspection-evidence.json"; +/** Prefix for per-target native inspection evidence files staged beside a candidate. */ +export const PACKAGED_INSPECTION_FILE_PREFIX = "fork-inspection-evidence"; export const CANDIDATE_ARTIFACT_NAME = "fork-release-candidate"; export const NATIVE_RECEIPTS_ARTIFACT_NAME = "fork-release-native-receipts"; diff --git a/scripts/verify-fork-candidate.ts b/scripts/verify-fork-candidate.ts index 4b7b44bd7f0b..ad3bcf0792c8 100644 --- a/scripts/verify-fork-candidate.ts +++ b/scripts/verify-fork-candidate.ts @@ -30,6 +30,7 @@ import { NATIVE_RECEIPTS_FILE_NAME, NATIVE_RECEIPTS_SCHEMA_VERSION, PACKAGED_INSPECTION_FILE_NAME, + PACKAGED_INSPECTION_FILE_PREFIX, RELEASE_ENVIRONMENT, SHA256SUMS_FILE_NAME, compareStableVersions, @@ -142,6 +143,7 @@ const META_FILES = new Set([ function listAssetFiles(dir: string): string[] { return NodeFS.readdirSync(dir) .filter((name) => !META_FILES.has(name)) + .filter((name) => !name.startsWith(PACKAGED_INSPECTION_FILE_PREFIX)) .filter((name) => NodeFS.statSync(NodePath.join(dir, name)).isFile()) .sort(); } From 929b63795e7696855ada61de5fd359dc2f51da78 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Wed, 23 Sep 2026 20:34:16 -0400 Subject: [PATCH 10/10] fix(release): do not stage the optional arm64 DMG unless requested An Intel-only mac build failed at staging because candidateTargetAssets("mac") always demanded T3-Code--arm64.dmg. Thread includeMacosArm64 through the build plan and staging, and build the arm64 DMG only when it is explicitly requested. The Intel DMG itself already built; this only fixes the entry point. --- scripts/build-fork-candidate.ts | 2 ++ scripts/lib/candidate-build-plan.test.ts | 18 +++++++++++ scripts/lib/candidate-build-plan.ts | 38 ++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/scripts/build-fork-candidate.ts b/scripts/build-fork-candidate.ts index eeca54a797f1..b9b4955983be 100644 --- a/scripts/build-fork-candidate.ts +++ b/scripts/build-fork-candidate.ts @@ -231,6 +231,7 @@ function buildPlan(args: Args): ReadonlyArray { resourceMonitorDir: args.resourceMonitorDir, linuxArchive: args.linuxArchive, assumeInstalled: args.assumeInstalled, + includeMacosArm64: args.includeMacosArm64, }); } @@ -280,6 +281,7 @@ function main(): void { target: args.target, version: args.version, outputDir: args.outputDir, + includeMacosArm64: args.includeMacosArm64, }); const verifyStep = planCandidateTargetVerification({ target: args.target, diff --git a/scripts/lib/candidate-build-plan.test.ts b/scripts/lib/candidate-build-plan.test.ts index 5b110b05860a..1ade029c1ab0 100644 --- a/scripts/lib/candidate-build-plan.test.ts +++ b/scripts/lib/candidate-build-plan.test.ts @@ -102,6 +102,24 @@ it("stages each target artifact into the shared candidate directory", () => { assert.include(commands, "--output-dir out dir"); }); +it("does not demand the optional Apple Silicon DMG for an Intel-only mac build", () => { + const intel = flat( + planCandidateStaging({ target: "mac", version: VERSION, outputDir: "candidate" }), + ); + assert.include(intel, `T3-Code-${VERSION}-x64.dmg`); + assert.notInclude(intel, `T3-Code-${VERSION}-arm64.dmg`); + + const withArm = flat( + planCandidateStaging({ + target: "mac", + version: VERSION, + outputDir: "candidate", + includeMacosArm64: true, + }), + ); + assert.include(withArm, `T3-Code-${VERSION}-arm64.dmg`); +}); + it("uses the shared aggregate verifier so local and CI candidates are frozen identically", () => { const step = planCandidateVerification({ version: VERSION, diff --git a/scripts/lib/candidate-build-plan.ts b/scripts/lib/candidate-build-plan.ts index 10f82873cb79..2b8ad21d40f4 100644 --- a/scripts/lib/candidate-build-plan.ts +++ b/scripts/lib/candidate-build-plan.ts @@ -49,6 +49,8 @@ export interface CandidatePlanInput { * installed. When omitted the plan runs `vp install` first. */ readonly assumeInstalled?: boolean | undefined; + /** Build and stage the optional Apple Silicon DMG too. */ + readonly includeMacosArm64?: boolean | undefined; } export interface VerificationPlanInput { @@ -68,12 +70,17 @@ export interface VerificationPlanInput { export function candidateTargetAssets( target: CandidateTarget, version: string, + options: { readonly includeMacosArm64?: boolean } = {}, ): ReadonlyArray { if (target === "linux") return [`t3-${version}-linux-x64.tar.gz`]; if (target === "win") { return [`T3-Code-${version}-x64.exe`, `t3-${version}-win32-x64.zip`]; } - return [`T3-Code-${version}-x64.dmg`, `T3-Code-${version}-arm64.dmg`]; + // Apple Silicon is optional and deferred; it must never be demanded unless the + // caller explicitly asked for it, or an Intel-only build fails at staging. + const mac = [`T3-Code-${version}-x64.dmg`]; + if (options.includeMacosArm64 === true) mac.push(`T3-Code-${version}-arm64.dmg`); + return mac; } /** The steps that build one target's artifacts into `outputDir`. */ @@ -278,6 +285,30 @@ export function planCandidateBuild(input: CandidatePlanInput): ReadonlyArray { - return candidateTargetAssets(input.target, input.version).map((asset) => ({ + return candidateTargetAssets(input.target, input.version, { + includeMacosArm64: input.includeMacosArm64 === true, + }).map((asset) => ({ id: `stage-${asset}`, description: `Stage ${asset} into the shared candidate directory`, command: [