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..cbd2bc31d9ac --- /dev/null +++ b/docs/operations/fork-release.md @@ -0,0 +1,270 @@ +# 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. + +### 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.githubusercontent.com/nullStack65/t3code/main/scripts/install.sh | sh +``` + +This puts `t3` in `~/.local/bin`. Set `T3CODE_CHANNEL=nightly` only if you know +why; fork releases are plain stable versions. `T3CODE_RELEASE_REPOSITORY` and +`T3CODE_RELEASE_BASE_URL` exist for mirrors. + +## Updating + +Download the newer artifact and install it over the existing one. Your settings, +sign-in, pairings, projects, and databases are preserved. + +- 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( - "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/pingdotgg/t3code/releases/download/v1.2.3", + "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/nullStack65/t3code/releases/download/v1.2.3", ); expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe( "https://mirror.example/t3/v1.2.3", ); + expect(cliReleaseDownloadBaseUrl("1.2.3", undefined, "example-org/fork")).toBe( + "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/example-org/fork/releases/download/v1.2.3", + ); + }); + + it("defaults the release repository to the fork and honors an override", () => { + // The shipped default must never be upstream, or a fork install would + // discover and download official `pingdotgg` builds. + expect(resolveCliReleaseRepository({})).toBe("nullStack65/t3code"); + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: "example-org/fork" })).toBe( + "example-org/fork", + ); + // A blank or malformed override must not silently redirect to nothing. + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: " " })).toBe( + "nullStack65/t3code", + ); + expect(resolveCliReleaseRepository({ T3CODE_RELEASE_REPOSITORY: "not-a-repo" })).toBe( + "nullStack65/t3code", + ); }); it("parses sha256sum output including binary-mode markers", () => { @@ -87,8 +107,11 @@ describe("cliRelease", () => { it("pages through the release index at the largest page GitHub allows", () => { expect(cliReleaseIndexPageUrl(1)).toBe( - "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1", + "https://api.github.com/repos/nullStack65/t3code/releases?per_page=100&page=1", ); expect(cliReleaseIndexPageUrl(3)).toContain("page=3"); + expect(cliReleaseIndexPageUrl(2, "example-org/fork")).toBe( + "https://api.github.com/repos/example-org/fork/releases?per_page=100&page=2", + ); }); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 28f0d530bb29..a3d19698558e 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -5,11 +5,32 @@ * platform key, so a rename here is a release-breaking change. */ -const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +/** + * The repository this build resolves its own releases from. This is a fork, so + * the default must never be upstream: an install that fell back to + * `pingdotgg/t3code` would silently update onto an official build and lose the + * fork. `T3CODE_RELEASE_REPOSITORY` overrides it for mirrors and tests; unlike + * `T3CODE_RELEASE_BASE_URL` it also retargets the release-index lookup that + * `t3 update` and the install scripts use to discover a version. + */ +export const CLI_RELEASE_REPOSITORY = "nullStack65/t3code"; +export const CLI_RELEASE_REPOSITORY_ENV = "T3CODE_RELEASE_REPOSITORY"; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; /** Overrides the download origin for mirrors and air-gapped installs. */ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; +const REPOSITORY_PATTERN = /^[^/\s]+\/[^/\s]+$/; + +/** The `owner/repo` this build downloads and discovers releases from. */ +export function resolveCliReleaseRepository( + env: Readonly> = process.env, +): string { + const override = env[CLI_RELEASE_REPOSITORY_ENV]?.trim(); + return override !== undefined && override !== "" && REPOSITORY_PATTERN.test(override) + ? override + : CLI_RELEASE_REPOSITORY; +} + /** * The archives a release attaches. Kept in step with the build_linux_cli * matrix, build_windows_arm64_cli, and the `cli_archive` rows in @@ -54,14 +75,17 @@ export function cliArchiveFileName(version: string, platformKey: CliArchivePlatf return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; } -const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; +const CLI_RELEASE_DEFAULT_BASE_URL = (repository: string) => + `https://github.com/${repository}/releases/download`; /** Directory that `releases/download//` lives under. */ export function cliReleaseDownloadBaseUrl( version: string, - baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, + baseUrl: string | undefined = undefined, + repository: string = resolveCliReleaseRepository(), ): string { - return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; + const origin = baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL(repository); + return `${origin.replace(/\/+$/, "")}/v${version}`; } /** @@ -97,8 +121,11 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel { * until a channel match turns up; a busy nightly train can push the newest * preview or stable release past any single page. */ -export function cliReleaseIndexPageUrl(page: number): string { - return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; +export function cliReleaseIndexPageUrl( + page: number, + repository: string = resolveCliReleaseRepository(), +): string { + return `https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`; } /** diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..d736d65aa7ab 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -118,7 +118,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include( script, - "T3_RELEASE_BASE_URL='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/pingdotgg/t3code/releases/download'", + "T3_RELEASE_BASE_URL='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/nullStack65/t3code/releases/download'", ); assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"'); assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index e5839989efc7..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..b9b4955983be --- /dev/null +++ b/scripts/build-fork-candidate.ts @@ -0,0 +1,340 @@ +#!/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"; + +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 stageResourceMonitor(args: Args): void { + const target = NodePath.join(args.resourceMonitorDir, "linux-x64"); + NodeFS.mkdirSync(target, { recursive: true }); + NodeFS.copyFileSync( + NodePath.join("native/resource-monitor/target/release/t3-resource-monitor"), + NodePath.join(target, "t3-resource-monitor"), + ); + console.log(`Staged resource monitor into ${target}`); +} + +function 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 { + if (step.id === "resource-monitor" && args.target === "linux") { + run(step, args); + stageResourceMonitor(args); + continue; + } + run(step, args); + } catch (error) { + if (!args.keepGoing) throw error; + console.error(`step '${step.id}' failed; preserving completed outputs and continuing.`); + console.error(String(error)); + } + } + 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..c372a5a02318 --- /dev/null +++ b/scripts/fork-release-entrypoints.test.ts @@ -0,0 +1,659 @@ +// @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 }); + } +}); + +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 { "/$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-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/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("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/nullStack65/t3code"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("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/nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("git@github.com:nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal( + parseSourceRepository("ssh://git@github.com/nullStack65/t3code.git"), + "nullStack65/t3code", + ); + assert.equal(parseSourceRepository("not-a-repo"), undefined); + assert.equal(parseSourceRepository(undefined), undefined); +}); + +it("prefers an explicit repository and SHA over GitHub Actions and the fork default", () => { + assert.equal( + resolveSourceRepository({ + T3CODE_SOURCE_REPOSITORY: "explicit/fork", + GITHUB_REPOSITORY: "actions/repo", + }), + "explicit/fork", + ); + assert.equal(resolveSourceRepository({ GITHUB_REPOSITORY: "actions/repo" }), "actions/repo"); + // A local fork worktree usually names upstream `origin`; the default must + // still be the fork so a local build is never labelled as an upstream build. + assert.equal(resolveSourceRepository({}), "nullStack65/t3code"); + + assert.equal( + resolveSourceSha({ T3CODE_SOURCE_SHA: FULL_SHA, GITHUB_SHA: "a".repeat(40) }), + FULL_SHA, + ); + assert.equal(resolveSourceSha({ GITHUB_SHA: FULL_SHA }), FULL_SHA); + assert.equal(resolveSourceSha({ GITHUB_SHA: "not-a-sha" }), "unknown"); + assert.equal(resolveSourceSha({}, undefined), "unknown"); +}); + +it("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/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..ad3bcf0792c8 --- /dev/null +++ b/scripts/verify-fork-candidate.ts @@ -0,0 +1,413 @@ +#!/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)) { + 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, + requirePackagedProvenance: args.inspectProvenance, + }) + : 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();