diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c2b27e6..586ef58 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,20 @@ updates: directory: / schedule: interval: monthly + # These are minimum-version contract fixtures. Newer OpenCode runtimes are + # covered by compatibility.yml; raising the minimum requires coordinated + # source, integration, notice, and compatibility review. + ignore: + - dependency-name: "@opencode-ai/plugin" + update-types: + - "version-update:semver-patch" + - "version-update:semver-minor" + - "version-update:semver-major" + - dependency-name: "@opencode-ai/sdk" + update-types: + - "version-update:semver-patch" + - "version-update:semver-minor" + - "version-update:semver-major" - package-ecosystem: bun directory: /picker schedule: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 37badf2..0f63c5f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -460,6 +460,7 @@ jobs: environment: release-signing-windows permissions: actions: read + id-token: write strategy: fail-fast: false matrix: @@ -474,12 +475,17 @@ jobs: with: name: picker-unsigned-windows-${{ matrix.arch }} path: ${{ runner.temp }}/unsigned-picker - - name: Sign and verify Windows picker + - name: Validate Windows signing configuration and unsigned picker shell: powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ". '{0}'" env: + AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_ARTIFACT_SIGNING_ENDPOINT: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} EXPECTED_PE_MACHINE: ${{ matrix.expected_machine }} - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + EXPECTED_WINDOWS_SIGNER_SUBJECT: ${{ vars.EXPECTED_WINDOWS_SIGNER_SUBJECT }} run: | Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" @@ -504,11 +510,47 @@ jobs: ) { throw "Signing must run with the trusted system Windows PowerShell" } - foreach ($name in @("WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD")) { + $required = @( + "AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME", + "AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", + "AZURE_ARTIFACT_SIGNING_ENDPOINT", + "AZURE_CLIENT_ID", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TENANT_ID", + "EXPECTED_WINDOWS_SIGNER_SUBJECT" + ) + foreach ($name in $required) { if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { - throw "Missing required Windows release secret: $name" + throw "Missing required Windows release environment variable: $name" + } + } + foreach ($name in @("AZURE_CLIENT_ID", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID")) { + $guid = [Guid]::Empty + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [Guid]::TryParse($value, [ref]$guid) -or $guid -eq [Guid]::Empty) { + throw "$name must be a non-zero GUID" } } + $endpoint = $null + if ( + -not [Uri]::TryCreate( + $env:AZURE_ARTIFACT_SIGNING_ENDPOINT, + [UriKind]::Absolute, + [ref]$endpoint + ) -or + $endpoint.Scheme -ne "https" -or + -not $endpoint.IsDefaultPort -or + -not $endpoint.Host.EndsWith( + ".codesigning.azure.net", + [StringComparison]::OrdinalIgnoreCase + ) -or + -not [string]::IsNullOrEmpty($endpoint.UserInfo) -or + -not [string]::IsNullOrEmpty($endpoint.Query) -or + -not [string]::IsNullOrEmpty($endpoint.Fragment) -or + $endpoint.AbsolutePath -ne "/" + ) { + throw "AZURE_ARTIFACT_SIGNING_ENDPOINT must be a root https://*.codesigning.azure.net endpoint" + } $unsignedRoot = [IO.Path]::GetFullPath( [IO.Path]::Combine($env:RUNNER_TEMP, "unsigned-picker") @@ -531,6 +573,13 @@ jobs: ) { throw "Expected exactly one regular unsigned Windows picker artifact" } + $unsignedSignature = Get-AuthenticodeSignature -LiteralPath $picker + if ( + $unsignedSignature.Status -ne + [System.Management.Automation.SignatureStatus]::NotSigned + ) { + throw "Expected an unsigned Windows picker before release signing" + } $expectedMachine = [Convert]::ToUInt16($env:EXPECTED_PE_MACHINE, 16) $stream = [IO.File]::OpenRead($picker) $reader = [IO.BinaryReader]::new($stream) @@ -559,6 +608,113 @@ jobs: $reader.Dispose() } + - name: Authenticate to Azure with GitHub OIDC + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Sign Windows picker with Azure Artifact Signing + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ runner.temp }}\unsigned-picker\picker-windows-${{ matrix.arch }}.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + append-signature: false + description: opencode-model-dispatch native model picker + description-url: https://github.com/${{ github.repository }} + cache-dependencies: false + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + - name: Verify signed Windows picker + shell: powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ". '{0}'" + env: + EXPECTED_PE_MACHINE: ${{ matrix.expected_machine }} + EXPECTED_WINDOWS_SIGNER_SUBJECT: ${{ vars.EXPECTED_WINDOWS_SIGNER_SUBJECT }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" + $expectedPowerShell = [IO.Path]::GetFullPath( + [IO.Path]::Combine( + $env:SystemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe" + ) + ) + $actualPowerShell = [IO.Path]::GetFullPath( + [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName + ) + if ( + -not [string]::Equals( + $actualPowerShell, + $expectedPowerShell, + [StringComparison]::OrdinalIgnoreCase + ) + ) { + throw "Signature verification must run with the trusted system Windows PowerShell" + } + $unsignedRoot = [IO.Path]::GetFullPath( + [IO.Path]::Combine($env:RUNNER_TEMP, "unsigned-picker") + ) + $picker = [IO.Path]::Combine( + $unsignedRoot, + "picker-windows-${{ matrix.arch }}.exe" + ) + $entries = @([IO.Directory]::EnumerateFileSystemEntries($unsignedRoot)) + $pickerInfo = [IO.FileInfo]::new($picker) + if ( + $entries.Count -ne 1 -or + -not $pickerInfo.Exists -or + (($pickerInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or + -not [string]::Equals( + [IO.Path]::GetFullPath($entries[0]), + $picker, + [StringComparison]::OrdinalIgnoreCase + ) + ) { + throw "Expected exactly one regular signed Windows picker artifact" + } + $expectedMachine = [Convert]::ToUInt16($env:EXPECTED_PE_MACHINE, 16) + $stream = [IO.File]::OpenRead($picker) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5a4d) { + throw "Signed Windows picker is not a PE executable" + } + $stream.Position = 0x3c + $peOffset = $reader.ReadUInt32() + if ($peOffset -gt $stream.Length - 6) { + throw "Signed Windows picker has an invalid PE header offset" + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "Signed Windows picker has an invalid PE signature" + } + $machine = $reader.ReadUInt16() + if ($machine -ne $expectedMachine) { + throw ( + "Signed Windows picker has PE machine 0x{0:X4}, expected 0x{1:X4}" -f + $machine, + $expectedMachine + ) + } + } finally { + $reader.Dispose() + } $windowsSdkBin = [IO.Path]::Combine( ${env:ProgramFiles(x86)}, "Windows Kits", @@ -592,43 +748,24 @@ jobs: } [Console]::WriteLine("Using SignTool {0} at {1}", $signtoolVersion, $signtoolPath) - $certificate = [IO.Path]::Combine($env:RUNNER_TEMP, "code-signing.pfx") - [IO.File]::WriteAllBytes( - $certificate, - [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - ) - & $signtoolPath sign /fd SHA256 /td SHA256 /tr https://timestamp.digicert.com /f $certificate /p $env:WINDOWS_CERTIFICATE_PASSWORD $picker - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - & $signtoolPath verify /pa /v $picker + & $signtoolPath verify /pa /all /v $picker if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Remove Windows signing credentials - if: always() - shell: powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ". '{0}'" - run: | - $expectedPowerShell = [IO.Path]::GetFullPath( - [IO.Path]::Combine( - $env:SystemRoot, - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe" - ) - ) - $actualPowerShell = [IO.Path]::GetFullPath( - [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName - ) + $signature = Get-AuthenticodeSignature -LiteralPath $picker + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "PowerShell reported an invalid Authenticode signature: $($signature.StatusMessage)" + } if ( + $null -eq $signature.SignerCertificate -or -not [string]::Equals( - $actualPowerShell, - $expectedPowerShell, - [StringComparison]::OrdinalIgnoreCase + $signature.SignerCertificate.Subject, + $env:EXPECTED_WINDOWS_SIGNER_SUBJECT, + [StringComparison]::Ordinal ) ) { - throw "Cleanup must run with the trusted system Windows PowerShell" + throw "Windows picker signer subject does not match EXPECTED_WINDOWS_SIGNER_SUBJECT" } - $certificate = [IO.Path]::Combine($env:RUNNER_TEMP, "code-signing.pfx") - if ([IO.File]::Exists($certificate)) { - [IO.File]::Delete($certificate) + if ($null -eq $signature.TimeStamperCertificate) { + throw "Windows picker signature is missing its RFC 3161 timestamp" } - name: Retain canonical signed Windows picker if: success() diff --git a/.github/workflows/verify-release-oidc.yml b/.github/workflows/verify-release-oidc.yml new file mode 100644 index 0000000..a6b21e2 --- /dev/null +++ b/.github/workflows/verify-release-oidc.yml @@ -0,0 +1,53 @@ +name: Verify release-signing OIDC + +on: + workflow_dispatch: + +permissions: {} + +jobs: + validate-ref: + name: Require the protected main branch + runs-on: ubuntu-24.04 + steps: + - name: Reject non-main dispatches + shell: bash + run: test "$GITHUB_REF" = "refs/heads/main" + + verify: + name: Verify immutable release-signing subject + needs: validate-ref + runs-on: ubuntu-24.04 + environment: release-signing-windows + permissions: + id-token: write + steps: + - name: Verify immutable OIDC claims + shell: bash + env: + EXPECTED_AUDIENCE: api://AzureADTokenExchange + EXPECTED_ISSUER: https://token.actions.githubusercontent.com + EXPECTED_SUBJECT: repo:Lauritz-Timm@269186225/opencode-model-dispatch@1290427988:environment:release-signing-windows + run: | + set -euo pipefail + response="$( + curl --fail-with-body --silent --show-error --get \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + --data-urlencode "audience=${EXPECTED_AUDIENCE}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}" + )" + token="$(jq -er '.value' <<<"$response")" + payload="$( + node -e ' + const part = process.argv[1]?.split(".")[1] + if (!part) process.exit(1) + process.stdout.write(Buffer.from(part, "base64url").toString("utf8")) + ' "$token" + )" + actual_subject="$(jq -er '.sub' <<<"$payload")" + actual_audience="$(jq -er '.aud' <<<"$payload")" + actual_issuer="$(jq -er '.iss' <<<"$payload")" + test "$actual_subject" = "$EXPECTED_SUBJECT" + test "$actual_audience" = "$EXPECTED_AUDIENCE" + test "$actual_issuer" = "$EXPECTED_ISSUER" + printf 'Verified OIDC subject: %s\n' "$actual_subject" diff --git a/.gitignore b/.gitignore index 488b7de..4d5a490 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist-picker/ /bin/picker-windows-x64.exe /bin/picker-windows-arm64.exe coverage/ +.manual-release/ docs/plan.md docs/spec.md *.log diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 457b0c1..2eac95e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,8 +5,8 @@ component inventory in `third-party/components.json`. Do not edit it by hand. Dependency inputs: -- `bun.lock` (SHA-256: `f2a5f36973431ca6d45214e8ad2d7c2755be4aeab31ebcf420b552596e5687d0`) -- `picker/bun.lock` (SHA-256: `7dcfde9990ba0b2db7ad3996f124ef12b99db463ee32f453de48380d08ea4d36`) +- `bun.lock` (SHA-256: `5cd883f35956099e7050ff28f51454cf98afc1ca3df8a29b6384c8591c1d98a9`) +- `picker/bun.lock` (SHA-256: `2bbfadd4a47521b38aeb89a461dc6b5dc3f9d2f362e1e2023fd0176315942a35`) - `picker/src-tauri/Cargo.lock` (SHA-256: `7dd1e5e67fccc1a12eff5e25a2476a8110a707bd870eb721d6c1cf13dbca1e4c`) ## Bundled JavaScript and adapted source diff --git a/bun.lock b/bun.lock index 1780391..664dce4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "opencode-model-dispatch", "devDependencies": { - "@opencode-ai/plugin": "1.18.10", - "@opencode-ai/sdk": "1.18.9", + "@opencode-ai/plugin": "1.18.7", + "@opencode-ai/sdk": "1.18.7", "bun-types": "1.3.14", "typescript": "7.0.2", }, @@ -30,9 +30,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.10", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.10", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-l4R1ulu5wtA0+cCinzdSPkEO8ZCdIgb/ZjltzstIWQqRqeH3H880OabNaeje/ZCkSoAT8c/o9oT3tdjyFP7o7g=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.7", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.7", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-kXm5hqur5JiF/xJPaQYkuNn7vSl4ya09YgL/pIvIrggWCLFsfo1gXqNbONRcGW7m5Ih2k6kgMXFyjB9or1+dEg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-oDJSmsmiGW+3lNLmZYj3EpUkpiT3ITZBKffH3mrmu2KMJXlkxQ/Nvv7jqPffSM7o8lCdBZS/aCE+2GkA3/92gQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.7", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-5txVELvCxzC3O/MYnRKdTvBN3TqQjE3PaEks3kDRA/moWzMSUhdm15xCth/Mz/NPYyGTn2u4IdScdV9vLTfLRg=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -127,7 +127,5 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - - "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-K+glWbBp5pfGepZ/6EHvuXY3IpVG2qgsEqF4z9mOiUQUrs6KWN72Vdp15Qsl2TCDbemgHWXKQKgbX3b8PSK2hg=="], } } diff --git a/docs/development.md b/docs/development.md index 174e0b9..b22f34b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -32,6 +32,14 @@ bun run typecheck Use the frozen lockfiles in normal development and CI-facing verification. Add or update dependencies deliberately, then commit the resulting lockfile. +The pinned `@opencode-ai/plugin` and `@opencode-ai/sdk` development versions +must equal the lower bound in `engines.opencode`. They are the compile, bundle, +and minimum-runtime contract fixtures, while the nightly compatibility workflow +tests newer OpenCode versions. Dependabot therefore skips ordinary version +updates for those two packages. Dependabot security updates remain enabled and +must trigger a coordinated contract review rather than an isolated version +bump. + ## Repository Structure | Path | Responsibility | diff --git a/docs/manual-integration-gate.md b/docs/manual-integration-gate.md index 8118e62..90c69cc 100644 --- a/docs/manual-integration-gate.md +++ b/docs/manual-integration-gate.md @@ -26,11 +26,66 @@ bun run test bun run typecheck bun run test:package bun run test:opencode -bun run test:package:native:opencode bun run test:picker:rendered bun run test:picker-ready bun run test:gui:auto bun run test:gui +bun run release:manual-candidate +bun run release:manual-candidate:test +bun run release:manual-candidate:test:tui +``` + +Run `release:manual-candidate` after the generic tests so no later build can +silently replace the picker being retained. The command requires a clean +source candidate equal to `origin/main`, builds the plugin and current host +picker, and retains one local-host npm tarball under the git-ignored +`.manual-release/` directory. It prints the exact commit SHA, tarball basename +and SHA-512 SRI, and picker path and SHA-256 needed below. The two `:test` +commands run that retained tarball through the same installed-package native +OpenCode integration; they do not repack it. The automated retained-tarball +integration currently requires Linux/X11. Run this entire gate on a supported +Linux host so the candidate prepared there is the same one subsequently tested +in both live surfaces. + +Create one genuinely empty temporary scratch project and install the retained +tarball into it through OpenCode's supported package installer: + +```sh +manual_scratch="$(mktemp -d -t opencode-model-dispatch-release-XXXXXX)" +bun run release:manual-candidate:install -- --project "$manual_scratch" +``` + +The install helper verifies the recorded commit and both hashes again, starts +an isolated loopback-only npm registry, asks the local `opencode` executable to +install the exact `opencode-model-dispatch@` package into the scratch +project, confirms that registry served the retained tarball, and then stops +the registry. Provider resolution during the live checks therefore uses the +operator's normal OpenCode configuration rather than a registry override. + +Run the TUI from that directory: + +```sh +cd "$manual_scratch" +opencode +``` + +After the TUI checks, fully quit it. Open the same directory through OpenCode +Desktop's normal **Open project** action and complete the Desktop checks. Do +not run the install helper again, change branches, rebuild, repack, or replace +anything under `.manual-release/` between the two surfaces. Record the scratch +path below as `/opencode-model-dispatch-release-XXXXXX`, never as its +real local path. + +To use another retained directory, choose a repository-relative path already +ignored by Git and pass the same option to every helper invocation: + +```sh +bun run release:manual-candidate -- --output .manual-release/custom +bun run release:manual-candidate:test -- --output .manual-release/custom +bun run release:manual-candidate:test:tui -- --output .manual-release/custom +bun run release:manual-candidate:install -- \ + --output .manual-release/custom \ + --project "$manual_scratch" ``` ## Checklist @@ -38,7 +93,7 @@ bun run test:gui - [ ] `package.json` has a non-zero version and `v` is the intended release tag. - [ ] The tested source commit's full 40-character SHA and npm tarball are recorded below. - [ ] `bun run test:package` passes from a clean checkout. -- [ ] On Linux, `bun run test:package:native:opencode` installs an npm tarball and completes a real OpenCode task through its bundled native picker. +- [ ] On Linux, `bun run release:manual-candidate:test` installs the retained npm tarball and completes a real OpenCode task through its bundled native picker. - [ ] `bun run test:gui` visibly opens the native picker and returns the chosen models. - [ ] On Linux, `bun run test:gui:auto` submits the real Tauri window under X11/Xvfb. - [ ] `bun run test:picker:rendered` passes its computed-theme, responsive-layout, icon-absence, and keyboard assertions in a local browser. diff --git a/docs/releasing.md b/docs/releasing.md index 20e44a1..55c5f6d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -43,6 +43,49 @@ Complete `docs/manual-integration-gate.md` from the exact intended source commit. Record its full `git rev-parse HEAD`, the tested tarball, and the TUI and Desktop results. +Prepare one retained local-host candidate artifact before opening either +surface: + +```sh +bun run release:manual-candidate +``` + +The command fails unless the tracked and untracked source is clean and +`HEAD == origin/main`. It builds both plugin and local picker, stages only that +fresh host picker, packs the result without lifecycle scripts, and writes: + +- `.manual-release/opencode-model-dispatch---.tgz`; +- `.manual-release/manual-candidate.json`. + +The metadata is commit-bound and records the tarball SHA-512 SRI and picker +SHA-256. `.manual-release/` is intentionally ignored by Git, but do not delete, +rebuild, repack, or replace it until both live surfaces have passed. + +On Linux, exercise the retained bytes through both automated real-OpenCode +paths: + +```sh +bun run release:manual-candidate:test +bun run release:manual-candidate:test:tui +``` + +Then create one empty temporary project and install that exact tarball for the +human TUI and Desktop checks: + +```sh +manual_scratch="$(mktemp -d -t opencode-model-dispatch-release-XXXXXX)" +bun run release:manual-candidate:install -- --project "$manual_scratch" +``` + +The installer exposes only the retained candidate and its locked local +dependency graph through a temporary loopback registry. It runs `opencode +plugin --pure` with a commit- and invocation-unique npm alias in the empty +project, confirms the exact tarball was fetched instead of reused from +OpenCode's global package cache, and removes the registry override before the +live checks. Start the TUI in `$manual_scratch`, then fully quit it and open the +same directory in OpenCode Desktop. Copy the five printed evidence values into +the gate and sanitize the project path as documented there. + Commit only the completed evidence file on a branch based on that source commit. Merge it through protected `main`, confirm that the merged commit differs from the tested source only by the evidence file, and wait for CI on @@ -128,7 +171,7 @@ the first release uses a short-lived granular access token: Subsequent releases use GitHub OIDC trusted publishing and require no npm repository secret. -## Signing Secrets +## Signing Configuration Store the Apple credentials only as environment secrets in the protected `release-signing-macos` GitHub environment: @@ -141,17 +184,129 @@ Store the Apple credentials only as environment secrets in the protected - `APPLE_API_KEY_ID` - `APPLE_API_ISSUER_ID` -Store the Windows credentials only as environment secrets in the protected +`APPLE_API_PRIVATE_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER_ID` must +belong to an App Store Connect **Team API key**. An individual API key cannot +be used with `notarytool`. The Account Holder must first request App Store +Connect API access, and an Account Holder or Admin must create the team key. +Keep the downloaded P8 private key as carefully as the Developer ID P12. See +Apple's [API key documentation][apple-api-keys] and +[team-key setup guide][apple-team-keys]. + +Windows signing uses [Azure Artifact Signing][azure-signing] with GitHub OIDC. +The private signing key remains in Microsoft's HSM-backed service; do not add a +PFX, Azure client secret, or other long-lived Windows signing secret to +GitHub. The cloud signing job receives only the release executable. Runtime +model selection, prompts, and OpenCode sessions remain local. + +Before creating the Azure resources, confirm the current eligibility rules. As +of July 2026, Public Trust accepts organizations in the EU, including a +qualifying Danish legal organization, but individual developers are accepted +only in the United States and Canada. It also requires a paid Azure +subscription whose billing identity matches the certificate subject. Do not +push a release tag until identity validation is complete and the certificate +profile is active. See Microsoft's +[eligibility quickstart][azure-quickstart] and +[current pricing documentation][azure-pricing]. If the publisher is not +eligible, select another HSM-backed public-trust provider in a reviewed +workflow change; never fall back to unsigned Windows binaries. + +Create an Entra application/service principal and give it only the +`Artifact Signing Certificate Profile Signer` role at the certificate-profile +scope. Add a federated credential with: + +- issuer `https://token.actions.githubusercontent.com`; +- audience `api://AzureADTokenExchange`; +- the exact environment-bound repository subject ending in + `:environment:release-signing-windows`. + +Enable GitHub's [immutable OIDC subject][github-immutable-oidc] before creating +that federated credential. From an authenticated repository-administrator +shell, use GitHub's [OIDC REST endpoint][github-oidc-rest] to keep the default +context format while opting in to immutable owner and repository IDs: + +```sh +gh api --method PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + repos/Lauritz-Timm/opencode-model-dispatch/actions/oidc/customization/sub \ + -F use_default=true \ + -F use_immutable_subject=true +``` + +Read the setting back without changing it: + +```sh +gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + repos/Lauritz-Timm/opencode-model-dispatch/actions/oidc/customization/sub \ + --jq '{use_default, use_immutable_subject}' +``` + +Both values must be `true`; `bun run check:public-repo` enforces this readback. +The setting does not prove the environment suffix that a job's token will +contain. Derive the expected subject from the current immutable IDs instead: + +```sh +expected_subject="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + repos/Lauritz-Timm/opencode-model-dispatch \ + --jq '"repo:\(.owner.login)@\(.owner.id)/\(.name)@\(.id):environment:release-signing-windows"' +)" +test "$expected_subject" = \ + "repo:Lauritz-Timm@269186225/opencode-model-dispatch@1290427988:environment:release-signing-windows" +printf '%s\n' "$expected_subject" +``` + +For this repository, the expected immutable subject is: + +```text +repo:Lauritz-Timm@269186225/opencode-model-dispatch@1290427988:environment:release-signing-windows +``` + +The final proof must come from a token minted for a job that names the protected +`release-signing-windows` environment. Before adding the Entra federated +credential, manually run the repository's `Verify release-signing OIDC` +workflow on `main`, approve the environment, and require it to pass. The +workflow has no checkout, repository write permission, or secret dependency; +it decodes a one-time token and compares its exact `sub`, `aud`, and `iss` +claims. + +Copy the verified subject into the Entra federated credential. Store these +identifiers as environment variables in the protected `release-signing-windows` GitHub environment: -- `WINDOWS_CERTIFICATE` (base64 PFX) -- `WINDOWS_CERTIFICATE_PASSWORD` +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_ARTIFACT_SIGNING_ENDPOINT` +- `AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME` +- `AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME` +- `EXPECTED_WINDOWS_SIGNER_SUBJECT` + +`AZURE_ARTIFACT_SIGNING_ENDPOINT` must be the root HTTPS endpoint ending in +`.codesigning.azure.net`. Set `EXPECTED_WINDOWS_SIGNER_SUBJECT` to the exact +X.500 subject emitted by the active certificate profile. The workflow rejects +an already-signed input, signs both x64 and ARM64 files from a supported x64 +Windows runner, and then verifies PE architecture, public trust, the exact +subject, and the RFC 3161 timestamp. Azure certificate thumbprints rotate, so +do not pin a thumbprint. Require maintainer approval on both signing environments. Do not also define -these credentials as repository-level secrets. +the Apple credentials or Windows configuration at repository level. `NPM_BOOTSTRAP_TOKEN` exists for the first publish only. +[apple-api-keys]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api +[apple-team-keys]: https://developer.apple.com/help/app-store-connect/get-started/app-store-connect-api +[azure-signing]: https://learn.microsoft.com/en-us/azure/artifact-signing/overview +[azure-quickstart]: https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart +[azure-pricing]: https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-change-sku +[github-immutable-oidc]: https://docs.github.com/en/actions/reference/security/oidc#immutable-subject-claims +[github-oidc-rest]: https://docs.github.com/en/rest/actions/oidc + ## Recovery - A failed local or manual gate requires a fix and a fresh run from the new diff --git a/package.json b/package.json index d13c091..9bbedf3 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "check:release-version": "bun run scripts/check-release-version.ts", "notices:generate": "bun run scripts/generate-third-party-notices.ts", "check:notices": "bun run scripts/generate-third-party-notices.ts --check", - "check:package-types": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck false dist/index.d.ts", + "check:package-types": "tsc --noEmit --ignoreConfig --types bun-types --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck false dist/index.d.ts", "check:public-repo": "bun run scripts/check-public-repository.ts", "check:manual-gate": "bun run scripts/check-manual-gate.ts", "check:release-package": "bun run check:release-version && bun run scripts/check-packaging.ts --require-all-pickers", @@ -74,6 +74,10 @@ "test:picker-ready": "bun run scripts/smoke-picker-ready.ts", "test:gui": "bun run build:picker && bun run scripts/smoke-native-picker.ts", "test:gui:auto": "bun run build:picker && bun run scripts/smoke-native-picker-auto.ts", + "release:manual-candidate": "bun run scripts/manual-candidate.ts prepare", + "release:manual-candidate:install": "bun run scripts/manual-candidate.ts install", + "release:manual-candidate:test": "bun run scripts/manual-candidate.ts test", + "release:manual-candidate:test:tui": "bun run scripts/manual-candidate.ts test --tui", "release:candidate-preflight": "bun run check:release-source && bun run check:release-version && bun run check:notices && bun run typecheck && bun run check:coverage && cargo test --manifest-path picker/src-tauri/Cargo.toml --locked && bun run check:packaging && bun run test:package", "release:preflight": "bun run release:candidate-preflight && bun run check:manual-gate", "release:artifact-preflight": "bun run check:notices && bun run check:release-package && bun run test:package", @@ -87,8 +91,8 @@ "@opencode-ai/plugin": ">=1.18.7 <2" }, "devDependencies": { - "@opencode-ai/plugin": "1.18.10", - "@opencode-ai/sdk": "1.18.9", + "@opencode-ai/plugin": "1.18.7", + "@opencode-ai/sdk": "1.18.7", "typescript": "7.0.2", "bun-types": "1.3.14" } diff --git a/scripts/check-packaging.ts b/scripts/check-packaging.ts index 17cda91..5798bcf 100644 --- a/scripts/check-packaging.ts +++ b/scripts/check-packaging.ts @@ -17,6 +17,7 @@ type PackageJson = { engines?: Record dependencies?: Record optionalDependencies?: Record + devDependencies?: Record peerDependencies?: Record peerDependenciesMeta?: Record homepage?: string @@ -406,6 +407,10 @@ async function main(): Promise { const failures: string[] = [] const pkg = await readJson("package.json") const readme = await readText("README.md") + const minimumOpenCodeContractVersion = + /^>=([0-9]+\.[0-9]+\.[0-9]+) <[0-9]+$/.exec( + pkg.engines?.opencode ?? "", + )?.[1] const requireAllPickers = process.argv.includes("--require-all-pickers") || process.env.REQUIRE_ALL_PICKERS === "1" @@ -435,6 +440,29 @@ async function main(): Promise { "@opencode-ai/plugin must not be an optional peer because public declarations import it", ) } + if (!minimumOpenCodeContractVersion) { + failures.push( + "package engines.opencode must expose an exact stable lower-bound version", + ) + } else { + for (const dependency of ["@opencode-ai/plugin", "@opencode-ai/sdk"]) { + if (pkg.devDependencies?.[dependency] !== minimumOpenCodeContractVersion) { + failures.push( + `${dependency} development dependency must remain pinned to the minimum OpenCode contract ${minimumOpenCodeContractVersion}`, + ) + } + } + } + if (!pkg.scripts?.["check:package-types"]?.includes("--ignoreConfig")) { + failures.push( + "check:package-types must ignore the repository tsconfig when checking the emitted declaration entrypoint", + ) + } + if (!pkg.scripts?.["check:package-types"]?.includes("--types bun-types")) { + failures.push( + "check:package-types must explicitly load the pinned Bun and Node declaration environment", + ) + } if (pkg.publishConfig?.access !== "public" || pkg.publishConfig.provenance !== true) { failures.push("publishConfig must require public access and provenance") } diff --git a/scripts/check-public-repository.ts b/scripts/check-public-repository.ts index 5dd9d0c..df59e90 100644 --- a/scripts/check-public-repository.ts +++ b/scripts/check-public-repository.ts @@ -53,6 +53,11 @@ export interface DependabotSecurityUpdates { paused?: boolean } +export interface OidcSubjectCustomization { + use_default?: boolean + use_immutable_subject?: boolean +} + export interface RepositoryRule { type?: string parameters?: { @@ -115,6 +120,7 @@ export interface PublicRepositorySnapshot { vulnerabilityReporting: Verification immutableReleases: Verification dependabotSecurityUpdates: Verification + oidcSubjectCustomization: Verification mainRules: Verification classicMainProtection: Verification releaseTagRulesets: Verification @@ -193,6 +199,10 @@ export function publicRepositoryReadiness( failures, ) requireDependabotSecurityUpdates(snapshot.dependabotSecurityUpdates, failures) + requireImmutableOidcSubject( + snapshot.oidcSubjectCustomization, + failures, + ) requireMainProtection( snapshot.mainRules, snapshot.classicMainProtection, @@ -247,6 +257,7 @@ export async function collectPublicRepositorySnapshot( vulnerabilityReporting, immutableReleases, dependabotSecurityUpdates, + oidcSubjectCustomization, mainRules, classicMainProtection, releaseTagRulesets, @@ -270,6 +281,12 @@ export async function collectPublicRepositorySnapshot( "Dependabot security updates", isDependabotSecurityUpdates, ), + getVerifiedJson( + settingsContext, + `/repos/${slug}/actions/oidc/customization/sub`, + "immutable OIDC subject customization", + isOidcSubjectCustomization, + ), getVerifiedArray( settingsContext, `/repos/${slug}/rules/branches/main?per_page=100`, @@ -293,6 +310,7 @@ export async function collectPublicRepositorySnapshot( vulnerabilityReporting, immutableReleases, dependabotSecurityUpdates, + oidcSubjectCustomization, mainRules, classicMainProtection, releaseTagRulesets, @@ -328,6 +346,25 @@ function requireDependabotSecurityUpdates( } } +function requireImmutableOidcSubject( + verification: Verification, + failures: string[], +): void { + const label = "GitHub immutable OIDC subject customization" + if (verification.status === "unavailable") { + failures.push(`${label} could not be verified: ${verification.reason}`) + return + } + if ( + verification.value.use_default !== true + || verification.value.use_immutable_subject !== true + ) { + failures.push( + `${label} must keep the default context and enable immutable owner/repository IDs`, + ) + } +} + function requireMainProtection( rulesVerification: Verification, classicVerification: Verification, @@ -804,6 +841,14 @@ function isDependabotSecurityUpdates( && typeof value.paused === "boolean" } +function isOidcSubjectCustomization( + value: unknown, +): value is OidcSubjectCustomization { + return isObject(value) + && typeof value.use_default === "boolean" + && typeof value.use_immutable_subject === "boolean" +} + function isRepositoryRule(value: unknown): value is RepositoryRule { return isObject(value) && typeof value.type === "string" } diff --git a/scripts/check-release-source.ts b/scripts/check-release-source.ts index 495ccdd..e5fba5c 100644 --- a/scripts/check-release-source.ts +++ b/scripts/check-release-source.ts @@ -8,6 +8,7 @@ const root = fileURLToPath(new URL("../", import.meta.url)) export const REQUIRED_TRACKED_RELEASE_PATHS = [ ".gitattributes", + ".gitignore", ".npmignore", ".github/ISSUE_TEMPLATE/bug_report.md", ".github/ISSUE_TEMPLATE/config.yml", @@ -17,6 +18,7 @@ export const REQUIRED_TRACKED_RELEASE_PATHS = [ ".github/workflows/ci.yml", ".github/workflows/compatibility.yml", ".github/workflows/publish.yml", + ".github/workflows/verify-release-oidc.yml", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", @@ -43,6 +45,8 @@ export const REQUIRED_TRACKED_RELEASE_PATHS = [ "scripts/test-apple-notary-log-validator.sh", "scripts/validate-apple-notary-log.swift", "scripts/generate-third-party-notices.ts", + "scripts/manual-candidate.ts", + "scripts/manual-candidate-support.ts", "scripts/resolve-opencode-compatibility.ts", "third-party/RUST_THIRD_PARTY_LICENSES.md", "third-party/about.hbs", diff --git a/scripts/installed-native-opencode-support.ts b/scripts/installed-native-opencode-support.ts index a51ad92..1921b6d 100644 --- a/scripts/installed-native-opencode-support.ts +++ b/scripts/installed-native-opencode-support.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises" +import { readFile, readdir } from "node:fs/promises" import { join } from "node:path" import { releasePickerAssets } from "./check-packaging" @@ -45,6 +45,52 @@ export async function assertExactPickerAssets( } } +export async function assertCandidatePickerAssets(options: { + releaseBin: string + localPicker: string + installedBin: string + localAssetName: string +}): Promise<"complete-release" | "local-candidate"> { + const installedAssets = (await readdir(options.installedBin)) + .filter((name) => name.startsWith("picker-")) + .sort() + const completeAssets = releasePickerAssets.map((asset) => asset.name).sort() + if ( + installedAssets.length === completeAssets.length && + installedAssets.every((name, index) => name === completeAssets[index]) + ) { + await assertExactPickerAssets(options.releaseBin, options.installedBin) + return "complete-release" + } + + if ( + installedAssets.length !== 1 || + installedAssets[0] !== options.localAssetName + ) { + throw new Error( + "Installed candidate must contain either every release picker or exactly " + + `the local ${options.localAssetName} picker; received ${installedAssets.join(", ") || "none"}`, + ) + } + + const [sourceBytes, installedBytes] = await Promise.all([ + readRequiredAsset( + options.localPicker, + `local candidate ${options.localAssetName}`, + ), + readRequiredAsset( + join(options.installedBin, options.localAssetName), + `installed package bin/${options.localAssetName}`, + ), + ]) + if (!sourceBytes.equals(installedBytes)) { + throw new Error( + `Installed package bin/${options.localAssetName} differs from the local candidate picker`, + ) + } + return "local-candidate" +} + type ProcessKill = ( processID: number, signal: NodeJS.Signals | 0, diff --git a/scripts/manual-candidate-support.ts b/scripts/manual-candidate-support.ts new file mode 100644 index 0000000..da18dd4 --- /dev/null +++ b/scripts/manual-candidate-support.ts @@ -0,0 +1,463 @@ +import { createHash, randomBytes } from "node:crypto" +import { + chmod, + copyFile, + cp, + lstat, + mkdir, + readFile, + rename, + rm, +} from "node:fs/promises" +import { + basename, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path" + +import type { PickerTarget } from "../src/picker-targets" + +export const defaultManualCandidateOutput = ".manual-release" +export const manualCandidateMetadataFilename = "manual-candidate.json" + +const fullCommitPattern = /^[0-9a-f]{40}$/i +const npmIntegrityPattern = /^sha512-[A-Za-z0-9+/]{86}==$/ +const sha256Pattern = /^[0-9a-f]{64}$/i +const manualCandidateNoncePattern = /^[0-9a-f]{16}$/ + +export interface ManualCandidateMetadata { + schemaVersion: 1 + commitSha: string + packageName: string + packageVersion: string + tarball: string + tarballIntegrity: string + pickerPath: string + pickerSha256: string +} + +export type ManualCandidateCommand = + | { action: "prepare"; output: string } + | { action: "install"; output: string; project: string; openCode: string } + | { action: "test"; output: string; tui: boolean } + | { action: "help" } + +export interface VerifiedManualCandidate { + metadata: ManualCandidateMetadata + tarballPath: string + pickerPath: string +} + +export function parseManualCandidateArguments( + arguments_: readonly string[], +): ManualCandidateCommand { + const args = arguments_.filter((argument) => argument !== "--") + if (args.includes("--help") || args.includes("-h")) return { action: "help" } + + const action = args.shift() + if (action !== "prepare" && action !== "install" && action !== "test") { + throw new Error("Expected prepare, install, or test") + } + + let output = defaultManualCandidateOutput + let project: string | undefined + let openCode = process.env.OPENCODE_BIN?.trim() || "opencode" + let tui = false + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === "--output") { + output = requiredOptionValue(args, ++index, "--output") + continue + } + if (argument === "--project") { + project = requiredOptionValue(args, ++index, "--project") + continue + } + if (argument === "--opencode") { + openCode = requiredOptionValue(args, ++index, "--opencode") + continue + } + if (argument === "--tui") { + tui = true + continue + } + throw new Error(`Unknown manual candidate argument: ${argument}`) + } + + if (action === "prepare") return { action, output } + if (action === "install") { + if (!project) throw new Error("install requires --project ") + return { action, output, project, openCode } + } + return { action, output, tui } +} + +export function resolveManualCandidateOutput( + repositoryRoot: string, + output: string, +): { absolutePath: string; repositoryPath: string } { + if (!output || output.trim() !== output || isAbsolute(output)) { + throw new Error( + "Manual candidate output must be a nonempty repository-relative directory", + ) + } + const absolutePath = resolve(repositoryRoot, output) + const repositoryPath = relative(repositoryRoot, absolutePath) + if ( + !repositoryPath || + repositoryPath === ".." || + repositoryPath.startsWith(`..${sep}`) || + isAbsolute(repositoryPath) + ) { + throw new Error("Manual candidate output must stay inside the repository") + } + return { + absolutePath, + repositoryPath: repositoryPath.split(sep).join("/"), + } +} + +export function manualCandidateGitIgnorePath( + repositoryPath: string, +): string { + return `${repositoryPath.replace(/\/+$/, "")}/` +} + +export function manualCandidateInstallSpec( + metadata: Pick< + ManualCandidateMetadata, + "commitSha" | "packageName" | "packageVersion" + >, + nonce = randomBytes(8).toString("hex"), +): string { + assertManualCandidateCommit(metadata.commitSha) + assertManualCandidateNonce(nonce) + return [ + "opencode-model-dispatch-manual", + metadata.commitSha.slice(0, 12).toLowerCase(), + `${nonce}@npm:${metadata.packageName}@${metadata.packageVersion}`, + ].join("-") +} + +export function manualCandidateTarballFilename( + packedFilename: string, + commitSha: string, + nonce = randomBytes(8).toString("hex"), +): string { + assertSafeTarballFilename( + packedFilename, + "Packed manual candidate tarball", + ) + assertManualCandidateCommit(commitSha) + assertManualCandidateNonce(nonce) + return [ + packedFilename.slice(0, -4), + commitSha.slice(0, 12).toLowerCase(), + `${nonce}.tgz`, + ].join("-") +} + +export async function invalidateManualCandidate( + outputRoot: string, +): Promise { + const metadataPath = join(outputRoot, manualCandidateMetadataFilename) + let metadata: Awaited> + try { + metadata = await lstat(metadataPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw error + } + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error( + "Existing manual candidate metadata must be a real regular file", + ) + } + await rm(metadataPath) +} + +export async function promoteManualCandidate(options: { + outputRoot: string + stagedTarball: string + stagedMetadata: string + tarballFilename: string +}): Promise<{ tarballPath: string; metadataPath: string }> { + assertSafeTarballFilename( + options.tarballFilename, + "Retained manual candidate tarball", + ) + const tarballPath = join(options.outputRoot, options.tarballFilename) + const metadataPath = join( + options.outputRoot, + manualCandidateMetadataFilename, + ) + await Promise.all([ + assertRegularFile(options.stagedTarball, "staged manual candidate tarball"), + assertRegularFile(options.stagedMetadata, "staged manual candidate metadata"), + assertMissingPath(tarballPath, "retained manual candidate tarball"), + assertMissingPath(metadataPath, "retained manual candidate metadata"), + ]) + + await rename(options.stagedTarball, tarballPath) + try { + await rename(options.stagedMetadata, metadataPath) + } catch (error) { + await rm(tarballPath, { force: true }) + throw error + } + return { tarballPath, metadataPath } +} + +export async function discardManualCandidatePromotion( + outputRoot: string, + tarballFilename: string, +): Promise { + assertSafeTarballFilename( + tarballFilename, + "Retained manual candidate tarball", + ) + await Promise.all([ + removeCandidateLeaf( + join(outputRoot, manualCandidateMetadataFilename), + "manual candidate metadata", + ), + removeCandidateLeaf( + join(outputRoot, tarballFilename), + "manual candidate tarball", + ), + ]) +} + +export function readManualCandidateMetadata( + value: unknown, +): ManualCandidateMetadata { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new Error("Manual candidate metadata must use schemaVersion 1") + } + + const metadata: ManualCandidateMetadata = { + schemaVersion: 1, + commitSha: readRequiredString(value, "commitSha"), + packageName: readRequiredString(value, "packageName"), + packageVersion: readRequiredString(value, "packageVersion"), + tarball: readRequiredString(value, "tarball"), + tarballIntegrity: readRequiredString(value, "tarballIntegrity"), + pickerPath: readRequiredString(value, "pickerPath"), + pickerSha256: readRequiredString(value, "pickerSha256"), + } + if (!fullCommitPattern.test(metadata.commitSha)) { + throw new Error("Manual candidate commitSha must be a full commit SHA") + } + if ( + basename(metadata.tarball) !== metadata.tarball || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(metadata.tarball) + ) { + throw new Error("Manual candidate tarball must be a safe .tgz basename") + } + if (!npmIntegrityPattern.test(metadata.tarballIntegrity)) { + throw new Error("Manual candidate tarballIntegrity must be canonical SHA-512 SRI") + } + if ( + !/^dist-picker\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test( + metadata.pickerPath, + ) + ) { + throw new Error( + "Manual candidate pickerPath must be a dist-picker repository path", + ) + } + if (!sha256Pattern.test(metadata.pickerSha256)) { + throw new Error("Manual candidate pickerSha256 must be 64 hexadecimal characters") + } + return metadata +} + +export async function stageManualCandidatePackage(options: { + repositoryRoot: string + stageRoot: string + picker: PickerTarget +}): Promise { + const { repositoryRoot, stageRoot, picker } = options + await mkdir(join(stageRoot, "bin"), { recursive: true }) + for (const path of [ + "package.json", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + ]) { + await copyFile(join(repositoryRoot, path), join(stageRoot, path)) + } + await cp(join(repositoryRoot, "dist"), join(stageRoot, "dist"), { + recursive: true, + force: false, + errorOnExist: true, + }) + await copyFile( + join(repositoryRoot, "bin", "picker.js"), + join(stageRoot, "bin", "picker.js"), + ) + await copyFile( + join(repositoryRoot, "dist-picker", picker.assetName), + join(stageRoot, "bin", picker.assetName), + ) + await chmod(join(stageRoot, "bin", "picker.js"), 0o755) + if (picker.executable) { + await chmod(join(stageRoot, "bin", picker.assetName), 0o755) + } +} + +export async function verifyManualCandidate(options: { + repositoryRoot: string + outputRoot: string + expectedCommit: string + picker: PickerTarget + packageName: string + packageVersion: string +}): Promise { + const metadataPath = join( + options.outputRoot, + manualCandidateMetadataFilename, + ) + await assertRegularFile(metadataPath, "manual candidate metadata") + const metadata = readManualCandidateMetadata( + JSON.parse(await readFile(metadataPath, "utf8")) as unknown, + ) + if (metadata.commitSha.toLowerCase() !== options.expectedCommit.toLowerCase()) { + throw new Error( + `Manual candidate was built from ${metadata.commitSha}, not current HEAD ${options.expectedCommit}`, + ) + } + if ( + metadata.packageName !== options.packageName || + metadata.packageVersion !== options.packageVersion + ) { + throw new Error("Manual candidate package identity no longer matches package.json") + } + + const expectedPickerPath = `dist-picker/${options.picker.assetName}` + if (metadata.pickerPath !== expectedPickerPath) { + throw new Error( + `Manual candidate pickerPath must be ${expectedPickerPath} on this host`, + ) + } + + const tarballPath = join(options.outputRoot, metadata.tarball) + const pickerPath = join(options.repositoryRoot, ...metadata.pickerPath.split("/")) + await assertRegularFile(tarballPath, "manual candidate tarball") + await assertRegularFile(pickerPath, "manual candidate picker") + + const [tarballIntegrity, pickerSha256] = await Promise.all([ + sha512Integrity(tarballPath), + sha256(pickerPath), + ]) + if (tarballIntegrity !== metadata.tarballIntegrity) { + throw new Error("Retained manual candidate tarball no longer matches its SHA-512") + } + if (pickerSha256 !== metadata.pickerSha256) { + throw new Error("Manual candidate picker no longer matches its SHA-256") + } + return { metadata, tarballPath, pickerPath } +} + +export async function assertRegularFile( + path: string, + label: string, +): Promise { + const metadata = await lstat(path) + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error(`${label} must be a real regular file`) + } +} + +export async function sha512Integrity(path: string): Promise { + const bytes = await readFile(path) + return `sha512-${createHash("sha512").update(bytes).digest("base64")}` +} + +export async function sha256(path: string): Promise { + return createHash("sha256").update(await readFile(path)).digest("hex") +} + +async function assertMissingPath(path: string, label: string): Promise { + try { + await lstat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw error + } + throw new Error(`${label} already exists and will not be overwritten`) +} + +async function removeCandidateLeaf( + path: string, + label: string, +): Promise { + let metadata: Awaited> + try { + metadata = await lstat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw error + } + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + throw new Error(`${label} cleanup path must not be a directory`) + } + await rm(path) +} + +function assertManualCandidateCommit(commitSha: string): void { + if (!fullCommitPattern.test(commitSha)) { + throw new Error("Manual candidate commitSha must be a full commit SHA") + } +} + +function assertManualCandidateNonce(nonce: string): void { + if (!manualCandidateNoncePattern.test(nonce)) { + throw new Error( + "Manual candidate nonce must be exactly 16 lowercase hexadecimal characters", + ) + } +} + +function assertSafeTarballFilename(filename: string, label: string): void { + if ( + basename(filename) !== filename + || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(filename) + ) { + throw new Error(`${label} must be a safe .tgz basename`) + } +} + +function requiredOptionValue( + args: readonly string[], + index: number, + option: string, +): string { + const value = args[index] + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a value`) + } + return value +} + +function readRequiredString( + value: Record, + key: string, +): string { + const candidate = value[key] + if ( + typeof candidate !== "string" || + candidate.length === 0 || + candidate.trim() !== candidate + ) { + throw new Error(`Manual candidate metadata ${key} must be a nonempty string`) + } + return candidate +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/scripts/manual-candidate.ts b/scripts/manual-candidate.ts new file mode 100644 index 0000000..ba796cf --- /dev/null +++ b/scripts/manual-candidate.ts @@ -0,0 +1,576 @@ +import { + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises" +import { tmpdir } from "node:os" +import { + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path" +import { fileURLToPath } from "node:url" + +import { + pickerTargetForNode, + type PickerTarget, +} from "../src/picker-targets" +import { + assertRegularFile, + defaultManualCandidateOutput, + discardManualCandidatePromotion, + invalidateManualCandidate, + manualCandidateGitIgnorePath, + manualCandidateInstallSpec, + manualCandidateMetadataFilename, + manualCandidateTarballFilename, + parseManualCandidateArguments, + promoteManualCandidate, + resolveManualCandidateOutput, + sha256, + sha512Integrity, + stageManualCandidatePackage, + verifyManualCandidate, + type ManualCandidateMetadata, + type VerifiedManualCandidate, +} from "./manual-candidate-support" + +export { + parseManualCandidateArguments, + readManualCandidateMetadata, + resolveManualCandidateOutput, + stageManualCandidatePackage, + verifyManualCandidate, + type ManualCandidateCommand, + type ManualCandidateMetadata, + type VerifiedManualCandidate, +} from "./manual-candidate-support" + +const root = fileURLToPath(new URL("../", import.meta.url)) + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error( + `manual candidate failed: ${error instanceof Error ? error.message : String(error)}`, + ) + process.exit(1) + } +} + +async function main(): Promise { + const command = parseManualCandidateArguments(process.argv.slice(2)) + if (command.action === "help") { + printHelp() + return + } + + if (command.action === "prepare") { + await prepareManualCandidate(command.output) + return + } + if (command.action === "install") { + await installManualCandidate( + command.output, + command.project, + command.openCode, + ) + return + } + await testManualCandidate(command.output, command.tui) +} + +async function prepareManualCandidate(output: string): Promise { + await runInherited([process.execPath, "run", "check:release-source"], root) + const commitSha = await gitOutput(["rev-parse", "--verify", "HEAD^{commit}"]) + const target = requiredHostPicker() + const outputPaths = resolveManualCandidateOutput(root, output) + await assertIgnoredOutput(outputPaths.repositoryPath) + await ensureContainedDirectory(root, outputPaths.absolutePath) + await invalidateManualCandidate(outputPaths.absolutePath) + + await runInherited([process.execPath, "run", "build"], root) + await runInherited([process.execPath, "run", "build:picker"], root) + await runInherited( + [process.execPath, "run", "check:picker-host"], + root, + { + ...process.env, + MODEL_DISPATCH_EXPECTED_PICKER_PLATFORM: target.platform, + MODEL_DISPATCH_EXPECTED_PICKER_ARCH: target.arch, + }, + ) + + const packageManifest = await readSourcePackageManifest() + const pickerPath = `dist-picker/${target.assetName}` + await assertRegularFile( + join(root, ...pickerPath.split("/")), + "freshly built picker", + ) + + const work = await mkdtemp( + join(outputPaths.absolutePath, ".manual-candidate-work-"), + ) + try { + const stageRoot = join(work, "package") + const packRoot = join(work, "pack") + const candidateRoot = join(work, "candidate") + await Promise.all([ + mkdir(stageRoot, { recursive: true }), + mkdir(packRoot, { recursive: true }), + mkdir(candidateRoot, { recursive: true }), + ]) + await stageManualCandidatePackage({ + repositoryRoot: root, + stageRoot, + picker: target, + }) + + await runInherited( + [ + "npm", + "pack", + "--silent", + "--ignore-scripts", + "--pack-destination", + packRoot, + stageRoot, + ], + root, + { + ...process.env, + npm_config_cache: join(work, "npm-cache"), + }, + ) + const packedEntries = await readdir(packRoot) + if ( + packedEntries.length !== 1 || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(packedEntries[0]!) + ) { + throw new Error( + `npm pack must create exactly one safe .tgz file; received ${packedEntries.join(", ") || "none"}`, + ) + } + const packedFilename = packedEntries[0]! + const packedTarball = join(packRoot, packedFilename) + await assertPackedLocalPicker(packedTarball, target) + const sourceIntegrity = await sha512Integrity(packedTarball) + const tarballFilename = manualCandidateTarballFilename( + packedFilename, + commitSha, + ) + const stagedTarball = join(candidateRoot, tarballFilename) + await copyFile(packedTarball, stagedTarball) + const retainedIntegrity = await sha512Integrity(stagedTarball) + if (sourceIntegrity !== retainedIntegrity) { + throw new Error("Staged manual candidate tarball differs from npm pack output") + } + + const metadata: ManualCandidateMetadata = { + schemaVersion: 1, + commitSha, + packageName: packageManifest.name, + packageVersion: packageManifest.version, + tarball: tarballFilename, + tarballIntegrity: retainedIntegrity, + pickerPath, + pickerSha256: await sha256(join(root, ...pickerPath.split("/"))), + } + const stagedMetadata = join( + candidateRoot, + manualCandidateMetadataFilename, + ) + await writeFile( + stagedMetadata, + `${JSON.stringify(metadata, null, 2)}\n`, + "utf8", + ) + await verifyManualCandidate({ + repositoryRoot: root, + outputRoot: candidateRoot, + expectedCommit: commitSha, + picker: target, + packageName: packageManifest.name, + packageVersion: packageManifest.version, + }) + await runInherited([process.execPath, "run", "check:release-source"], root) + const finalCommitSha = await gitOutput([ + "rev-parse", + "--verify", + "HEAD^{commit}", + ]) + if (finalCommitSha.toLowerCase() !== commitSha.toLowerCase()) { + throw new Error( + `Release source changed from ${commitSha} to ${finalCommitSha} while preparing the manual candidate`, + ) + } + let promoted = false + try { + await promoteManualCandidate({ + outputRoot: outputPaths.absolutePath, + stagedTarball, + stagedMetadata, + tarballFilename, + }) + promoted = true + await verifyManualCandidate({ + repositoryRoot: root, + outputRoot: outputPaths.absolutePath, + expectedCommit: commitSha, + picker: target, + packageName: packageManifest.name, + packageVersion: packageManifest.version, + }) + } catch (error) { + if (promoted) { + await discardManualCandidatePromotion( + outputPaths.absolutePath, + tarballFilename, + ) + } + throw error + } + printEvidence(metadata, outputPaths.repositoryPath) + } finally { + await rm(work, { recursive: true, force: true }) + } +} + +async function installManualCandidate( + output: string, + projectInput: string, + openCode: string, +): Promise { + await runInherited([process.execPath, "run", "check:release-source"], root) + const outputPaths = resolveManualCandidateOutput(root, output) + await assertIgnoredOutput(outputPaths.repositoryPath) + const candidate = await verifyCurrentManualCandidate(outputPaths.absolutePath) + const project = await requireEmptyScratchProject(projectInput) + const work = await mkdtemp(join(tmpdir(), "model-dispatch-manual-install-")) + const { startLocalNpmRegistry } = await import("./local-npm-registry") + const registry = await startLocalNpmRegistry({ + root, + work, + initialTarballs: [{ + packageRoot: root, + tarballPath: candidate.tarballPath, + }], + }) + + try { + const environment = { + ...process.env, + BUN_CONFIG_REGISTRY: registry.baseURL, + BUN_INSTALL_CACHE_DIR: join(work, "bun-cache"), + NPM_CONFIG_REGISTRY: registry.baseURL, + npm_config_registry: registry.baseURL, + npm_config_cache: join(work, "npm-cache"), + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + OPENCODE_DISABLE_AUTOUPDATE: "true", + } + const packageSpec = manualCandidateInstallSpec(candidate.metadata) + await runInherited( + [openCode, "plugin", packageSpec, "--pure"], + project, + environment, + ) + registry.assertInstalled(candidate.metadata.packageName) + } finally { + registry.server.stop(true) + await rm(work, { recursive: true, force: true }) + } + + console.log( + "Exact manual candidate installed in the empty scratch project through a loopback-only registry.", + ) + console.log( + "Open that same project in OpenCode TUI and OpenCode Desktop; no registry override is needed after installation.", + ) + printEvidence(candidate.metadata, outputPaths.repositoryPath) +} + +async function testManualCandidate( + output: string, + tui: boolean, +): Promise { + await runInherited([process.execPath, "run", "check:release-source"], root) + const outputPaths = resolveManualCandidateOutput(root, output) + await assertIgnoredOutput(outputPaths.repositoryPath) + const candidate = await verifyCurrentManualCandidate(outputPaths.absolutePath) + await runInherited( + [ + process.execPath, + "run", + "scripts/run-installed-native-opencode.ts", + ...(tui ? ["--tui"] : []), + ], + root, + { + ...process.env, + MODEL_DISPATCH_TEST_PACKAGE_TARBALL: candidate.tarballPath, + }, + ) +} + +async function verifyCurrentManualCandidate( + outputRoot: string, +): Promise { + const target = requiredHostPicker() + const [commitSha, packageManifest] = await Promise.all([ + gitOutput(["rev-parse", "--verify", "HEAD^{commit}"]), + readSourcePackageManifest(), + ]) + return verifyManualCandidate({ + repositoryRoot: root, + outputRoot, + expectedCommit: commitSha, + picker: target, + packageName: packageManifest.name, + packageVersion: packageManifest.version, + }) +} + +async function requireEmptyScratchProject(projectInput: string): Promise { + if (!projectInput || projectInput.trim() !== projectInput) { + throw new Error("Scratch project path must be nonempty without surrounding whitespace") + } + const project = resolve(projectInput) + const repositoryRelative = relative(root, project) + if ( + !repositoryRelative || + ( + repositoryRelative !== ".." && + !repositoryRelative.startsWith(`..${sep}`) && + !isAbsolute(repositoryRelative) + ) + ) { + throw new Error("Scratch project must be outside the repository") + } + const metadata = await lstat(project) + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error("Scratch project must be a real directory, not a symlink") + } + if ((await readdir(project)).length > 0) { + throw new Error("Scratch project must be empty before installing the candidate") + } + return project +} + +async function ensureContainedDirectory( + repositoryRoot: string, + directory: string, +): Promise { + const repositoryRealPath = await realpath(repositoryRoot) + const pathParts = relative(repositoryRoot, directory).split(sep) + let current = repositoryRoot + for (const part of pathParts) { + current = join(current, part) + try { + const metadata = await lstat(current) + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error( + `Manual candidate output component must be a real directory: ${part}`, + ) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + await mkdir(current) + } + } + const outputRealPath = await realpath(directory) + const containedPath = relative(repositoryRealPath, outputRealPath) + if ( + !containedPath || + containedPath === ".." || + containedPath.startsWith(`..${sep}`) || + isAbsolute(containedPath) + ) { + throw new Error("Manual candidate output resolved outside the repository") + } +} + +async function assertIgnoredOutput(repositoryPath: string): Promise { + const result = Bun.spawn( + [ + "git", + "check-ignore", + "--quiet", + "--no-index", + "--", + manualCandidateGitIgnorePath(repositoryPath), + ], + { + cwd: root, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }, + ) + const [code, stderr] = await Promise.all([ + result.exited, + new Response(result.stderr).text(), + ]) + if (code !== 0) { + throw new Error( + `Manual candidate output must be ignored by git: ${repositoryPath}` + + (stderr.trim() ? ` (${stderr.trim()})` : ""), + ) + } +} + +async function assertPackedLocalPicker( + tarballPath: string, + target: PickerTarget, +): Promise { + const archive = await new Bun.Archive(await readFile(tarballPath)).files() + const paths = [...archive.keys()] + const expectedPicker = `package/bin/${target.assetName}` + if (!paths.includes(expectedPicker)) { + throw new Error(`Manual candidate tarball omitted ${expectedPicker}`) + } + const unexpectedPicker = paths.find((path) => + /^package\/bin\/picker-(?:linux|macos|windows)-/.test(path) && + path !== expectedPicker + ) + if (unexpectedPicker) { + throw new Error( + `Manual candidate tarball contained stale host asset ${unexpectedPicker}`, + ) + } +} + +async function readSourcePackageManifest(): Promise<{ + name: string + version: string +}> { + const value = parseJson( + await readFile(join(root, "package.json"), "utf8"), + "package.json", + ) + if (!isRecord(value)) throw new Error("package.json must be an object") + return { + name: readRequiredString(value, "name"), + version: readRequiredString(value, "version"), + } +} + +function requiredHostPicker(): PickerTarget { + const target = pickerTargetForNode(process.platform, process.arch) + if (!target) { + throw new Error( + `Current host ${process.platform}-${process.arch} has no supported picker target`, + ) + } + return target +} + +function printEvidence( + metadata: ManualCandidateMetadata, + outputPath: string, +): void { + console.log("Manual candidate evidence:") + console.log(`Commit SHA: ${metadata.commitSha}`) + console.log(`Plugin package or tarball: ${metadata.tarball}`) + console.log(`npm tarball SHA-512: ${metadata.tarballIntegrity}`) + console.log(`Picker asset path: ${metadata.pickerPath}`) + console.log(`Picker SHA-256: ${metadata.pickerSha256}`) + console.log( + `Retained candidate metadata: ${outputPath}/${manualCandidateMetadataFilename}`, + ) +} + +async function gitOutput(arguments_: string[]): Promise { + const result = await runCaptured(["git", ...arguments_], root) + return result.stdout.trim() +} + +async function runInherited( + command: string[], + cwd: string, + environment: Record = process.env, +): Promise { + const child = Bun.spawn(command, { + cwd, + env: environment, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + const code = await child.exited + if (code !== 0) { + throw new Error(`${command.join(" ")} failed with exit code ${code}`) + } +} + +async function runCaptured( + command: string[], + cwd: string, + environment: Record = process.env, +): Promise<{ stdout: string; stderr: string }> { + const child = Bun.spawn(command, { + cwd, + env: environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (code !== 0) { + throw new Error( + `${command.join(" ")} failed with exit code ${code}: ${stderr.trim() || stdout.trim()}`, + ) + } + return { stdout, stderr } +} + +function readRequiredString( + value: Record, + key: string, +): string { + const candidate = value[key] + if ( + typeof candidate !== "string" || + candidate.length === 0 || + candidate.trim() !== candidate + ) { + throw new Error(`Manual candidate metadata ${key} must be a nonempty string`) + } + return candidate +} + +function parseJson(value: string, label: string): unknown { + try { + return JSON.parse(value) as unknown + } catch (error) { + throw new Error( + `${label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function printHelp(): void { + console.log(`Usage: + bun run scripts/manual-candidate.ts prepare [--output ${defaultManualCandidateOutput}] + bun run scripts/manual-candidate.ts install --project [--output ${defaultManualCandidateOutput}] [--opencode ] + bun run scripts/manual-candidate.ts test [--tui] [--output ${defaultManualCandidateOutput}] + +The output directory must stay inside the repository and be ignored by git.`) +} diff --git a/scripts/test-installed-native-opencode.ts b/scripts/test-installed-native-opencode.ts index ce4cb9c..c4afe2e 100644 --- a/scripts/test-installed-native-opencode.ts +++ b/scripts/test-installed-native-opencode.ts @@ -5,7 +5,7 @@ import { basename, isAbsolute, join, resolve } from "node:path" import { fileURLToPath } from "node:url" import { - assertExactPickerAssets, + assertCandidatePickerAssets, terminateDetachedProcessGroup, } from "./installed-native-opencode-support" @@ -103,10 +103,12 @@ try { access(installedPicker, constants.X_OK), ]) if (providedTarball) { - await assertExactPickerAssets( - join(root, "bin"), - join(installedPackage, "bin"), - ) + await assertCandidatePickerAssets({ + releaseBin: join(root, "bin"), + localPicker: join(root, "dist-picker", assetName), + installedBin: join(installedPackage, "bin"), + localAssetName: assetName, + }) } else { const [sourceBytes, installedBytes] = await Promise.all([ readFile(nativeAsset!), diff --git a/test/installed-native-opencode.test.ts b/test/installed-native-opencode.test.ts index b7febd7..2c83e56 100644 --- a/test/installed-native-opencode.test.ts +++ b/test/installed-native-opencode.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { releasePickerAssets } from "../scripts/check-packaging" import { + assertCandidatePickerAssets, assertExactPickerAssets, nativeE2EPreparationMode, terminateDetachedProcessGroup, @@ -108,6 +109,98 @@ describe("installed native OpenCode integration support", () => { } }) + test("accepts only a byte-identical local picker or the complete release set", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-local-candidate-")) + const releaseBin = join(directory, "release-bin") + const installedBin = join(directory, "installed-bin") + const localPicker = join(directory, "picker-linux-x64") + await Promise.all([ + mkdir(releaseBin, { recursive: true }), + mkdir(installedBin, { recursive: true }), + ]) + + try { + await writeFile(localPicker, new Uint8Array([1, 2, 3, 4])) + await writeFile( + join(installedBin, "picker-linux-x64"), + new Uint8Array([1, 2, 3, 4]), + ) + await expect(assertCandidatePickerAssets({ + releaseBin, + localPicker, + installedBin, + localAssetName: "picker-linux-x64", + })).resolves.toBe("local-candidate") + + await writeFile( + join(installedBin, "picker-windows-x64.exe"), + new Uint8Array([5, 6, 7]), + ) + await expect(assertCandidatePickerAssets({ + releaseBin, + localPicker, + installedBin, + localAssetName: "picker-linux-x64", + })).rejects.toThrow( + "either every release picker or exactly the local picker-linux-x64", + ) + + await rm(installedBin, { recursive: true, force: true }) + await mkdir(installedBin) + for (const [index, asset] of releasePickerAssets.entries()) { + const bytes = new Uint8Array([index, 8, 9]) + await Promise.all([ + writeFile(join(releaseBin, asset.name), bytes), + writeFile(join(installedBin, asset.name), bytes), + ]) + } + await expect(assertCandidatePickerAssets({ + releaseBin, + localPicker, + installedBin, + localAssetName: "picker-linux-x64", + })).resolves.toBe("complete-release") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects unknown native picker assets alongside an otherwise valid candidate", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-unknown-asset-")) + const releaseBin = join(directory, "release-bin") + const installedBin = join(directory, "installed-bin") + const localPicker = join(directory, "picker-linux-x64") + await Promise.all([ + mkdir(releaseBin, { recursive: true }), + mkdir(installedBin, { recursive: true }), + writeFile(localPicker, new Uint8Array([1, 2, 3, 4])), + ]) + + try { + await Promise.all([ + writeFile( + join(installedBin, "picker-linux-x64"), + new Uint8Array([1, 2, 3, 4]), + ), + writeFile( + join(installedBin, "picker-linux-riscv64"), + new Uint8Array([5, 6, 7, 8]), + ), + ]) + + await expect(assertCandidatePickerAssets({ + releaseBin, + localPicker, + installedBin, + localAssetName: "picker-linux-x64", + })).rejects.toThrow( + "received picker-linux-riscv64, picker-linux-x64", + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("terminates a detached integration group gracefully", async () => { let running = true const signals: Array = [] diff --git a/test/manual-candidate.test.ts b/test/manual-candidate.test.ts new file mode 100644 index 0000000..a4f8688 --- /dev/null +++ b/test/manual-candidate.test.ts @@ -0,0 +1,375 @@ +import { createHash } from "node:crypto" +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { describe, expect, test } from "bun:test" + +import type { PickerTarget } from "../src/picker-targets" +import { + discardManualCandidatePromotion, + invalidateManualCandidate, + manualCandidateGitIgnorePath, + manualCandidateInstallSpec, + manualCandidateTarballFilename, + parseManualCandidateArguments, + promoteManualCandidate, + readManualCandidateMetadata, + resolveManualCandidateOutput, + stageManualCandidatePackage, + verifyManualCandidate, + type ManualCandidateMetadata, +} from "../scripts/manual-candidate-support" + +const linuxX64: PickerTarget = { + platform: "linux", + arch: "x64", + rustTarget: "x86_64-unknown-linux-gnu", + assetName: "picker-linux-x64", + format: "elf", + machine: 0x3e, + executable: true, + openCodeIntegration: "verified", +} + +describe("manual release candidate support", () => { + test("checks ignored candidate outputs as directories before they exist", () => { + expect(manualCandidateGitIgnorePath(".manual-release")) + .toBe(".manual-release/") + expect(manualCandidateGitIgnorePath(".manual-release/custom/")) + .toBe(".manual-release/custom/") + }) + + test("uses a commit- and invocation-unique npm alias for exact installation", () => { + const metadata = { + commitSha: "a".repeat(40), + packageName: "opencode-model-dispatch", + packageVersion: "0.1.0", + } + expect(manualCandidateInstallSpec(metadata, "0123456789abcdef")).toBe( + "opencode-model-dispatch-manual-aaaaaaaaaaaa-0123456789abcdef" + + "@npm:opencode-model-dispatch@0.1.0", + ) + expect(() => manualCandidateInstallSpec(metadata, "not-random")) + .toThrow("16 lowercase hexadecimal") + }) + + test("gives retained tarballs a commit- and invocation-unique basename", () => { + expect(manualCandidateTarballFilename( + "opencode-model-dispatch-0.1.0.tgz", + "b".repeat(40), + "fedcba9876543210", + )).toBe( + "opencode-model-dispatch-0.1.0-bbbbbbbbbbbb-fedcba9876543210.tgz", + ) + }) + + test("parses prepare, exact scratch install, and retained test commands", () => { + expect(parseManualCandidateArguments(["prepare"])).toEqual({ + action: "prepare", + output: ".manual-release", + }) + expect(parseManualCandidateArguments([ + "install", + "--project", + "/tmp/manual-project", + "--output", + "dist/manual", + "--opencode", + "/opt/opencode", + ])).toEqual({ + action: "install", + output: "dist/manual", + project: "/tmp/manual-project", + openCode: "/opt/opencode", + }) + expect(parseManualCandidateArguments(["test", "--tui"])).toEqual({ + action: "test", + output: ".manual-release", + tui: true, + }) + expect(() => parseManualCandidateArguments(["install"])) + .toThrow("requires --project") + expect(() => parseManualCandidateArguments(["prepare", "--unknown"])) + .toThrow("Unknown manual candidate argument") + }) + + test("keeps retained output inside a repository-relative directory", () => { + const repository = resolve("/tmp/manual-candidate-repository") + expect(resolveManualCandidateOutput(repository, ".manual-release")) + .toEqual({ + absolutePath: join(repository, ".manual-release"), + repositoryPath: ".manual-release", + }) + expect(() => resolveManualCandidateOutput(repository, "")) + .toThrow("repository-relative") + expect(() => resolveManualCandidateOutput(repository, ".")) + .toThrow("inside the repository") + expect(() => resolveManualCandidateOutput(repository, "../outside")) + .toThrow("inside the repository") + expect(() => resolveManualCandidateOutput(repository, resolve("/tmp/outside"))) + .toThrow("repository-relative") + }) + + test("stages only the local picker and excludes stale host artifacts", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-manual-stage-")) + const repository = join(directory, "repository") + const stage = join(directory, "stage") + try { + await Promise.all([ + mkdir(join(repository, "dist"), { recursive: true }), + mkdir(join(repository, "dist-picker"), { recursive: true }), + mkdir(join(repository, "bin"), { recursive: true }), + ]) + for (const path of [ + "package.json", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + ]) { + await writeFile(join(repository, path), path) + } + await writeFile(join(repository, "dist", "index.js"), "export default {}") + await writeFile(join(repository, "bin", "picker.js"), "#!/usr/bin/env node\n") + await writeFile(join(repository, "bin", "picker-macos-arm64"), "stale") + await writeFile( + join(repository, "dist-picker", linuxX64.assetName), + "fresh-linux-picker", + ) + + await stageManualCandidatePackage({ + repositoryRoot: repository, + stageRoot: stage, + picker: linuxX64, + }) + + expect(await readFile(join(stage, "bin", linuxX64.assetName), "utf8")) + .toBe("fresh-linux-picker") + expect(await Bun.file(join(stage, "bin", "picker-macos-arm64")).exists()) + .toBe(false) + if (process.platform !== "win32") { + expect((await stat(join(stage, "bin", "picker.js"))).mode & 0o111) + .not.toBe(0) + expect((await stat(join(stage, "bin", linuxX64.assetName))).mode & 0o111) + .not.toBe(0) + } + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("verifies commit-bound tarball and picker hashes and rejects tampering", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-manual-verify-")) + const repository = join(directory, "repository") + const output = join(repository, ".manual-release") + const pickerPath = join(repository, "dist-picker", linuxX64.assetName) + const tarballPath = join(output, "opencode-model-dispatch-0.1.0.tgz") + const commitSha = "1".repeat(40) + try { + await Promise.all([ + mkdir(join(repository, "dist-picker"), { recursive: true }), + mkdir(output, { recursive: true }), + ]) + await writeFile(pickerPath, "candidate-picker") + await chmod(pickerPath, 0o755) + await writeFile(tarballPath, "candidate-tarball") + const metadata: ManualCandidateMetadata = { + schemaVersion: 1, + commitSha, + packageName: "opencode-model-dispatch", + packageVersion: "0.1.0", + tarball: "opencode-model-dispatch-0.1.0.tgz", + tarballIntegrity: integrity("candidate-tarball"), + pickerPath: `dist-picker/${linuxX64.assetName}`, + pickerSha256: createHash("sha256") + .update("candidate-picker") + .digest("hex"), + } + await writeFile( + join(output, "manual-candidate.json"), + JSON.stringify(metadata), + ) + + await expect(verifyManualCandidate({ + repositoryRoot: repository, + outputRoot: output, + expectedCommit: commitSha, + picker: linuxX64, + packageName: metadata.packageName, + packageVersion: metadata.packageVersion, + })).resolves.toMatchObject({ metadata }) + + await writeFile(tarballPath, "tampered") + await expect(verifyManualCandidate({ + repositoryRoot: repository, + outputRoot: output, + expectedCommit: commitSha, + picker: linuxX64, + packageName: metadata.packageName, + packageVersion: metadata.packageVersion, + })).rejects.toThrow("tarball no longer matches") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("refuses symlinked metadata without touching its external target", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-manual-metadata-link-")) + const output = join(directory, "output") + const externalMetadata = join(directory, "external-metadata.json") + try { + await mkdir(output) + await writeFile(externalMetadata, "external-metadata") + await symlink( + externalMetadata, + join(output, "manual-candidate.json"), + "file", + ) + + await expect(invalidateManualCandidate(output)).rejects.toThrow( + "metadata must be a real regular file", + ) + expect(await readFile(externalMetadata, "utf8")).toBe("external-metadata") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("promotes staged evidence without following a retained tarball symlink", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-manual-promote-")) + const output = join(directory, "output") + const staged = join(directory, "staged") + const externalTarball = join(directory, "external.tgz") + const tarballFilename = "candidate-deadbeefdead-0123456789abcdef.tgz" + const stagedTarball = join(staged, tarballFilename) + const stagedMetadata = join(staged, "manual-candidate.json") + try { + await Promise.all([mkdir(output), mkdir(staged)]) + await Promise.all([ + writeFile(externalTarball, "external-tarball"), + writeFile(stagedTarball, "candidate-tarball"), + writeFile(stagedMetadata, "{}"), + ]) + await symlink( + externalTarball, + join(output, tarballFilename), + "file", + ) + + await expect(promoteManualCandidate({ + outputRoot: output, + stagedTarball, + stagedMetadata, + tarballFilename, + })).rejects.toThrow("already exists and will not be overwritten") + expect(await readFile(externalTarball, "utf8")).toBe("external-tarball") + expect(await readFile(stagedTarball, "utf8")).toBe("candidate-tarball") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("atomically promotes and can discard a staged candidate", async () => { + const directory = await mkdtemp(join(tmpdir(), "model-dispatch-manual-atomic-")) + const output = join(directory, "output") + const staged = join(directory, "staged") + const tarballFilename = "candidate-cafebabecafe-fedcba9876543210.tgz" + const stagedTarball = join(staged, tarballFilename) + const stagedMetadata = join(staged, "manual-candidate.json") + try { + await Promise.all([mkdir(output), mkdir(staged)]) + await Promise.all([ + writeFile(stagedTarball, "candidate-tarball"), + writeFile(stagedMetadata, "candidate-metadata"), + ]) + + await expect(promoteManualCandidate({ + outputRoot: output, + stagedTarball, + stagedMetadata, + tarballFilename, + })).resolves.toEqual({ + tarballPath: join(output, tarballFilename), + metadataPath: join(output, "manual-candidate.json"), + }) + expect(await readFile(join(output, tarballFilename), "utf8")) + .toBe("candidate-tarball") + expect(await readFile(join(output, "manual-candidate.json"), "utf8")) + .toBe("candidate-metadata") + + await discardManualCandidatePromotion(output, tarballFilename) + expect(await Bun.file(join(output, tarballFilename)).exists()).toBe(false) + expect(await Bun.file(join(output, "manual-candidate.json")).exists()) + .toBe(false) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("validates evidence metadata before using retained paths", () => { + const valid = { + schemaVersion: 1, + commitSha: "a".repeat(40), + packageName: "opencode-model-dispatch", + packageVersion: "0.1.0", + tarball: "opencode-model-dispatch-0.1.0.tgz", + tarballIntegrity: `sha512-${"A".repeat(86)}==`, + pickerPath: "dist-picker/picker-linux-x64", + pickerSha256: "b".repeat(64), + } + expect(readManualCandidateMetadata(valid)).toEqual(valid) + expect(() => readManualCandidateMetadata({ + ...valid, + tarball: "../candidate.tgz", + })).toThrow("safe .tgz basename") + expect(() => readManualCandidateMetadata({ + ...valid, + pickerPath: "/tmp/picker", + })).toThrow("dist-picker repository path") + }) + + test("publishes the preparation, install, and exact-tarball test scripts", async () => { + const packageJson = JSON.parse( + await Bun.file(new URL("../package.json", import.meta.url)).text(), + ) as { scripts?: Record } + const commandSource = await Bun.file( + new URL("../scripts/manual-candidate.ts", import.meta.url), + ).text() + expect(packageJson.scripts?.["release:manual-candidate"]).toBe( + "bun run scripts/manual-candidate.ts prepare", + ) + expect(packageJson.scripts?.["release:manual-candidate:install"]).toBe( + "bun run scripts/manual-candidate.ts install", + ) + expect(packageJson.scripts?.["release:manual-candidate:test"]).toBe( + "bun run scripts/manual-candidate.ts test", + ) + expect(packageJson.scripts?.["release:manual-candidate:test:tui"]).toBe( + "bun run scripts/manual-candidate.ts test --tui", + ) + expect(commandSource).toContain( + 'await import("./local-npm-registry")', + ) + expect(commandSource).not.toMatch( + /import\s+\{[^}]*startLocalNpmRegistry[^}]*\}\s+from\s+"\.\/local-npm-registry"/s, + ) + expect(commandSource.indexOf("const finalCommitSha")).toBeLessThan( + commandSource.indexOf("await promoteManualCandidate"), + ) + expect(commandSource).toContain( + '[openCode, "plugin", packageSpec, "--pure"]', + ) + }) +}) + +function integrity(value: string): string { + return `sha512-${createHash("sha512").update(value).digest("base64")}` +} diff --git a/test/manual-integration-gate.test.ts b/test/manual-integration-gate.test.ts index 503187a..d7ff32d 100644 --- a/test/manual-integration-gate.test.ts +++ b/test/manual-integration-gate.test.ts @@ -15,7 +15,7 @@ describe("manual OpenCode integration gate", () => { for (const required of [ "Local OpenCode starts with the plugin installed in a scratch project", - "`bun run test:package:native:opencode` installs an npm tarball", + "`bun run release:manual-candidate:test` installs the retained npm tarball", "First-run setup opens at plugin load", "Dispatch remains disabled if setup is cancelled and snoozed", "Enabling dispatch works", diff --git a/test/opencode-compatibility.test.ts b/test/opencode-compatibility.test.ts index bd6fb8e..f01541a 100644 --- a/test/opencode-compatibility.test.ts +++ b/test/opencode-compatibility.test.ts @@ -12,6 +12,29 @@ async function readText(path: string): Promise { } describe("OpenCode compatibility policy", () => { + test("keeps minimum contract fixtures out of ordinary Dependabot bumps", async () => { + const dependabot = await readText(".github/dependabot.yml") + const development = await readText("docs/development.md") + const ignoredVersionUpdates = [ + '"version-update:semver-patch"', + '"version-update:semver-minor"', + '"version-update:semver-major"', + ] + + for (const dependency of ["@opencode-ai/plugin", "@opencode-ai/sdk"]) { + const start = dependabot.indexOf(`dependency-name: "${dependency}"`) + expect(start).toBeGreaterThan(-1) + const entry = dependabot.slice(start, start + 260) + for (const updateType of ignoredVersionUpdates) { + expect(entry).toContain(updateType) + } + } + expect(development).toContain( + "must equal the lower bound in `engines.opencode`", + ) + expect(development).toContain("Dependabot security updates remain enabled") + }) + test("selects the exact minimum and latest patch in each active minor", () => { expect(resolveOpenCodeCompatibilityTargets( ">=1.18.7 <2", diff --git a/test/packaging.test.ts b/test/packaging.test.ts index bba570b..9402154 100644 --- a/test/packaging.test.ts +++ b/test/packaging.test.ts @@ -97,6 +97,10 @@ describe("packaging and release assets", () => { ">=1.18.7 <2", ) expect(pkg.peerDependenciesMeta?.["@opencode-ai/plugin"]).toBeUndefined() + expect(pkg.devDependencies?.["@opencode-ai/plugin"]).toBe("1.18.7") + expect(pkg.devDependencies?.["@opencode-ai/sdk"]).toBe("1.18.7") + expect(pkg.scripts?.["check:package-types"]).toContain("--ignoreConfig") + expect(pkg.scripts?.["check:package-types"]).toContain("--types bun-types") expect(await Bun.file(new URL("../.npmignore", import.meta.url)).exists()).toBe(true) expect(launcher).toContain("isAbsolute") expect(launcher).toContain( @@ -134,6 +138,10 @@ describe("packaging and release assets", () => { expect(script).toContain("postinstall") expect(script).toContain("opencode-model-dispatch-picker") expect(script).toContain("files") + expect(script).toContain("minimumOpenCodeContractVersion") + expect(script).toContain("check:package-types") + expect(script).toContain("--ignoreConfig") + expect(script).toContain("--types bun-types") }) test("release packaging validates native binary magic, size, and Unix modes", async () => { @@ -565,7 +573,7 @@ describe("packaging and release assets", () => { expect(workflow).toContain( "rustup toolchain install 1.97.1 --profile minimal --no-self-update", ) - expect(workflow.match(/id-token: write/g)).toHaveLength(3) + expect(workflow.match(/id-token: write/g)).toHaveLength(4) expect(stageJob).toContain("id-token: write") expect(stageJob).toContain("attestations: write") expect(stageJob).toContain("artifact-metadata: write") @@ -750,16 +758,49 @@ describe("packaging and release assets", () => { expect(windowsSigningJob).toContain('"WindowsPowerShell"') expect(windowsSigningJob).toContain('"powershell.exe"') expect(windowsSigningJob).toContain("$signtoolPath") - expect(windowsSigningJob).toContain("secrets.WINDOWS_CERTIFICATE") + expect(windowsSigningJob).toContain("id-token: write") + expect(windowsSigningJob).toContain( + "azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43", + ) + expect(windowsSigningJob).toContain( + "azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82", + ) + expect(windowsSigningJob).toContain( + "vars.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", + ) + expect(windowsSigningJob).toContain( + "vars.EXPECTED_WINDOWS_SIGNER_SUBJECT", + ) + expect(windowsSigningJob).toContain( + "AZURE_ARTIFACT_SIGNING_ENDPOINT must be a root https://*.codesigning.azure.net endpoint", + ) + expect(windowsSigningJob).toContain("cache-dependencies: false") + expect(windowsSigningJob).toContain("append-signature: false") + expect(windowsSigningJob).toContain( + "[System.Management.Automation.SignatureStatus]::NotSigned", + ) + expect(windowsSigningJob).toContain( + "Get-AuthenticodeSignature -LiteralPath $picker", + ) + expect(windowsSigningJob).toContain( + "Windows picker signature is missing its RFC 3161 timestamp", + ) + expect(windowsSigningJob).toContain( + "Windows picker signer subject does not match EXPECTED_WINDOWS_SIGNER_SUBJECT", + ) + expect(windowsSigningJob).not.toContain("secrets.WINDOWS_CERTIFICATE") expect(windowsSigningJob).not.toContain("secrets.APPLE_CERTIFICATE") - expect(windowsSigningJob.match(/secrets\.WINDOWS_[A-Z_]+/g)).toHaveLength(2) + expect(windowsSigningJob).not.toMatch(/secrets\.WINDOWS_[A-Z_]+/) + const macosActions = [...macosSigningJob.matchAll(/uses:\s+([^\s]+)/g)] + .map((match) => match[1]) + expect(macosActions).toHaveLength(2) + const windowsActions = [...windowsSigningJob.matchAll(/uses:\s+([^\s]+)/g)] + .map((match) => match[1]) + expect(windowsActions).toHaveLength(4) + for (const action of [...macosActions, ...windowsActions]) { + expect(action).toMatch(/@[0-9a-f]{40}$/) + } for (const signingJob of [macosSigningJob, windowsSigningJob]) { - const actions = [...signingJob.matchAll(/uses:\s+([^\s]+)/g)] - .map((match) => match[1]) - expect(actions).toHaveLength(2) - for (const action of actions) { - expect(action).toMatch(/@[0-9a-f]{40}$/) - } expect(signingJob).not.toContain("actions/checkout@") expect(signingJob).not.toContain("oven-sh/setup-bun") expect(signingJob).not.toContain("actions/setup-node") @@ -810,10 +851,28 @@ describe("packaging and release assets", () => { expect(macosSigningJob.indexOf("Remove Apple signing credentials")).toBeLessThan( macosSigningJob.indexOf("Retain canonical signed macOS picker"), ) - expect(windowsSigningJob.indexOf("Sign and verify Windows picker")).toBeLessThan( - windowsSigningJob.indexOf("Remove Windows signing credentials"), + expect( + windowsSigningJob.indexOf( + "Validate Windows signing configuration and unsigned picker", + ), + ).toBeLessThan( + windowsSigningJob.indexOf("Authenticate to Azure with GitHub OIDC"), + ) + expect( + windowsSigningJob.indexOf("Authenticate to Azure with GitHub OIDC"), + ).toBeLessThan( + windowsSigningJob.indexOf( + "Sign Windows picker with Azure Artifact Signing", + ), + ) + expect( + windowsSigningJob.indexOf( + "Sign Windows picker with Azure Artifact Signing", + ), + ).toBeLessThan( + windowsSigningJob.indexOf("Verify signed Windows picker"), ) - expect(windowsSigningJob.indexOf("Remove Windows signing credentials")).toBeLessThan( + expect(windowsSigningJob.indexOf("Verify signed Windows picker")).toBeLessThan( windowsSigningJob.indexOf("Retain canonical signed Windows picker"), ) expect(workflow).toContain("gh release create") @@ -844,6 +903,16 @@ describe("packaging and release assets", () => { expect(releasing).toContain("mandatory local pre-tag gate") expect(releasing).toMatch(/contains no\s+repository-administration token/) expect(releasing).toContain("allowed action `npm publish`") + expect(releasing).toContain("App Store Connect **Team API key**") + expect(releasing).toContain("individual API key cannot") + expect(releasing).toContain("Azure Artifact Signing") + expect(releasing).toContain("immutable OIDC subject") + expect(releasing).toContain("use_immutable_subject=true") + expect(releasing).toContain("Verify release-signing OIDC") + expect(releasing).toContain("The final proof must come from a token minted") + expect(releasing).not.toContain("sub_claim_prefix") + expect(releasing).toContain("EXPECTED_WINDOWS_SIGNER_SUBJECT") + expect(releasing).not.toContain("`WINDOWS_CERTIFICATE`") expect(`${readme}\n${releasing}`).not.toContain("REPOSITORY_RULESET_AUDIT_TOKEN") }) }) diff --git a/test/public-repository.test.ts b/test/public-repository.test.ts index b49bc6c..f0cb5ab 100644 --- a/test/public-repository.test.ts +++ b/test/public-repository.test.ts @@ -106,6 +106,10 @@ function readySnapshot( vulnerabilityReporting: verified({ enabled: true }), immutableReleases: verified({ enabled: true, enforced_by_owner: false }), dependabotSecurityUpdates: verified({ enabled: true, paused: false }), + oidcSubjectCustomization: verified({ + use_default: true, + use_immutable_subject: true, + }), mainRules: verified(completeMainRules), classicMainProtection: unavailable("classic branch protection is not configured"), releaseTagRulesets: verified([completeTagRuleset]), @@ -159,6 +163,7 @@ describe("public repository release gate", () => { vulnerabilityReporting: unavailable("HTTP 401"), immutableReleases: unavailable("HTTP 403"), dependabotSecurityUpdates: unavailable("ambiguous HTTP 404"), + oidcSubjectCustomization: unavailable("HTTP 403"), })) expect(result.failures).toContain( @@ -170,6 +175,9 @@ describe("public repository release gate", () => { expect(result.failures).toContain( "GitHub Dependabot security updates could not be verified: ambiguous HTTP 404", ) + expect(result.failures).toContain( + "GitHub immutable OIDC subject customization could not be verified: HTTP 403", + ) }) test("distinguishes disabled and paused security features", () => { @@ -194,6 +202,30 @@ describe("public repository release gate", () => { ) }) + test("requires the default immutable OIDC subject format", () => { + const disabled = publicRepositoryReadiness(readySnapshot({ + oidcSubjectCustomization: verified({ + use_default: true, + use_immutable_subject: false, + }), + })) + expect(disabled.failures).toContain( + "GitHub immutable OIDC subject customization must keep the default " + + "context and enable immutable owner/repository IDs", + ) + + const customContext = publicRepositoryReadiness(readySnapshot({ + oidcSubjectCustomization: verified({ + use_default: false, + use_immutable_subject: true, + }), + })) + expect(customContext.failures).toContain( + "GitHub immutable OIDC subject customization must keep the default " + + "context and enable immutable owner/repository IDs", + ) + }) + test("requires deletion, force-push, and complete strict CI protection on main", () => { const result = publicRepositoryReadiness(readySnapshot({ mainRules: verified([ @@ -523,6 +555,9 @@ describe("GitHub readiness snapshot collection", () => { .toBe("Bearer settings-token") expect(seenAuthorization.get(`${API_ROOT}/automated-security-fixes`)) .toBe("Bearer settings-token") + expect(seenAuthorization.get( + `${API_ROOT}/actions/oidc/customization/sub`, + )).toBe("Bearer settings-token") expect(seenAuthorization.get(`${API_ROOT}/branches/main/protection`)) .toBe("Bearer settings-token") }) @@ -541,6 +576,10 @@ describe("GitHub readiness snapshot collection", () => { status: 404, body: { message: "Not Found" }, } + routes[`${API_ROOT}/actions/oidc/customization/sub`] = { + status: 403, + body: { message: "Resource not accessible by integration" }, + } const snapshot = await collectPublicRepositorySnapshot({ slug: SLUG, @@ -567,6 +606,12 @@ describe("GitHub readiness snapshot collection", () => { "Dependabot security updates endpoint returned HTTP 404; " + "disabled and permission-hidden states are ambiguous", }) + expect(snapshot.oidcSubjectCustomization).toEqual({ + status: "unavailable", + reason: + "immutable OIDC subject customization endpoint returned HTTP 403; " + + "the token cannot verify this setting", + }) const result = publicRepositoryReadiness(snapshot) expect(result.failures.some((failure) => @@ -578,6 +623,11 @@ describe("GitHub readiness snapshot collection", () => { expect(result.failures.some((failure) => failure.startsWith("GitHub Dependabot security updates could not be verified:") )).toBe(true) + expect(result.failures.some((failure) => + failure.startsWith( + "GitHub immutable OIDC subject customization could not be verified:", + ) + )).toBe(true) }) test("also fails closed when ruleset or exact-SHA CI APIs are permission-hidden", async () => { @@ -695,6 +745,9 @@ function happyRoutes(): Record { [`${API_ROOT}/automated-security-fixes`]: { body: { enabled: true, paused: false }, }, + [`${API_ROOT}/actions/oidc/customization/sub`]: { + body: { use_default: true, use_immutable_subject: true }, + }, [`${API_ROOT}/rules/branches/main?per_page=100`]: { body: completeMainRules, }, diff --git a/test/release-hardening.test.ts b/test/release-hardening.test.ts index fc821d0..e0c466e 100644 --- a/test/release-hardening.test.ts +++ b/test/release-hardening.test.ts @@ -33,6 +33,7 @@ describe("release hardening", () => { readText(".github/workflows/ci.yml"), readText(".github/workflows/compatibility.yml"), readText(".github/workflows/publish.yml"), + readText(".github/workflows/verify-release-oidc.yml"), ]) const combined = workflows.join("\n") @@ -73,6 +74,16 @@ describe("release hardening", () => { "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", ) } + if (ref?.startsWith("azure/login@")) { + expect(ref).toBe( + "azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43", + ) + } + if (ref?.startsWith("azure/artifact-signing-action@")) { + expect(ref).toBe( + "azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82", + ) + } } const setupNodeCount = workflow.match(/actions\/setup-node@/g)?.length ?? 0 @@ -96,7 +107,7 @@ describe("release hardening", () => { ]) }) - test("platform signing secrets are isolated behind protected environments", async () => { + test("platform signing credentials are isolated behind protected environments", async () => { const workflow = await readText(".github/workflows/publish.yml") const macos = workflowJob( workflow, @@ -114,10 +125,50 @@ describe("release hardening", () => { expect(macos).not.toContain("secrets.WINDOWS_CERTIFICATE") expect(windows).toContain("environment: release-signing-windows") - expect(windows).toContain("secrets.WINDOWS_CERTIFICATE") + expect(windows).toContain("id-token: write") + expect(windows).toContain( + "azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43", + ) + expect(windows).toContain( + "azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82", + ) + expect(windows).toContain("vars.AZURE_ARTIFACT_SIGNING_ENDPOINT") + expect(windows).toContain("vars.EXPECTED_WINDOWS_SIGNER_SUBJECT") + expect(windows).toContain("cache-dependencies: false") + expect(windows).toContain("append-signature: false") + expect(windows).toContain( + "[System.Management.Automation.SignatureStatus]::NotSigned", + ) + expect(windows).toContain( + "Windows picker signer subject does not match EXPECTED_WINDOWS_SIGNER_SUBJECT", + ) + expect(windows).not.toContain("secrets.WINDOWS_CERTIFICATE") expect(windows).not.toContain("secrets.APPLE_CERTIFICATE") }) + test("manual OIDC proof is main-only, environment-bound, and secretless", async () => { + const workflow = await readText( + ".github/workflows/verify-release-oidc.yml", + ) + + expect(workflow).toContain("workflow_dispatch:") + expect(workflow).toContain("permissions: {}") + expect(workflow).toContain('test "$GITHUB_REF" = "refs/heads/main"') + expect(workflow).toContain("needs: validate-ref") + expect(workflow).toContain("environment: release-signing-windows") + expect(workflow).toContain("id-token: write") + expect(workflow).toContain("ACTIONS_ID_TOKEN_REQUEST_URL") + expect(workflow).toContain("api://AzureADTokenExchange") + expect(workflow).toContain("https://token.actions.githubusercontent.com") + expect(workflow).toContain( + "repo:Lauritz-Timm@269186225/opencode-model-dispatch@1290427988" + + ":environment:release-signing-windows", + ) + expect(workflow).not.toContain("actions/checkout@") + expect(workflow).not.toContain("${{ secrets.") + expect(workflow).not.toContain("contents: write") + }) + test("npm release files and staged top-level paths are exact allowlists", () => { expect(releasePackageManifestFailures([ ...releasePackageManifestFiles, diff --git a/test/release-source.test.ts b/test/release-source.test.ts index 4a2e3ac..6de0236 100644 --- a/test/release-source.test.ts +++ b/test/release-source.test.ts @@ -59,11 +59,13 @@ describe("release source preflight", () => { "docs/compatibility.md", "picker/bun.lock", "picker/src-tauri/Cargo.lock", + "scripts/manual-candidate-support.ts", "scripts/resolve-opencode-compatibility.ts", "third-party/about.toml", "third-party/RUST_THIRD_PARTY_LICENSES.md", "SECURITY.md", ".github/workflows/publish.yml", + ".github/workflows/verify-release-oidc.yml", ".github/dependabot.yml", ]) const failures = releaseSourceFailures(releaseSource({