From 0fd993dd655b66c7cbf8e25b80832b73e6f2fce9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:21:39 +0000 Subject: [PATCH 01/14] release: version packages --- .changeset/desktop-container.md | 8 -------- packages/cli/CHANGELOG.md | 8 ++++++++ packages/cli/package.json | 2 +- packages/desktop/CHANGELOG.md | 9 +++++++++ packages/desktop/package.json | 2 +- 5 files changed, 19 insertions(+), 10 deletions(-) delete mode 100644 .changeset/desktop-container.md create mode 100644 packages/desktop/CHANGELOG.md diff --git a/.changeset/desktop-container.md b/.changeset/desktop-container.md deleted file mode 100644 index de03569d..00000000 --- a/.changeset/desktop-container.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@spencer-kit/coder-studio": patch -"@coder-studio/desktop": patch ---- - -Ship the first unified Coder Studio and Desktop Shell release, including the Electron container, -isolated state locking, the bundled Node Engine, WSL support, and independently signed Product -Runtime updates with startup validation and automatic rollback. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index b731e260..24932737 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.7 + +### Patch Changes + +- [#90](https://github.com/spencerkit/coder-studio/pull/90) [`ff6bb92`](https://github.com/spencerkit/coder-studio/commit/ff6bb92c0ca30c61f8ee35c4785dcc44f5c3f647) Thanks [@pallyoung](https://github.com/pallyoung)! - Ship the first unified Coder Studio and Desktop Shell release, including the Electron container, + isolated state locking, the bundled Node Engine, WSL support, and independently signed Product + Runtime updates with startup validation and automatic rollback. + ## 0.5.6 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 55533818..81d3f551 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@spencer-kit/coder-studio", - "version": "0.5.6", + "version": "0.5.7", "type": "module", "description": "Self-hosted browser workspace for AI coding agents, review, supervision, and cross-device continuation.", "main": "./src/index.ts", diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md new file mode 100644 index 00000000..0cf1a841 --- /dev/null +++ b/packages/desktop/CHANGELOG.md @@ -0,0 +1,9 @@ +# @coder-studio/desktop + +## 0.1.1 + +### Patch Changes + +- [#90](https://github.com/spencerkit/coder-studio/pull/90) [`ff6bb92`](https://github.com/spencerkit/coder-studio/commit/ff6bb92c0ca30c61f8ee35c4785dcc44f5c3f647) Thanks [@pallyoung](https://github.com/pallyoung)! - Ship the first unified Coder Studio and Desktop Shell release, including the Electron container, + isolated state locking, the bundled Node Engine, WSL support, and independently signed Product + Runtime updates with startup validation and automatic rollback. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9576932a..f75a451a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@coder-studio/desktop", - "version": "0.1.0", + "version": "0.1.1", "description": "Native desktop shell for the local-first Coder Studio agentic workspace.", "author": "Coder Studio", "license": "MIT", From 46e008a4a5c65b192d75cd6204fc0b7c0c8b4bc4 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 17:18:00 +0800 Subject: [PATCH 02/14] test: follow desktop package version --- scripts/build-desktop-runtime.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/build-desktop-runtime.test.ts b/scripts/build-desktop-runtime.test.ts index 9cbdfc9b..ae20a66d 100644 --- a/scripts/build-desktop-runtime.test.ts +++ b/scripts/build-desktop-runtime.test.ts @@ -100,6 +100,9 @@ describe("build-desktop-runtime", () => { process.env.CODER_STUDIO_RELEASE_PUBLISHED_AT = "2026-08-08T01:02:03.000Z"; process.env.CODER_STUDIO_RUNTIME_UPDATE_URL = ""; process.env.CODER_STUDIO_FACTORY_RELEASE_BASE_URL = ""; + const desktopPackage = JSON.parse( + await readFile(resolve(DESKTOP_DIR, "package.json"), "utf8") + ) as { version: string }; await buildDesktopShell({ clean: true }); @@ -107,7 +110,7 @@ describe("build-desktop-runtime", () => { readFile(resolve(DESKTOP_DIST_DIR, "build-info.json"), "utf8").then(JSON.parse) ).resolves.toMatchObject({ schemaVersion: 1, - shellVersion: "0.1.0", + shellVersion: desktopPackage.version, publishedAt: "2026-08-08T01:02:03.000Z", engineVersion: "2", nodeVersion: "24.19.0", From 8bd406878150011e81e71d847d5ee194fc24a11a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 18:05:05 +0800 Subject: [PATCH 03/14] ci: support unsigned Windows desktop releases --- .github/workflows/desktop-acceptance.yml | 16 ++++++++- .github/workflows/desktop-release.yml | 29 +++++++++++---- .github/workflows/desktop-verify.yml | 15 +++++--- scripts/github-workflows.test.ts | 36 ++++++++++++++++--- scripts/verify-desktop-installed-update.ps1 | 11 ++++-- .../verify-desktop-installed-update.test.ts | 2 ++ 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index 906a2650..4c247e99 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -2,6 +2,12 @@ name: Publish Desktop acceptance on: workflow_dispatch: + inputs: + windows_signing: + description: "Sign Windows executables with Authenticode" + required: true + default: true + type: boolean permissions: contents: read @@ -93,6 +99,7 @@ jobs: uses: ./.github/workflows/desktop-verify.yml with: signed: true + windows_signing: ${{ inputs.windows_signing }} signing_key_artifact: ${{ needs.prepare.outputs.signing_key_artifact }} runtime_update_url: ${{ needs.prepare.outputs.runtime_update_url }} release_tag: ${{ needs.prepare.outputs.release_tag }} @@ -171,10 +178,14 @@ jobs: exit 1 fi elif grep -q '(HTTP 404)' "${release_error}"; then + windows_signing_note="" + if [[ "${{ inputs.windows_signing }}" != "true" ]]; then + windows_signing_note=" WARNING: Windows executables in this acceptance build are not Authenticode-signed." + fi gh release create "${RELEASE_TAG}" \ --target "${GITHUB_SHA}" \ --title "Coder Studio Desktop acceptance ${GITHUB_RUN_NUMBER}" \ - --notes "Ephemeral signed acceptance channel for ${GITHUB_SHA}. The signing key is test-only; do not promote these assets to production." \ + --notes "Ephemeral Runtime-signed acceptance channel for ${GITHUB_SHA}. The Runtime signing key is test-only; do not promote these assets to production.${windows_signing_note}" \ --draft else cat "${release_error}" >&2 @@ -388,6 +399,9 @@ jobs: if ('${{ matrix.scenario }}' -in @('fresh-wsl', 'wsl', 'wsl-combined')) { $arguments += @('-WslDistro', '${{ steps.wsl.outputs.name }}') } + if ('${{ inputs.windows_signing }}' -ne 'true') { + $arguments += '-SkipAuthenticode' + } pnpm acceptance:desktop:installed -- @arguments if ($LASTEXITCODE -ne 0) { throw 'Installed Desktop acceptance failed' } diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 6c1fecfa..d9d1f51f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -8,6 +8,11 @@ on: required: true default: false type: boolean + windows_signing: + description: "Sign Windows executables with Authenticode" + required: true + default: true + type: boolean desktop_acceptance_run_id: description: "Workflow run containing the complete installed Desktop acceptance matrix" required: false @@ -181,7 +186,7 @@ jobs: retention-days: 14 windows-assets: - name: Build signed Windows assets + name: Build Windows assets needs: prepare runs-on: windows-latest timeout-minutes: 60 @@ -191,8 +196,9 @@ jobs: env: CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_RUNTIME_SIGNING_PRIVATE_KEY }} CODER_STUDIO_RUNTIME_PUBLIC_KEY: ${{ secrets.DESKTOP_RUNTIME_PUBLIC_KEY }} - CSC_LINK: ${{ secrets.DESKTOP_WINDOWS_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WINDOWS_CSC_KEY_PASSWORD }} + CSC_LINK: ${{ inputs.windows_signing && secrets.DESKTOP_WINDOWS_CSC_LINK || '' }} + CSC_KEY_PASSWORD: ${{ inputs.windows_signing && secrets.DESKTOP_WINDOWS_CSC_KEY_PASSWORD || '' }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.windows_signing && 'true' || 'false' }} CODER_STUDIO_RELEASE_PUBLISHED_AT: ${{ needs.prepare.outputs.published_at }} CODER_STUDIO_FACTORY_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.tag }}/ @@ -218,7 +224,7 @@ jobs: if (-not $env:CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY -or -not $env:CODER_STUDIO_RUNTIME_PUBLIC_KEY) { throw "Desktop Runtime Ed25519 signing secrets are required" } - if ("${{ needs.prepare.outputs.release_kind }}" -eq "full" -and (-not $env:CSC_LINK -or -not $env:CSC_KEY_PASSWORD)) { + if ("${{ needs.prepare.outputs.release_kind }}" -eq "full" -and "${{ inputs.windows_signing }}" -eq "true" -and (-not $env:CSC_LINK -or -not $env:CSC_KEY_PASSWORD)) { throw "Windows Authenticode signing secrets are required for a full Desktop release" } @@ -230,7 +236,7 @@ jobs: pnpm --filter @coder-studio/desktop test pnpm ci:typecheck - - name: Build signed Windows assets + - name: Build Windows assets shell: pwsh run: | if ("${{ needs.prepare.outputs.release_kind }}" -eq "full") { @@ -241,7 +247,7 @@ jobs: } - name: Verify Authenticode signatures - if: needs.prepare.outputs.release_kind == 'full' + if: needs.prepare.outputs.release_kind == 'full' && inputs.windows_signing shell: pwsh run: | $metadata = Get-Content release/desktop/latest.yml -Raw @@ -258,7 +264,7 @@ jobs: } } - - name: Stage signed Windows assets + - name: Stage Windows assets shell: pwsh run: | $components = 'win-runtime' @@ -380,10 +386,16 @@ jobs: - name: Publish immutable prerelease shell: bash run: | + release_notes_args=() + if [[ "${{ needs.prepare.outputs.release_kind }}" == "full" && "${{ inputs.windows_signing }}" != "true" ]]; then + release_notes_args+=(--notes "> [!WARNING] + > Windows executables in this release are not Authenticode-signed. Windows may show an Unknown publisher warning. Runtime, WSL Engine, and update-channel manifests remain cryptographically signed.") + fi gh release create "${{ needs.prepare.outputs.tag }}" \ --target "${GITHUB_SHA}" \ --title "${{ needs.prepare.outputs.title }}" \ --generate-notes \ + "${release_notes_args[@]}" \ --prerelease --latest=false \ release/desktop-release-final/* @@ -556,6 +568,9 @@ jobs: if ('${{ matrix.target }}' -eq 'wsl') { $arguments += @('-WslDistro', '${{ steps.wsl.outputs.name }}') } + if ('${{ inputs.windows_signing }}' -ne 'true') { + $arguments += '-SkipAuthenticode' + } pnpm acceptance:desktop:installed -- @arguments if ($LASTEXITCODE -ne 0) { throw 'Production installed Desktop acceptance failed' } diff --git a/.github/workflows/desktop-verify.yml b/.github/workflows/desktop-verify.yml index 3c86647d..c0c092b4 100644 --- a/.github/workflows/desktop-verify.yml +++ b/.github/workflows/desktop-verify.yml @@ -7,6 +7,10 @@ on: type: boolean required: false default: false + windows_signing: + type: boolean + required: false + default: true signing_key_artifact: type: string required: false @@ -114,8 +118,9 @@ jobs: timeout-minutes: 60 env: CODER_STUDIO_RELEASE_PUBLISHED_AT: ${{ needs.prepare.outputs.published_at }} - CSC_LINK: ${{ secrets.windows_csc_link }} - CSC_KEY_PASSWORD: ${{ secrets.windows_csc_key_password }} + CSC_LINK: ${{ inputs.windows_signing && secrets.windows_csc_link || '' }} + CSC_KEY_PASSWORD: ${{ inputs.windows_signing && secrets.windows_csc_key_password || '' }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.windows_signing && 'true' || 'false' }} outputs: artifact_name: ${{ steps.artifact_name.outputs.value }} @@ -170,18 +175,18 @@ jobs: pnpm ci:typecheck - name: Require acceptance Authenticode credentials - if: inputs.signed + if: inputs.windows_signing shell: pwsh run: | if (-not $env:CSC_LINK -or -not $env:CSC_KEY_PASSWORD) { - throw 'Windows Authenticode signing secrets are required for signed Desktop acceptance' + throw 'Windows Authenticode signing secrets are required when windows_signing is enabled' } - name: Build Windows installer and Runtime run: pnpm dist:desktop - name: Verify acceptance Authenticode signatures - if: inputs.signed + if: inputs.windows_signing shell: pwsh run: | $metadata = Get-Content release/desktop/latest.yml -Raw diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index df028f43..930aac77 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -92,6 +92,7 @@ describe("GitHub workflow boundaries", () => { expect(workflow.permissions).toEqual({ contents: "read" }); expect(workflowCall.inputs).toEqual({ signed: { type: "boolean", required: false, default: false }, + windows_signing: { type: "boolean", required: false, default: true }, signing_key_artifact: { type: "string", required: false, default: "" }, runtime_update_url: { type: "string", required: false, default: "" }, release_tag: { type: "string", required: false, default: "" }, @@ -142,13 +143,14 @@ describe("GitHub workflow boundaries", () => { overwrite: true, }); expect(windowsJob.env).toMatchObject({ - CSC_LINK: "${{ secrets.windows_csc_link }}", - CSC_KEY_PASSWORD: "${{ secrets.windows_csc_key_password }}", + CSC_LINK: "${{ inputs.windows_signing && secrets.windows_csc_link || '' }}", + CSC_KEY_PASSWORD: "${{ inputs.windows_signing && secrets.windows_csc_key_password || '' }}", + CSC_IDENTITY_AUTO_DISCOVERY: "${{ inputs.windows_signing && 'true' || 'false' }}", }); const authenticode = windowsSteps.find( (step) => step.name === "Verify acceptance Authenticode signatures" ); - expect(authenticode?.if).toBe("inputs.signed"); + expect(authenticode?.if).toBe("inputs.windows_signing"); expect(authenticode?.run).toContain("Get-AuthenticodeSignature"); expect(authenticode?.run).toContain("release/desktop/latest.yml"); expect(windowsTypecheck?.run).toContain("pnpm ci:typecheck"); @@ -214,7 +216,18 @@ describe("GitHub workflow boundaries", () => { ); const release = publishSteps.find((step) => step.name === "Publish tag-pinned prerelease"); - expect(workflow.on).toEqual({ workflow_dispatch: null }); + expect(workflow.on).toEqual({ + workflow_dispatch: { + inputs: { + windows_signing: { + description: "Sign Windows executables with Authenticode", + required: true, + default: true, + type: "boolean", + }, + }, + }, + }); expect(workflow.permissions).toEqual({ contents: "read" }); expect(Object.keys(workflow.jobs)).toEqual([ "prepare", @@ -270,6 +283,7 @@ describe("GitHub workflow boundaries", () => { uses: "./.github/workflows/desktop-verify.yml", with: { signed: true, + windows_signing: "${{ inputs.windows_signing }}", signing_key_artifact: "${{ needs.prepare.outputs.signing_key_artifact }}", runtime_update_url: "${{ needs.prepare.outputs.runtime_update_url }}", release_tag: "${{ needs.prepare.outputs.release_tag }}", @@ -365,6 +379,11 @@ describe("GitHub workflow boundaries", () => { const releaseInputs = (release.on.workflow_dispatch as { inputs: Record }) .inputs; expect(releaseInputs).not.toHaveProperty("mode"); + expect(releaseInputs.windows_signing).toMatchObject({ + default: true, + required: true, + type: "boolean", + }); expect(release.jobs.prepare.outputs).toMatchObject({ published_at: "${{ steps.release.outputs.published_at }}", release_kind: "${{ steps.release.outputs.release_kind }}", @@ -395,6 +414,9 @@ describe("GitHub workflow boundaries", () => { expect(windowsBuild.env?.CODER_STUDIO_FACTORY_RELEASE_BASE_URL).toBe( "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.tag }}/" ); + expect(windowsBuild.env?.CSC_IDENTITY_AUTO_DISCOVERY).toBe( + "${{ inputs.windows_signing && 'true' || 'false' }}" + ); expect(JSON.stringify(linuxBuild)).toContain("needs.prepare.outputs.release_kind"); expect(JSON.stringify(windowsBuild)).toContain("needs.prepare.outputs.release_kind"); expect(linuxBuild.env?.CODER_STUDIO_FACTORY_RELEASE_BASE_URL).toBeUndefined(); @@ -424,6 +446,7 @@ describe("GitHub workflow boundaries", () => { expect(releaseIndex).toBeGreaterThan(attestIndex); expect(publishSteps[productionValidateIndex]?.run).toContain("--release-kind"); expect(publishSteps[releaseIndex]?.run).toContain("--prerelease --latest=false"); + expect(publishSteps[releaseIndex]?.run).toContain("not Authenticode-signed"); }); it("gates Desktop and CLI promotion on immutable installed-upgrade reports", () => { @@ -443,6 +466,7 @@ describe("GitHub workflow boundaries", () => { expect(runInstalled?.run).toContain("pnpm acceptance:desktop:installed"); expect(runInstalled?.run).toContain("-CandidateInstaller"); expect(runInstalled?.run).toContain("-PublicKeyPath"); + expect(runInstalled?.run).toContain("-SkipAuthenticode"); expect(prepareScenario?.run).toContain("'runtime:win32-x64'"); expect(prepareScenario?.run).toContain("'wsl-combined'"); expect(runInstalled?.run).toContain("@('fresh-wsl', 'wsl', 'wsl-combined')"); @@ -454,6 +478,10 @@ describe("GitHub workflow boundaries", () => { const releaseInstalled = release.jobs["installed-upgrade"]; const promotion = release.jobs.promote; expect(releaseInstalled.needs).toEqual(["prepare", "publish"]); + const releaseRunInstalled = (releaseInstalled.steps ?? []).find( + (step) => step.name === "Run production installed Desktop update" + ); + expect(releaseRunInstalled?.run).toContain("-SkipAuthenticode"); expect(promotion.needs).toEqual(["prepare", "publish", "installed-upgrade"]); const promotionSteps = promotion.steps ?? []; const validateReports = promotionSteps.find( diff --git a/scripts/verify-desktop-installed-update.ps1 b/scripts/verify-desktop-installed-update.ps1 index 6a375275..d5f15dbe 100644 --- a/scripts/verify-desktop-installed-update.ps1 +++ b/scripts/verify-desktop-installed-update.ps1 @@ -29,6 +29,7 @@ param( [string]$CommitSha = '', [string]$ChannelSignatureDigest = '', [string]$WslDistro = '', + [switch]$SkipAuthenticode, [switch]$KeepOnFailure ) @@ -189,8 +190,10 @@ function Read-JournalIdentity([string]$Path) { $previousInstallerPath = Resolve-RequiredFile $PreviousInstaller 'Previous installer' $candidateInstallerPath = Resolve-RequiredFile $CandidateInstaller 'Candidate installer' $publicKeyFile = Resolve-RequiredFile $PublicKeyPath 'Desktop acceptance public key' -Assert-Authenticode $previousInstallerPath 'Previous installer' -Assert-Authenticode $candidateInstallerPath 'Candidate installer' +if (-not $SkipAuthenticode) { + Assert-Authenticode $previousInstallerPath 'Previous installer' + Assert-Authenticode $candidateInstallerPath 'Candidate installer' +} $isFreshInstall = $Scenario -in @('fresh-native', 'fresh-wsl') $isWslScenario = $Scenario -in @('fresh-wsl', 'wsl', 'wsl-combined') @@ -222,7 +225,9 @@ try { if (-not (Test-Path -LiteralPath $desktopExecutable -PathType Leaf)) { throw "Installed Desktop executable is missing: $desktopExecutable" } - Assert-Authenticode $desktopExecutable 'Installed Desktop executable' + if (-not $SkipAuthenticode) { + Assert-Authenticode $desktopExecutable 'Installed Desktop executable' + } if ($isWslScenario) { if (-not $WslDistro.StartsWith('coder-studio-acceptance-', [StringComparison]::Ordinal)) { diff --git a/scripts/verify-desktop-installed-update.test.ts b/scripts/verify-desktop-installed-update.test.ts index 81cc7470..4475c114 100644 --- a/scripts/verify-desktop-installed-update.test.ts +++ b/scripts/verify-desktop-installed-update.test.ts @@ -79,6 +79,8 @@ describe("verify-desktop-installed-update", () => { expect(runner).toContain("'runtime:win32-x64'"); expect(runner).toContain("CODER_STUDIO_FACTORY_RELEASE_BASE_URL"); expect(runner).toContain("$Scenario -eq 'wsl-combined'"); + expect(runner).toContain("[switch]$SkipAuthenticode"); + expect(runner).toContain("if (-not $SkipAuthenticode)"); expect(runner).toContain("$report.logPaths = @($preservedPaths)"); expect(runner.indexOf("Preserve-AcceptanceEvidence")).toBeLessThan( runner.lastIndexOf("Remove-Item -LiteralPath $runRoot -Recurse") From 2caa606900964d064a4dec3b8344af4388ac73dc Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 18:27:21 +0800 Subject: [PATCH 04/14] fix: preserve canonical acceptance timestamps --- .github/workflows/desktop-acceptance.yml | 5 ++++- scripts/github-workflows.test.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index 4c247e99..a7703ea3 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -284,7 +284,10 @@ jobs: $scenario = '${{ matrix.scenario }}' $hasPreviousDesktop = '${{ needs.prepare.outputs.has_previous_desktop }}' -eq 'true' $originalChannel = Get-Content (Join-Path $candidate 'desktop-channel.json') -Raw | ConvertFrom-Json - $generatedAt = $originalChannel.generatedAt + $generatedAt = ([DateTimeOffset]$originalChannel.generatedAt).UtcDateTime.ToString( + 'yyyy-MM-ddTHH:mm:ss.fffZ', + [Globalization.CultureInfo]::InvariantCulture + ) $scenarioTag = "${{ needs.prepare.outputs.release_tag }}-$scenario" if ($hasPreviousDesktop -and $scenario -notin @('combined', 'wsl-combined')) { pnpm desktop:channel -- --directory $candidate --carry-forward-from $previous diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 930aac77..b25aa9d8 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -469,6 +469,8 @@ describe("GitHub workflow boundaries", () => { expect(runInstalled?.run).toContain("-SkipAuthenticode"); expect(prepareScenario?.run).toContain("'runtime:win32-x64'"); expect(prepareScenario?.run).toContain("'wsl-combined'"); + expect(prepareScenario?.run).toContain("yyyy-MM-ddTHH:mm:ss.fffZ"); + expect(prepareScenario?.run).toContain("InvariantCulture"); expect(runInstalled?.run).toContain("@('fresh-wsl', 'wsl', 'wsl-combined')"); expect(installedSteps.some((step) => step.name === "Upload installed-upgrade report")).toBe( true From 8805022d6411e1893abfe3d724c21383e910c218 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 18:47:33 +0800 Subject: [PATCH 05/14] fix: invoke acceptance artifact validation --- .github/workflows/desktop-acceptance.yml | 2 +- scripts/github-workflows.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index a7703ea3..3feb9c69 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -302,7 +302,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Unable to build scenario Desktop channel' } $env:CODER_STUDIO_RUNTIME_PUBLIC_KEY = Get-Content release/desktop-ci-signing/runtime-public.pem -Raw $validation = @( - 'desktop:artifacts', '--', 'validate', + 'desktop:artifacts', 'validate', '--directory', $candidate, '--components', 'desktop,win-runtime,wsl-engine,wsl-runtime' ) diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index b25aa9d8..bf1072c7 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -471,6 +471,8 @@ describe("GitHub workflow boundaries", () => { expect(prepareScenario?.run).toContain("'wsl-combined'"); expect(prepareScenario?.run).toContain("yyyy-MM-ddTHH:mm:ss.fffZ"); expect(prepareScenario?.run).toContain("InvariantCulture"); + expect(prepareScenario?.run).toContain("'desktop:artifacts', 'validate'"); + expect(prepareScenario?.run).not.toContain("'desktop:artifacts', '--', 'validate'"); expect(runInstalled?.run).toContain("@('fresh-wsl', 'wsl', 'wsl-combined')"); expect(installedSteps.some((step) => step.name === "Upload installed-upgrade report")).toBe( true From a21a165c20d0ccb6f24c491eb964646c9b5da89a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 19:19:31 +0800 Subject: [PATCH 06/14] fix: allow empty installed acceptance components --- .github/workflows/desktop-acceptance.yml | 4 +++- .github/workflows/desktop-release.yml | 4 +++- scripts/github-workflows.test.ts | 2 ++ scripts/verify-desktop-installed-update.ps1 | 6 ++++-- scripts/verify-desktop-installed-update.test.ts | 2 ++ scripts/verify-desktop-installed-update.ts | 3 +-- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index 3feb9c69..794bf847 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -389,13 +389,15 @@ jobs: '-PreviousRuntimeVersion', '${{ steps.scenario.outputs.previous_runtime }}', '-ExpectedShellVersion', '${{ steps.scenario.outputs.target_shell }}', '-ExpectedRuntimeVersion', '${{ steps.scenario.outputs.target_runtime }}', - '-ExpectedComponents', '${{ steps.scenario.outputs.components }}', '-PublicKeyPath', 'release/desktop-ci-signing/runtime-public.pem', '-Scenario', '${{ matrix.scenario }}', '-ReportPath', 'release/desktop-installed-report/${{ matrix.scenario }}.json', '-CommitSha', '${{ github.sha }}', '-ChannelSignatureDigest', '${{ steps.scenario.outputs.signature_digest }}' ) + if ('${{ steps.scenario.outputs.components }}') { + $arguments += @('-ExpectedComponents', '${{ steps.scenario.outputs.components }}') + } if ('${{ matrix.scenario }}' -eq 'runtime-health-rollback') { $arguments += @('-ExpectedRuntimeAfterRestart', '${{ steps.scenario.outputs.previous_runtime }}') } diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d9d1f51f..c9312aca 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -558,13 +558,15 @@ jobs: '-PreviousRuntimeVersion', '${{ steps.identity.outputs.previous_runtime }}', '-ExpectedShellVersion', '${{ steps.identity.outputs.target_shell }}', '-ExpectedRuntimeVersion', '${{ steps.identity.outputs.target_runtime }}', - '-ExpectedComponents', '${{ steps.identity.outputs.components }}', '-PublicKeyPath', 'release/desktop-production-key/runtime-public.pem', '-Scenario', '${{ steps.identity.outputs.scenario }}', '-ReportPath', 'release/desktop-production-report/${{ matrix.target }}.json', '-CommitSha', '${{ github.sha }}', '-ChannelSignatureDigest', '${{ steps.identity.outputs.signature_digest }}' ) + if ('${{ steps.identity.outputs.components }}') { + $arguments += @('-ExpectedComponents', '${{ steps.identity.outputs.components }}') + } if ('${{ matrix.target }}' -eq 'wsl') { $arguments += @('-WslDistro', '${{ steps.wsl.outputs.name }}') } diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index bf1072c7..1ecf43e1 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -466,6 +466,7 @@ describe("GitHub workflow boundaries", () => { expect(runInstalled?.run).toContain("pnpm acceptance:desktop:installed"); expect(runInstalled?.run).toContain("-CandidateInstaller"); expect(runInstalled?.run).toContain("-PublicKeyPath"); + expect(runInstalled?.run).toContain("if ('${{ steps.scenario.outputs.components }}')"); expect(runInstalled?.run).toContain("-SkipAuthenticode"); expect(prepareScenario?.run).toContain("'runtime:win32-x64'"); expect(prepareScenario?.run).toContain("'wsl-combined'"); @@ -485,6 +486,7 @@ describe("GitHub workflow boundaries", () => { const releaseRunInstalled = (releaseInstalled.steps ?? []).find( (step) => step.name === "Run production installed Desktop update" ); + expect(releaseRunInstalled?.run).toContain("if ('${{ steps.identity.outputs.components }}')"); expect(releaseRunInstalled?.run).toContain("-SkipAuthenticode"); expect(promotion.needs).toEqual(["prepare", "publish", "installed-upgrade"]); const promotionSteps = promotion.steps ?? []; diff --git a/scripts/verify-desktop-installed-update.ps1 b/scripts/verify-desktop-installed-update.ps1 index d5f15dbe..7e632a20 100644 --- a/scripts/verify-desktop-installed-update.ps1 +++ b/scripts/verify-desktop-installed-update.ps1 @@ -8,7 +8,7 @@ param( [Parameter(Mandatory = $true)][string]$PreviousRuntimeVersion, [Parameter(Mandatory = $true)][string]$ExpectedShellVersion, [Parameter(Mandatory = $true)][string]$ExpectedRuntimeVersion, - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ExpectedComponents, + [string]$ExpectedComponents = '', [Parameter(Mandatory = $true)][string]$PublicKeyPath, [Parameter(Mandatory = $true)] [ValidateSet( @@ -279,7 +279,6 @@ try { 'exec', 'tsx', 'scripts/verify-desktop-installed-update.ts', '--cdp-url', "http://127.0.0.1:$cdpPort", '--scenario', $Scenario, - '--components', $ExpectedComponents, '--previous-shell-version', $PreviousShellVersion, '--previous-runtime-version', $PreviousRuntimeVersion, '--target-shell-version', $ExpectedShellVersion, @@ -289,6 +288,9 @@ try { '--user-data-dir', $userDataDirectory, '--release-tag', $CandidateTag ) + if ($ExpectedComponents) { + $driverArgs += @('--components', $ExpectedComponents) + } if ($ExpectedRuntimeAfterRestart) { $driverArgs += @('--expected-runtime-after', $ExpectedRuntimeAfterRestart) } diff --git a/scripts/verify-desktop-installed-update.test.ts b/scripts/verify-desktop-installed-update.test.ts index 4475c114..83a5e663 100644 --- a/scripts/verify-desktop-installed-update.test.ts +++ b/scripts/verify-desktop-installed-update.test.ts @@ -75,6 +75,8 @@ describe("verify-desktop-installed-update", () => { ); expect(runner).toContain("function Preserve-AcceptanceEvidence"); + expect(runner).toContain("[string]$ExpectedComponents = ''"); + expect(runner).toContain("if ($ExpectedComponents)"); expect(runner).toContain("$initialScenario"); expect(runner).toContain("'runtime:win32-x64'"); expect(runner).toContain("CODER_STUDIO_FACTORY_RELEASE_BASE_URL"); diff --git a/scripts/verify-desktop-installed-update.ts b/scripts/verify-desktop-installed-update.ts index 3aa23175..4556262c 100644 --- a/scripts/verify-desktop-installed-update.ts +++ b/scripts/verify-desktop-installed-update.ts @@ -581,7 +581,6 @@ async function main(): Promise { const required = [ "cdp-url", "scenario", - "components", "previous-shell-version", "previous-runtime-version", "target-shell-version", @@ -590,7 +589,7 @@ async function main(): Promise { for (const key of required) if (!values.get(key)) throw new Error(`--${key} is required`); const scenario: InstalledDesktopScenario = { name: values.get("scenario") as InstalledDesktopScenarioName, - expectedComponentIds: parseList(values.get("components") as string), + expectedComponentIds: parseList(values.get("components") ?? ""), previousShellVersion: values.get("previous-shell-version") as string, previousRuntimeVersion: values.get("previous-runtime-version") as string, targetShellVersion: values.get("target-shell-version") as string, From 2fae0575d22f8bf4d193b30202918b6f267303b8 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 19:48:57 +0800 Subject: [PATCH 07/14] fix: invoke installed acceptance arguments --- .github/workflows/desktop-acceptance.yml | 2 +- .github/workflows/desktop-release.yml | 2 +- scripts/github-workflows.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index 794bf847..b0cc285c 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -407,7 +407,7 @@ jobs: if ('${{ inputs.windows_signing }}' -ne 'true') { $arguments += '-SkipAuthenticode' } - pnpm acceptance:desktop:installed -- @arguments + pnpm acceptance:desktop:installed @arguments if ($LASTEXITCODE -ne 0) { throw 'Installed Desktop acceptance failed' } - name: Upload installed-upgrade report diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index c9312aca..2ed8b9f3 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -573,7 +573,7 @@ jobs: if ('${{ inputs.windows_signing }}' -ne 'true') { $arguments += '-SkipAuthenticode' } - pnpm acceptance:desktop:installed -- @arguments + pnpm acceptance:desktop:installed @arguments if ($LASTEXITCODE -ne 0) { throw 'Production installed Desktop acceptance failed' } - name: Upload production installed-upgrade report diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 1ecf43e1..7cbad207 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -464,6 +464,7 @@ describe("GitHub workflow boundaries", () => { "${{ fromJSON(needs.prepare.outputs.acceptance_scenarios) }}" ); expect(runInstalled?.run).toContain("pnpm acceptance:desktop:installed"); + expect(runInstalled?.run).not.toContain("pnpm acceptance:desktop:installed --"); expect(runInstalled?.run).toContain("-CandidateInstaller"); expect(runInstalled?.run).toContain("-PublicKeyPath"); expect(runInstalled?.run).toContain("if ('${{ steps.scenario.outputs.components }}')"); @@ -486,6 +487,7 @@ describe("GitHub workflow boundaries", () => { const releaseRunInstalled = (releaseInstalled.steps ?? []).find( (step) => step.name === "Run production installed Desktop update" ); + expect(releaseRunInstalled?.run).not.toContain("pnpm acceptance:desktop:installed --"); expect(releaseRunInstalled?.run).toContain("if ('${{ steps.identity.outputs.components }}')"); expect(releaseRunInstalled?.run).toContain("-SkipAuthenticode"); expect(promotion.needs).toEqual(["prepare", "publish", "installed-upgrade"]); From 9290ab328ee51d1872ebc76b60eb520afac2fa2d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 20:07:57 +0800 Subject: [PATCH 08/14] fix: read packaged factory runtime manifest --- scripts/verify-desktop-installed-update.ps1 | 2 +- scripts/verify-desktop-installed-update.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/verify-desktop-installed-update.ps1 b/scripts/verify-desktop-installed-update.ps1 index 7e632a20..3e72b0e3 100644 --- a/scripts/verify-desktop-installed-update.ps1 +++ b/scripts/verify-desktop-installed-update.ps1 @@ -249,7 +249,7 @@ try { $factoryRuntime = Join-Path $installDirectory 'resources/factory-runtime' $factoryEvidence = Join-Path $userDataDirectory 'factory-runtime' New-Item -ItemType Directory -Path $factoryEvidence -Force | Out-Null - Copy-Item -LiteralPath (Join-Path $factoryRuntime 'runtime.manifest.json') -Destination (Join-Path $factoryEvidence 'runtime.manifest.json') -Force + Copy-Item -LiteralPath (Join-Path $factoryRuntime 'manifest.json') -Destination (Join-Path $factoryEvidence 'runtime.manifest.json') -Force } if ($Scenario -eq 'runtime-health-rollback') { $env:CODER_STUDIO_DESKTOP_FAIL_RUNTIME_VERSION = $ExpectedRuntimeVersion diff --git a/scripts/verify-desktop-installed-update.test.ts b/scripts/verify-desktop-installed-update.test.ts index 83a5e663..9b4253f1 100644 --- a/scripts/verify-desktop-installed-update.test.ts +++ b/scripts/verify-desktop-installed-update.test.ts @@ -80,6 +80,8 @@ describe("verify-desktop-installed-update", () => { expect(runner).toContain("$initialScenario"); expect(runner).toContain("'runtime:win32-x64'"); expect(runner).toContain("CODER_STUDIO_FACTORY_RELEASE_BASE_URL"); + expect(runner).toContain("Join-Path $factoryRuntime 'manifest.json'"); + expect(runner).not.toContain("Join-Path $factoryRuntime 'runtime.manifest.json'"); expect(runner).toContain("$Scenario -eq 'wsl-combined'"); expect(runner).toContain("[switch]$SkipAuthenticode"); expect(runner).toContain("if (-not $SkipAuthenticode)"); From fbe671ec668671b5ac89632008c602bdab7b1c56 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Wed, 12 Aug 2026 23:25:10 +0800 Subject: [PATCH 09/14] fix: await installed acceptance driver exit (#98) * fix: await installed acceptance driver exit * test: preserve installed desktop startup logs * fix(ci): retain installed driver process handle * fix(ci): stabilize disposable WSL acceptance * fix(ci): capture installed Desktop startup failures * fix(ci): trace WSL acceptance bootstrap * fix(ci): allow first WSL bootstrap to complete * fix(desktop): isolate WSL shell probe output * fix(desktop): force-stop stalled WSL shell probes * fix(ci): test WSL acceptance as regular user * fix(ci): isolate host-managed WSL acceptance probe * fix(ci): allow verified WSL bootstrap to finish --- .github/workflows/desktop-acceptance.yml | 4 ++ .github/workflows/desktop-release.yml | 4 ++ packages/desktop/src/main.ts | 14 ++++- packages/desktop/src/wsl-discovery.test.ts | 39 +++++++++++- packages/desktop/src/wsl-discovery.ts | 34 ++++++++--- scripts/github-workflows.test.ts | 14 +++++ scripts/verify-desktop-installed-update.ps1 | 61 ++++++++++++++++--- .../verify-desktop-installed-update.test.ts | 25 ++++++++ 8 files changed, 174 insertions(+), 21 deletions(-) diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index b0cc285c..bf0f6435 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -375,6 +375,10 @@ jobs: wsl.exe --export Ubuntu "${env:RUNNER_TEMP}\ubuntu-rootfs.tar" wsl.exe --import $distro "${env:RUNNER_TEMP}\$distro" "${env:RUNNER_TEMP}\ubuntu-rootfs.tar" --version 2 if ($LASTEXITCODE -ne 0) { throw 'Unable to create disposable WSL acceptance distribution' } + wsl.exe -d $distro -u root -- sh -lc "useradd --create-home --shell /bin/bash coderstudio && printf '[boot]\nsystemd=false\n[user]\ndefault=coderstudio\n' > /etc/wsl.conf" + if ($LASTEXITCODE -ne 0) { throw 'Unable to configure the disposable WSL acceptance user' } + wsl.exe --terminate $distro + if ($LASTEXITCODE -ne 0) { throw 'Unable to restart disposable WSL acceptance distribution' } "name=$distro" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Run installed Desktop update scenario diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 2ed8b9f3..52ff5dcc 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -544,6 +544,10 @@ jobs: wsl.exe --export Ubuntu "${env:RUNNER_TEMP}\ubuntu-rootfs.tar" wsl.exe --import $distro "${env:RUNNER_TEMP}\$distro" "${env:RUNNER_TEMP}\ubuntu-rootfs.tar" --version 2 if ($LASTEXITCODE -ne 0) { throw 'Unable to create disposable production WSL distribution' } + wsl.exe -d $distro -u root -- sh -lc "useradd --create-home --shell /bin/bash coderstudio && printf '[boot]\nsystemd=false\n[user]\ndefault=coderstudio\n' > /etc/wsl.conf" + if ($LASTEXITCODE -ne 0) { throw 'Unable to configure the disposable production WSL user' } + wsl.exe --terminate $distro + if ($LASTEXITCODE -ne 0) { throw 'Unable to restart disposable production WSL distribution' } "name=$distro" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Run production installed Desktop update diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 50e3a4f8..255f7580 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -314,6 +314,7 @@ function registerIpcHandlers(rootUserDataDir: string): void { async function handleStartupFailure(error: unknown): Promise { const details = error instanceof Error ? error.stack || error.message : String(error); + console.error("Unable to start Coder Studio", details); await environmentActivation.failPending(details).catch(() => undefined); if (smokeResultPath) { await finishSmokeTest( @@ -617,7 +618,9 @@ async function startApplication(): Promise { : undefined; environmentManager = new DesktopEnvironmentManager({ stateStore: environmentStateStore, - discovery: new WslDiscovery(), + discovery: new WslDiscovery({ + probeUserShell: process.env.CODER_STUDIO_DESKTOP_ACCEPTANCE !== "1", + }), shellVersion: app.getVersion(), nodeVersion: DESKTOP_NODE_VERSION, runtimeVersion: webRuntime?.manifest.runtimeVersion ?? productVersion, @@ -632,7 +635,14 @@ async function startApplication(): Promise { undefined, loadChannel: desktopChannelUrl && runtimePublicKey ? loadDesktopChannel : undefined, nativeRuntimeUpdateAdapter, - onProgress: emitEnvironmentProgress, + onProgress: (progress) => { + emitEnvironmentProgress(progress); + if (process.env.CODER_STUDIO_DESKTOP_ACCEPTANCE === "1") { + console.error( + `[desktop-acceptance:environment] ${new Date().toISOString()} ${JSON.stringify(progress)}` + ); + } + }, }); environmentManager.setActiveTarget(activeEnvironmentTarget); activeSession = session.fromPartition(getEnvironmentPartition(activeEnvironmentTarget)); diff --git a/packages/desktop/src/wsl-discovery.test.ts b/packages/desktop/src/wsl-discovery.test.ts index 82dc1c4c..f997430e 100644 --- a/packages/desktop/src/wsl-discovery.test.ts +++ b/packages/desktop/src/wsl-discovery.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { WslCommandRunner } from "./wsl-command.js"; -import { WslDiscovery } from "./wsl-discovery.js"; +import { createWslProbeScript, WSL_PROBE_SCRIPT, WslDiscovery } from "./wsl-discovery.js"; function result(stdout: string | Buffer, exitCode = 0) { return { @@ -11,6 +11,43 @@ function result(stdout: string | Buffer, exitCode = 0) { } describe("WslDiscovery", () => { + it("isolates interactive shell probe output from inherited WSL pipes", () => { + expect(WSL_PROBE_SCRIPT).toContain('probe_file="/tmp/coder-studio-shell-probe-$$"'); + expect(WSL_PROBE_SCRIPT).toContain("/usr/bin/timeout --kill-after=1s 5s"); + expect(WSL_PROBE_SCRIPT).toContain('>"$probe_file" 2>/dev/null'); + expect(WSL_PROBE_SCRIPT).toContain('cat "$probe_file"'); + expect(WSL_PROBE_SCRIPT).toContain('rm -f "$probe_file"'); + }); + + it("can skip the optional user shell probe for host-managed acceptance", async () => { + const runner = vi + .fn() + .mockResolvedValue( + result( + [ + "/home/coderstudio", + "/home/coderstudio/.local/share/coder-studio-desktop", + "x86_64", + "6.6.87.2-microsoft-standard-WSL2", + "glibc 2.39", + "false", + "false", + "", + ].join("\n") + ) + ); + const discovery = new WslDiscovery({ + runner, + platform: "win32", + probeUserShell: false, + }); + + await expect(discovery.probe("Acceptance")).resolves.toMatchObject({ supported: true }); + const script = runner.mock.calls[0]?.[0].at(-1); + expect(script).toBe(createWslProbeScript(false)); + expect(script).not.toContain("__CODER_STUDIO_USER_PATH__"); + }); + it("decodes Windows UTF-16 distro output", async () => { const runner = vi .fn() diff --git a/packages/desktop/src/wsl-discovery.ts b/packages/desktop/src/wsl-discovery.ts index dec2d18e..5c36e4aa 100644 --- a/packages/desktop/src/wsl-discovery.ts +++ b/packages/desktop/src/wsl-discovery.ts @@ -25,7 +25,7 @@ export interface WslDistroProbe { message?: string; } -const PROBE_SCRIPT = [ +const BASE_PROBE_SCRIPT = [ "set -eu", "home=${HOME:?}", 'data_root=${XDG_DATA_HOME:-"$home/.local/share"}/coder-studio-desktop', @@ -39,13 +39,21 @@ const PROBE_SCRIPT = [ 'if test -f "$data_root/runtime-store/active.json"; then runtime_installed=true; fi', 'printf "%s\\n%s\\n%s\\n%s\\n%s\\n" "$home" "$data_root" "$arch" "$kernel" "$libc"', 'printf "%s\\n%s\\n" "$engine_installed" "$runtime_installed"', - // Agent CLIs are spawned directly by the Server rather than through a shell. Capture the - // interactive shell PATH so tools managed by fnm/nvm/asdf/etc. can be inherited as well. - // The same shell reports its own npm, which decides whether the Server defers to the user's - // Node toolchain or falls back to the bundled Engine npm. - // Bound rc-file execution so a broken interactive shell cannot block Desktop startup. - 'if test -x /usr/bin/timeout; then shell=${SHELL:-/bin/sh}; if test -x "$shell"; then /usr/bin/timeout 5s "$shell" -ic \'printf "\\n__CODER_STUDIO_USER_PATH__%s\\n" "$PATH"; printf "__CODER_STUDIO_USER_NPM__%s\\n" "$(command -v npm 2>/dev/null || true)"\' 2>/dev/null || true; fi; fi', -].join("; "); +]; + +// Agent CLIs are spawned directly by the Server rather than through a shell. Capture the +// interactive shell PATH so tools managed by fnm/nvm/asdf/etc. can be inherited as well. +// The same shell reports its own npm, which decides whether the Server defers to the user's +// Node toolchain or falls back to the bundled Engine npm. Bound rc-file execution so a broken +// interactive shell cannot block Desktop startup. +const USER_SHELL_PROBE_SCRIPT = + 'if test -x /usr/bin/timeout; then shell=${SHELL:-/bin/sh}; if test -x "$shell"; then probe_file="/tmp/coder-studio-shell-probe-$$"; /usr/bin/timeout --kill-after=1s 5s "$shell" -ic \'printf "\\n__CODER_STUDIO_USER_PATH__%s\\n" "$PATH"; printf "__CODER_STUDIO_USER_NPM__%s\\n" "$(command -v npm 2>/dev/null || true)"\' >"$probe_file" 2>/dev/null || true; cat "$probe_file" 2>/dev/null || true; rm -f "$probe_file"; fi; fi'; + +export function createWslProbeScript(probeUserShell = true): string { + return [...BASE_PROBE_SCRIPT, ...(probeUserShell ? [USER_SHELL_PROBE_SCRIPT] : [])].join("; "); +} + +export const WSL_PROBE_SCRIPT = createWslProbeScript(); const USER_PATH_MARKER = "__CODER_STUDIO_USER_PATH__"; const USER_NPM_MARKER = "__CODER_STUDIO_USER_NPM__"; @@ -74,6 +82,7 @@ export class WslDiscovery { private readonly options: { runner?: WslCommandRunner; platform?: NodeJS.Platform; + probeUserShell?: boolean; } = {} ) {} @@ -93,7 +102,14 @@ export class WslDiscovery { async probe(distro: string): Promise { const result = await runWslCommandChecked( - ["--distribution", distro, "--exec", "/bin/sh", "-c", PROBE_SCRIPT], + [ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + createWslProbeScript(this.options.probeUserShell ?? true), + ], undefined, this.options.runner ?? runWslCommand ); diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 7cbad207..98cddfc2 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -459,6 +459,9 @@ describe("GitHub workflow boundaries", () => { const prepareScenario = installedSteps.find( (step) => step.name === "Prepare scenario-specific signed channel" ); + const prepareWsl = installedSteps.find( + (step) => step.name === "Prepare disposable WSL distribution" + ); expect(installed.needs).toEqual(["prepare", "publish"]); expect(installed.strategy?.matrix?.scenario).toBe( "${{ fromJSON(needs.prepare.outputs.acceptance_scenarios) }}" @@ -476,6 +479,10 @@ describe("GitHub workflow boundaries", () => { expect(prepareScenario?.run).toContain("'desktop:artifacts', 'validate'"); expect(prepareScenario?.run).not.toContain("'desktop:artifacts', '--', 'validate'"); expect(runInstalled?.run).toContain("@('fresh-wsl', 'wsl', 'wsl-combined')"); + expect(prepareWsl?.run).toContain("systemd=false"); + expect(prepareWsl?.run).toContain("useradd --create-home --shell /bin/bash coderstudio"); + expect(prepareWsl?.run).toContain("default=coderstudio"); + expect(prepareWsl?.run).toContain("wsl.exe --terminate $distro"); expect(installedSteps.some((step) => step.name === "Upload installed-upgrade report")).toBe( true ); @@ -487,9 +494,16 @@ describe("GitHub workflow boundaries", () => { const releaseRunInstalled = (releaseInstalled.steps ?? []).find( (step) => step.name === "Run production installed Desktop update" ); + const releasePrepareWsl = (releaseInstalled.steps ?? []).find( + (step) => step.name === "Prepare disposable production WSL distribution" + ); expect(releaseRunInstalled?.run).not.toContain("pnpm acceptance:desktop:installed --"); expect(releaseRunInstalled?.run).toContain("if ('${{ steps.identity.outputs.components }}')"); expect(releaseRunInstalled?.run).toContain("-SkipAuthenticode"); + expect(releasePrepareWsl?.run).toContain("systemd=false"); + expect(releasePrepareWsl?.run).toContain("useradd --create-home --shell /bin/bash coderstudio"); + expect(releasePrepareWsl?.run).toContain("default=coderstudio"); + expect(releasePrepareWsl?.run).toContain("wsl.exe --terminate $distro"); expect(promotion.needs).toEqual(["prepare", "publish", "installed-upgrade"]); const promotionSteps = promotion.steps ?? []; const validateReports = promotionSteps.find( diff --git a/scripts/verify-desktop-installed-update.ps1 b/scripts/verify-desktop-installed-update.ps1 index 3e72b0e3..544630f2 100644 --- a/scripts/verify-desktop-installed-update.ps1 +++ b/scripts/verify-desktop-installed-update.ps1 @@ -98,7 +98,9 @@ function Start-AcceptanceDesktop( [string]$UserDataDirectory, [int]$CdpPort, [string]$ScenarioName, - [string]$Distro + [string]$Distro, + [string]$StandardOut, + [string]$StandardError ) { $arguments = @( "--remote-debugging-port=$CdpPort", @@ -111,7 +113,8 @@ function Start-AcceptanceDesktop( } else { $arguments += '--coder-studio-environment-target=native' } - return Start-Process -FilePath $Executable -ArgumentList $arguments -PassThru + return Start-Process -FilePath $Executable -ArgumentList $arguments -PassThru ` + -RedirectStandardOutput $StandardOut -RedirectStandardError $StandardError } function Write-JsonAtomic([string]$Path, [object]$Value) { @@ -132,7 +135,10 @@ function Preserve-AcceptanceEvidence( [string]$DriverStandardOut, [string]$DriverStandardError, [string]$UserDataDirectory, - [string]$JournalFile + [string]$JournalFile, + [string]$FailureFile, + [string]$DesktopStandardOut, + [string]$DesktopStandardError ) { $resolvedReport = [System.IO.Path]::GetFullPath($ReportFile) $reportDirectory = Split-Path -Parent $resolvedReport @@ -141,14 +147,22 @@ function Preserve-AcceptanceEvidence( New-Item -ItemType Directory -Path $evidenceDirectory -Force | Out-Null $sources = [System.Collections.Generic.List[string]]::new() - foreach ($source in @($DriverStandardOut, $DriverStandardError, $JournalFile)) { + foreach ($source in @( + $DriverStandardOut, + $DriverStandardError, + $JournalFile, + $FailureFile, + $DesktopStandardOut, + $DesktopStandardError + )) { if ($source -and (Test-Path -LiteralPath $source -PathType Leaf)) { $sources.Add([System.IO.Path]::GetFullPath($source)) } } if (Test-Path -LiteralPath $UserDataDirectory -PathType Container) { Get-ChildItem -LiteralPath $UserDataDirectory -Recurse -File | Where-Object { - $_.Name -match '(?i)(update|electron).*\.log$' + $_.Name -match '(?i)(update|electron).*\.log$' -or + $_.Name -in @('main.log', 'backend.log') } | ForEach-Object { $sources.Add($_.FullName) } @@ -204,6 +218,9 @@ $userDataDirectory = Join-Path $runRoot 'user-data' $controlPath = Join-Path $runRoot 'interruption-control.json' $driverOut = Join-Path $runRoot 'driver.stdout.log' $driverErr = Join-Path $runRoot 'driver.stderr.log' +$desktopOut = Join-Path $runRoot 'desktop.stdout.log' +$desktopErr = Join-Path $runRoot 'desktop.stderr.log' +$failurePath = Join-Path $runRoot 'acceptance.failure.log' $journalPath = Join-Path $userDataDirectory 'desktop-update-plan.json' $wslMarkerPath = "/tmp/$runId-npm-invoked" $desktopExecutable = Join-Path $installDirectory 'Coder Studio.exe' @@ -265,8 +282,12 @@ try { } else { $Scenario } - $desktopProcess = Start-AcceptanceDesktop $desktopExecutable $userDataDirectory $cdpPort $initialScenario $WslDistro - $pages = Wait-Cdp $cdpPort + $desktopProcess = Start-AcceptanceDesktop $desktopExecutable $userDataDirectory $cdpPort $initialScenario $WslDistro $desktopOut $desktopErr + $desktopProcess.Handle | Out-Null + # A fresh WSL launch downloads, verifies, and installs both Engine and Runtime before it + # creates a page. Keep native and subsequent restart checks at the stricter default. + $startupTimeoutSeconds = if ($isWslScenario) { 300 } else { 90 } + $pages = Wait-Cdp $cdpPort $startupTimeoutSeconds $sidecarUrl = '' foreach ($page in $pages) { if ($page.type -eq 'page' -and $page.url -match '^https?://') { @@ -308,6 +329,9 @@ try { } $driverProcess = Start-Process -FilePath 'pnpm.cmd' -ArgumentList $driverArgs -PassThru -NoNewWindow -RedirectStandardOutput $driverOut -RedirectStandardError $driverErr + # Windows PowerShell 5.1 can return a null ExitCode after redirected Start-Process + # output unless the process handle is opened before the process exits. + $driverProcess.Handle | Out-Null $handledInterruptions = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::Ordinal ) @@ -327,7 +351,8 @@ try { } else { $Scenario } - $desktopProcess = Start-AcceptanceDesktop $desktopExecutable $userDataDirectory $cdpPort $restartScenario $WslDistro + $desktopProcess = Start-AcceptanceDesktop $desktopExecutable $userDataDirectory $cdpPort $restartScenario $WslDistro $desktopOut $desktopErr + $desktopProcess.Handle | Out-Null Wait-Cdp $cdpPort | Out-Null $journalAfter = Read-JournalIdentity $journalPath Write-JsonAtomic $controlPath @{ @@ -345,6 +370,7 @@ try { Start-Sleep -Milliseconds 200 $driverProcess.Refresh() } + $driverProcess.WaitForExit() if ($driverProcess.ExitCode -ne 0) { $stdout = if (Test-Path -LiteralPath $driverOut) { Get-Content -LiteralPath $driverOut -Raw } else { '' } $stderr = if (Test-Path -LiteralPath $driverErr) { Get-Content -LiteralPath $driverErr -Raw } else { '' } @@ -354,6 +380,20 @@ try { throw 'Installed Desktop driver did not produce its JSON report' } $failed = $false +} catch { + $failureDetails = [System.Collections.Generic.List[string]]::new() + $failureDetails.Add(($_ | Out-String).TrimEnd()) + if ($null -ne $desktopProcess) { + try { + $desktopProcess.Refresh() + $failureDetails.Add("Desktop process id: $($desktopProcess.Id)") + $failureDetails.Add("Desktop process exited: $($desktopProcess.HasExited)") + } catch { + $failureDetails.Add("Unable to inspect Desktop process: $($_.Exception.Message)") + } + } + $failureDetails | Set-Content -LiteralPath $failurePath -Encoding UTF8 + throw } finally { Stop-AcceptanceDesktop $desktopExecutable $userDataDirectory Preserve-AcceptanceEvidence ` @@ -361,7 +401,10 @@ try { $driverOut ` $driverErr ` $userDataDirectory ` - $journalPath + $journalPath ` + $failurePath ` + $desktopOut ` + $desktopErr if (Test-Path -LiteralPath (Join-Path $installDirectory 'Uninstall Coder Studio.exe')) { Start-Process -FilePath (Join-Path $installDirectory 'Uninstall Coder Studio.exe') -ArgumentList '/S' -Wait -ErrorAction SilentlyContinue | Out-Null } diff --git a/scripts/verify-desktop-installed-update.test.ts b/scripts/verify-desktop-installed-update.test.ts index 9b4253f1..f9d0946c 100644 --- a/scripts/verify-desktop-installed-update.test.ts +++ b/scripts/verify-desktop-installed-update.test.ts @@ -73,6 +73,10 @@ describe("verify-desktop-installed-update", () => { resolve(import.meta.dirname, "verify-desktop-installed-update.ps1"), "utf8" ); + const desktopMain = await readFile( + resolve(import.meta.dirname, "../packages/desktop/src/main.ts"), + "utf8" + ); expect(runner).toContain("function Preserve-AcceptanceEvidence"); expect(runner).toContain("[string]$ExpectedComponents = ''"); @@ -82,6 +86,27 @@ describe("verify-desktop-installed-update", () => { expect(runner).toContain("CODER_STUDIO_FACTORY_RELEASE_BASE_URL"); expect(runner).toContain("Join-Path $factoryRuntime 'manifest.json'"); expect(runner).not.toContain("Join-Path $factoryRuntime 'runtime.manifest.json'"); + expect(runner).toContain("$driverProcess.Handle | Out-Null"); + expect(runner.indexOf("$driverProcess.Handle | Out-Null")).toBeLessThan( + runner.indexOf("while (-not $driverProcess.HasExited)") + ); + expect(runner).toContain("$driverProcess.WaitForExit()"); + expect(runner.indexOf("$driverProcess.WaitForExit()")).toBeLessThan( + runner.indexOf("if ($driverProcess.ExitCode -ne 0)") + ); + expect(runner).toContain("$_.Name -in @('main.log', 'backend.log')"); + expect(runner).toContain("'acceptance.failure.log'"); + expect(runner).toContain("$failureDetails | Set-Content"); + expect(runner).toContain("'desktop.stdout.log'"); + expect(runner).toContain("'desktop.stderr.log'"); + expect(runner).toContain("-RedirectStandardOutput $StandardOut"); + expect(runner).toContain("$startupTimeoutSeconds = if ($isWslScenario) { 300 } else { 90 }"); + expect(runner).toContain("Wait-Cdp $cdpPort $startupTimeoutSeconds"); + expect(desktopMain).toContain('console.error("Unable to start Coder Studio", details)'); + expect(desktopMain).toContain("[desktop-acceptance:environment]"); + expect(desktopMain).toContain( + 'probeUserShell: process.env.CODER_STUDIO_DESKTOP_ACCEPTANCE !== "1"' + ); expect(runner).toContain("$Scenario -eq 'wsl-combined'"); expect(runner).toContain("[switch]$SkipAuthenticode"); expect(runner).toContain("if (-not $SkipAuthenticode)"); From 37ee3deaef3658f3f84892d4f97d6b9de14cef67 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 13 Aug 2026 00:13:48 +0800 Subject: [PATCH 10/14] fix CLI subpath dependency validation (#99) --- scripts/publish-cli.test.ts | 15 ++++++++++++--- scripts/publish-cli.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/publish-cli.test.ts b/scripts/publish-cli.test.ts index 9b6d8d9e..0b452def 100644 --- a/scripts/publish-cli.test.ts +++ b/scripts/publish-cli.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { assertCliPublishArtifacts, @@ -89,7 +89,15 @@ describe("publish-cli", () => { await mkdir(join(cliDir, "dist", "esm"), { recursive: true }); await mkdir(join(cliDir, "dist", "web"), { recursive: true }); await writeFile(join(cliDir, "dist", "bin.js"), "#!/usr/bin/env node\n"); - await writeFile(join(cliDir, "dist", "esm", "bin.mjs"), "export {};\n"); + await writeFile( + join(cliDir, "dist", "esm", "bin.mjs"), + [ + 'import "vscode-jsonrpc/node.js";', + 'import "@xterm/addon-serialize/internal";', + "export {};", + "", + ].join("\n") + ); await writeFile(join(cliDir, "dist", "esm", "index.mjs"), "export {};\n"); await writeFile(join(cliDir, "dist", "esm", "server-runner.mjs"), "export {};\n"); await writeFile(join(cliDir, "dist", "web", "index.html"), "\n"); @@ -115,6 +123,7 @@ describe("publish-cli", () => { }, dependencies: { "@xterm/addon-serialize": "^0.14.0", + "vscode-jsonrpc": "^8.2.1", }, }) ); @@ -229,7 +238,7 @@ describe("publish-cli", () => { ).rejects.toThrow("Refusing to publish from a dirty git worktree"); expect(exec).toHaveBeenCalledWith("git", ["status", "--porcelain"], { - cwd: "/repo", + cwd: resolve("/repo"), stdio: "pipe", }); expect(exec).not.toHaveBeenCalledWith("pnpm", expect.any(Array), expect.any(Object)); diff --git a/scripts/publish-cli.ts b/scripts/publish-cli.ts index 988f1745..17c41a6a 100644 --- a/scripts/publish-cli.ts +++ b/scripts/publish-cli.ts @@ -335,7 +335,9 @@ function assertBundleRuntimeDependenciesDeclared( } const declaredDeps = new Set(Object.keys(dependencies as Record)); - const undeclared = bareImports.filter((specifier) => !declaredDeps.has(specifier)); + const undeclared = bareImports.filter( + (specifier) => !declaredDeps.has(getPackageName(specifier)) + ); if (undeclared.length > 0) { throw new Error( @@ -344,6 +346,11 @@ function assertBundleRuntimeDependenciesDeclared( } } +function getPackageName(specifier: string): string { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + function assertPublishDependenciesResolvable(dependencies: unknown, packageJsonPath: string): void { if (typeof dependencies !== "object" || dependencies === null) { throw new Error(`CLI package.json is missing dependencies in ${packageJsonPath}`); From e1e4d12d2e4bcbc4b9c27088aeb1bbaf59933c41 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 13 Aug 2026 00:46:09 +0800 Subject: [PATCH 11/14] fix CLI acceptance argument parsing (#100) --- scripts/verify-cli-update.test.ts | 37 ++++++++++++++++++++++++++++++- scripts/verify-cli-update.ts | 18 +++++++++------ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/scripts/verify-cli-update.test.ts b/scripts/verify-cli-update.test.ts index 4b95f6ba..70b6ddc8 100644 --- a/scripts/verify-cli-update.test.ts +++ b/scripts/verify-cli-update.test.ts @@ -1,6 +1,10 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { type VerifyCliUpdateDeps, verifyCliUpdate } from "./verify-cli-update.js"; +import { + parseVerifyCliUpdateArgs, + type VerifyCliUpdateDeps, + verifyCliUpdate, +} from "./verify-cli-update.js"; function createDeps(): VerifyCliUpdateDeps { return { @@ -76,6 +80,37 @@ function createDeps(): VerifyCliUpdateDeps { } describe("verify-cli-update", () => { + it("accepts pnpm's argument separator before workflow options", () => { + expect( + parseVerifyCliUpdateArgs([ + "--", + "--package-name", + "@spencer-kit/coder-studio", + "--previous-version", + "0.5.6", + "--candidate-version", + "0.5.7", + "--registry-url", + "https://registry.npmjs.org/", + "--dist-tag", + "coder-studio-accept-42", + "--commit-sha", + "0123456789abcdef", + "--report", + "release/report.json", + ]) + ).toEqual({ + packageName: "@spencer-kit/coder-studio", + previousVersion: "0.5.6", + candidateVersion: "0.5.7", + registryUrl: "https://registry.npmjs.org/", + distTag: "coder-studio-accept-42", + commitSha: "0123456789abcdef", + prefix: undefined, + reportPath: "release/report.json", + }); + }); + it("upgrades a packaged CLI inside one isolated npm prefix", async () => { const deps = createDeps(); const prefix = resolve("/tmp/coder-studio-cli-acceptance-42"); diff --git a/scripts/verify-cli-update.ts b/scripts/verify-cli-update.ts index 0f31acb0..da1cd09d 100644 --- a/scripts/verify-cli-update.ts +++ b/scripts/verify-cli-update.ts @@ -565,13 +565,13 @@ function readArg(argv: string[], index: number, option: string): string { return value; } -async function main(): Promise { +export function parseVerifyCliUpdateArgs(argv: string[]): VerifyCliUpdateOptions { const values = new Map(); - const args = process.argv.slice(2); - for (let index = 0; index < args.length; index += 1) { - const option = args[index]; + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + if (option === "--") continue; if (!option?.startsWith("--")) throw new Error(`Unknown CLI acceptance argument: ${option}`); - values.set(option.slice(2), readArg(args, ++index, option)); + values.set(option.slice(2), readArg(argv, ++index, option)); } const required = [ "package-name", @@ -581,7 +581,7 @@ async function main(): Promise { "dist-tag", ]; for (const name of required) if (!values.get(name)) throw new Error(`--${name} is required`); - const report = await verifyCliUpdate({ + return { packageName: values.get("package-name") as string, previousVersion: values.get("previous-version") as string, candidateVersion: values.get("candidate-version") as string, @@ -590,7 +590,11 @@ async function main(): Promise { commitSha: values.get("commit-sha"), prefix: values.get("prefix"), reportPath: values.get("report"), - }); + }; +} + +async function main(): Promise { + const report = await verifyCliUpdate(parseVerifyCliUpdateArgs(process.argv.slice(2))); success("Packaged CLI update acceptance passed"); console.log(JSON.stringify(report, null, 2)); } From 75fc454299a80ac3ea27c953bc13f60bec6c5243 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 13 Aug 2026 01:34:27 +0800 Subject: [PATCH 12/14] fix CLI update acceptance activation (#101) --- scripts/verify-cli-update.test.ts | 45 +++++++++++ scripts/verify-cli-update.ts | 125 ++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 5 deletions(-) diff --git a/scripts/verify-cli-update.test.ts b/scripts/verify-cli-update.test.ts index 70b6ddc8..349a4864 100644 --- a/scripts/verify-cli-update.test.ts +++ b/scripts/verify-cli-update.test.ts @@ -1,6 +1,8 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + type AcceptanceWebSocket, + callActivatedCoderStudioWsCommand, parseVerifyCliUpdateArgs, type VerifyCliUpdateDeps, verifyCliUpdate, @@ -80,6 +82,49 @@ function createDeps(): VerifyCliUpdateDeps { } describe("verify-cli-update", () => { + it("claims activation and runs the update command on the same websocket", async () => { + const sent: Array> = []; + let socket: AcceptanceWebSocket | undefined; + const command = callActivatedCoderStudioWsCommand<{ currentVersion: string }>( + { + apiUrl: "http://127.0.0.1:43123", + op: "updates.getState", + args: {}, + }, + () => { + socket = { + onopen: null, + onmessage: null, + onerror: null, + onclose: null, + send: (data) => sent.push(JSON.parse(data) as Record), + close: vi.fn(), + }; + queueMicrotask(() => socket?.onopen?.()); + return socket; + } + ); + + await vi.waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toMatchObject({ op: "activation.claim" }); + socket?.onmessage?.({ + data: JSON.stringify({ kind: "result", id: sent[0].id, ok: true, data: { active: true } }), + }); + await vi.waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toMatchObject({ op: "updates.getState", args: {} }); + socket?.onmessage?.({ + data: JSON.stringify({ + kind: "result", + id: sent[1].id, + ok: true, + data: { currentVersion: "0.5.7" }, + }), + }); + + await expect(command).resolves.toEqual({ currentVersion: "0.5.7" }); + expect(socket?.close).toHaveBeenCalledOnce(); + }); + it("accepts pnpm's argument separator before workflow options", () => { expect( parseVerifyCliUpdateArgs([ diff --git a/scripts/verify-cli-update.ts b/scripts/verify-cli-update.ts index da1cd09d..6586ea59 100644 --- a/scripts/verify-cli-update.ts +++ b/scripts/verify-cli-update.ts @@ -6,10 +6,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import type { UpdatePrepareInstallResponse, UpdateStateSnapshot } from "@coder-studio/core"; -import { - type CoderStudioWsCommandInput, - callCoderStudioWsCommand, -} from "../packages/cli/src/automation-ws-client.js"; +import type { CoderStudioWsCommandInput } from "../packages/cli/src/automation-ws-client.js"; import { error, success } from "./shared/index.js"; import { isDirectExecution } from "./shared/process.js"; @@ -65,6 +62,23 @@ interface CommandOptions { cwd?: string; } +export interface AcceptanceWebSocket { + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onerror: ((event: unknown) => void) | null; + onclose: (() => void) | null; + send(data: string): void; + close(): void; +} + +interface AcceptanceCommandResult { + kind: "result"; + id: string; + ok: boolean; + data?: unknown; + error?: { code?: string; message?: string }; +} + export interface VerifyCliUpdateDeps { command( executable: string, @@ -193,6 +207,107 @@ function acceptanceEnvironment(input: { }; } +function toAcceptanceWebSocketUrl(apiUrl: string): string { + const url = new URL(apiUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = `${url.pathname.replace(/\/$/u, "")}/ws`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export async function callActivatedCoderStudioWsCommand( + input: CoderStudioWsCommandInput, + createSocket: (url: string) => AcceptanceWebSocket = (url) => + new WebSocket(url) as unknown as AcceptanceWebSocket +): Promise { + const socket = createSocket(toAcceptanceWebSocketUrl(input.apiUrl)); + const claimId = randomUUID(); + const commandId = randomUUID(); + const timeoutMs = input.timeoutMs ?? 30_000; + + return new Promise((resolveCommand, rejectCommand) => { + let settled = false; + const timer = setTimeout(() => { + finish(() => rejectCommand(new Error(`Timed out waiting for ${input.op} result`))); + }, timeoutMs); + + function finish(callback: () => void): void { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.close(); + callback(); + } + + socket.onopen = () => { + socket.send( + JSON.stringify({ + kind: "command", + id: claimId, + op: "activation.claim", + args: { clientInstanceId: `cli-update-acceptance-${randomUUID()}` }, + }) + ); + }; + socket.onmessage = (event) => { + let result: AcceptanceCommandResult; + try { + result = JSON.parse(String(event.data)) as AcceptanceCommandResult; + } catch (parseError) { + finish(() => rejectCommand(parseError)); + return; + } + if (result.kind !== "result") return; + if (result.id === claimId) { + if (!result.ok) { + finish(() => + rejectCommand( + new Error( + `${result.error?.code ? `${result.error.code}: ` : ""}${result.error?.message ?? "Activation claim failed"}` + ) + ) + ); + return; + } + socket.send( + JSON.stringify({ + kind: "command", + id: commandId, + op: input.op, + args: input.args, + }) + ); + return; + } + if (result.id !== commandId) return; + if (result.ok) { + finish(() => resolveCommand(result.data as T)); + return; + } + finish(() => + rejectCommand( + new Error( + `${result.error?.code ? `${result.error.code}: ` : ""}${result.error?.message ?? "Command failed"}` + ) + ) + ); + }; + socket.onerror = (event) => { + finish(() => + rejectCommand(event instanceof Error ? event : new Error("CLI acceptance WebSocket failed")) + ); + }; + socket.onclose = () => { + if (!settled) { + finish(() => + rejectCommand(new Error("CLI acceptance WebSocket closed before command result")) + ); + } + }; + }); +} + async function writePosixShim(path: string, source: string): Promise { await writeFile(path, `#!/usr/bin/env sh\nset -eu\n${source}\n`, "utf8"); await chmod(path, 0o755); @@ -386,7 +501,7 @@ const defaultDeps: VerifyCliUpdateDeps = { }, }; }, - callWs: callCoderStudioWsCommand, + callWs: callActivatedCoderStudioWsCommand, waitForReconcile: async ({ apiUrl, candidateVersion, callWs }) => { const deadline = Date.now() + 120_000; let lastState: Partial = {}; From 73e8e8327be093508858cffd08ea32f5e14c092c Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 13 Aug 2026 02:23:43 +0800 Subject: [PATCH 13/14] fix CLI legacy update acceptance (#102) --- scripts/verify-cli-update.test.ts | 111 ++++++++++++++++++ scripts/verify-cli-update.ts | 188 +++++++++++++++++++++++++----- 2 files changed, 269 insertions(+), 30 deletions(-) diff --git a/scripts/verify-cli-update.test.ts b/scripts/verify-cli-update.test.ts index 349a4864..d4d825dd 100644 --- a/scripts/verify-cli-update.test.ts +++ b/scripts/verify-cli-update.test.ts @@ -4,6 +4,7 @@ import { type AcceptanceWebSocket, callActivatedCoderStudioWsCommand, parseVerifyCliUpdateArgs, + startCandidateRegistryProxy, type VerifyCliUpdateDeps, verifyCliUpdate, } from "./verify-cli-update.js"; @@ -15,6 +16,15 @@ function createDeps(): VerifyCliUpdateDeps { apiUrl: "http://127.0.0.1:43123", stop: vi.fn(async () => undefined), })), + lookupReleaseMetadata: vi.fn(async () => ({ + version: "0.6.0", + currentPublishedAt: "2026-07-01T00:00:00.000Z", + latestPublishedAt: "2026-08-08T01:02:03.000Z", + })), + startRegistryProxy: vi.fn(async ({ registryUrl }) => ({ + registryUrl, + stop: vi.fn(async () => undefined), + })), callWs: vi.fn(async ({ op }) => { if (op === "updates.getState") { return { @@ -61,6 +71,7 @@ function createDeps(): VerifyCliUpdateDeps { latestVersion: "0.6.0", latestPublishedAt: "2026-08-08T01:02:03.000Z", targetVersion: "0.6.0", + availability: "up_to_date", updateStatus: "succeeded", })), runFailureScenario: vi.fn(async ({ scenario, prefix }) => ({ @@ -82,6 +93,45 @@ function createDeps(): VerifyCliUpdateDeps { } describe("verify-cli-update", () => { + it("serves a loopback npm registry view with the candidate mapped to latest", async () => { + const clientFetch = globalThis.fetch; + const upstreamFetch = vi.fn(async () => + Response.json({ + name: "@spencer-kit/coder-studio", + "dist-tags": { + latest: "0.5.6", + "coder-studio-accept-42": "0.5.7", + }, + versions: { "0.5.6": {}, "0.5.7": {} }, + }) + ); + vi.stubGlobal("fetch", upstreamFetch); + const proxy = await startCandidateRegistryProxy({ + registryUrl: "https://registry.npmjs.org/", + packageName: "@spencer-kit/coder-studio", + candidateVersion: "0.5.7", + }); + + try { + const response = await clientFetch( + new URL("%40spencer-kit%2Fcoder-studio", proxy.registryUrl) + ); + await expect(response.json()).resolves.toMatchObject({ + "dist-tags": { + latest: "0.5.7", + "coder-studio-accept-42": "0.5.7", + }, + }); + expect(upstreamFetch).toHaveBeenCalledWith(expect.any(URL), { cache: "no-store" }); + expect(String(upstreamFetch.mock.calls[0]?.[0])).toBe( + new URL("%40spencer-kit%2Fcoder-studio", "https://registry.npmjs.org/").toString() + ); + } finally { + await proxy.stop(); + vi.unstubAllGlobals(); + } + }); + it("claims activation and runs the update command on the same websocket", async () => { const sent: Array> = []; let socket: AcceptanceWebSocket | undefined; @@ -213,6 +263,67 @@ describe("verify-cli-update", () => { expect(deps.removePrefix).toHaveBeenCalledWith(prefix); }); + it("bootstraps an exact candidate from a legacy v1 update state", async () => { + const deps = createDeps(); + vi.mocked(deps.callWs).mockImplementation(async ({ op }) => { + if (op === "updates.getState") { + return { version: 1, currentVersion: "0.5.6", updateStatus: "idle" }; + } + if (op === "updates.prepareInstall") { + return { activity: { hasActiveWork: false } }; + } + if (op === "updates.startInstall") { + return { + version: 1, + currentVersion: "0.5.6", + targetVersion: "0.5.7", + updateStatus: "installing", + }; + } + throw new Error(`Legacy server must not receive ${op}`); + }); + vi.mocked(deps.lookupReleaseMetadata).mockResolvedValue({ + version: "0.5.7", + currentPublishedAt: "2026-07-01T00:00:00.000Z", + latestPublishedAt: "2026-08-12T01:02:03.000Z", + }); + vi.mocked(deps.waitForReconcile).mockResolvedValue({ + version: 2, + currentVersion: "0.5.7", + currentPublishedAt: "2026-08-12T01:02:03.000Z", + latestVersion: "0.5.7", + latestPublishedAt: "2026-08-12T01:02:03.000Z", + availability: "up_to_date", + updateStatus: "idle", + }); + + await expect( + verifyCliUpdate( + { + packageName: "@spencer-kit/coder-studio", + previousVersion: "0.5.6", + candidateVersion: "0.5.7", + registryUrl: "https://registry.npmjs.org/", + distTag: "coder-studio-accept-42", + prefix: resolve("/tmp/coder-studio-cli-acceptance-legacy"), + }, + deps + ) + ).resolves.toMatchObject({ + previousVersion: "0.5.6", + candidateVersion: "0.5.7", + candidatePublishedAt: "2026-08-12T01:02:03.000Z", + reconciledStatus: "succeeded", + }); + expect(deps.callWs).not.toHaveBeenCalledWith(expect.objectContaining({ op: "updates.check" })); + expect(deps.callWs).toHaveBeenCalledWith( + expect.objectContaining({ + op: "updates.startInstall", + args: { targetVersion: "0.5.7", force: false }, + }) + ); + }); + it("rejects an acceptance prefix that reports active work", async () => { const deps = createDeps(); vi.mocked(deps.callWs).mockImplementation(async ({ op }) => { diff --git a/scripts/verify-cli-update.ts b/scripts/verify-cli-update.ts index 6586ea59..42ce10e5 100644 --- a/scripts/verify-cli-update.ts +++ b/scripts/verify-cli-update.ts @@ -1,12 +1,21 @@ import { type ChildProcess, execFile, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import type { UpdatePrepareInstallResponse, UpdateStateSnapshot } from "@coder-studio/core"; +import type { + ReadableUpdateStateSnapshot, + UpdatePrepareInstallResponse, + UpdateStateSnapshot, +} from "@coder-studio/core"; import type { CoderStudioWsCommandInput } from "../packages/cli/src/automation-ws-client.js"; +import { + lookupNpmReleaseMetadata, + type NpmReleaseMetadata, +} from "../packages/server/src/update/npm-release-metadata.js"; import { error, success } from "./shared/index.js"; import { isDirectExecution } from "./shared/process.js"; @@ -57,6 +66,11 @@ interface ManagedServer { stop(): Promise; } +interface ManagedRegistryProxy { + registryUrl: string; + stop(): Promise; +} + interface CommandOptions { env: NodeJS.ProcessEnv; cwd?: string; @@ -91,6 +105,17 @@ export interface VerifyCliUpdateDeps { port: number; }): Promise; callWs(input: CoderStudioWsCommandInput): Promise; + lookupReleaseMetadata(input: { + packageName: string; + currentVersion: string; + distTag: string; + registryUrl: string; + }): Promise; + startRegistryProxy(input: { + registryUrl: string; + packageName: string; + candidateVersion: string; + }): Promise; waitForReconcile(input: { apiUrl: string; candidateVersion: string; @@ -119,7 +144,7 @@ function assertAcceptancePrefix(prefix: string): string { async function reservePort(): Promise { return new Promise((resolvePort, reject) => { - const server = createServer(); + const server = createNetServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); @@ -132,6 +157,72 @@ async function reservePort(): Promise { }); } +export async function startCandidateRegistryProxy(input: { + registryUrl: string; + packageName: string; + candidateVersion: string; +}): Promise { + const upstream = new URL( + input.registryUrl.endsWith("/") ? input.registryUrl : `${input.registryUrl}/` + ); + const server = createHttpServer(async (request, response) => { + try { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end(); + return; + } + const target = new URL(request.url ?? "/", upstream); + if (target.origin !== upstream.origin) { + throw new Error("npm acceptance proxy target changed origin"); + } + const upstreamResponse = await fetch(target, { cache: "no-store" }); + const contentType = upstreamResponse.headers.get("content-type") ?? ""; + let body = Buffer.from(await upstreamResponse.arrayBuffer()); + if (upstreamResponse.ok && contentType.includes("application/json")) { + const metadata = JSON.parse(body.toString("utf8")) as { + name?: unknown; + "dist-tags"?: Record; + versions?: Record; + }; + if ( + metadata.name === input.packageName && + metadata.versions?.[input.candidateVersion] && + metadata["dist-tags"] + ) { + metadata["dist-tags"].latest = input.candidateVersion; + body = Buffer.from(JSON.stringify(metadata)); + } + } + response.statusCode = upstreamResponse.status; + response.setHeader("cache-control", "no-store"); + if (contentType) response.setHeader("content-type", contentType); + response.setHeader("content-length", String(body.length)); + if (request.method === "HEAD") response.end(); + else response.end(body); + } catch (proxyError) { + response.statusCode = 502; + response.end(proxyError instanceof Error ? proxyError.message : String(proxyError)); + } + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolveClose) => server.close(() => resolveClose())); + throw new Error("npm acceptance proxy did not bind a TCP port"); + } + return { + registryUrl: `http://127.0.0.1:${address.port}/`, + stop: () => new Promise((resolveClose) => server.close(() => resolveClose())), + }; +} + async function waitForHealth(apiUrl: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -199,6 +290,7 @@ function acceptanceEnvironment(input: { ...process.env, PATH: pathEntries, npm_config_prefix: input.prefix, + npm_config_registry: input.registryUrl, PM2_HOME: resolve(input.prefix, "pm2"), CODER_STUDIO_HOME: coderStudioHome, CODER_STUDIO_RUNTIME_DIR: coderStudioHome, @@ -394,12 +486,13 @@ async function runDefaultFailureScenario(input: { { env } ); server = await defaultDeps.startServer({ executable: cliExecutable, env, port }); - const checked = await defaultDeps.callWs({ - apiUrl: server.apiUrl, - op: "updates.check", - args: {}, + const release = await defaultDeps.lookupReleaseMetadata({ + packageName: input.packageName, + currentVersion: input.previousVersion, + distTag: input.distTag, + registryUrl: input.registryUrl, }); - if (checked.latestVersion !== input.candidateVersion) { + if (release.version !== input.candidateVersion) { throw new Error(`Fault scenario dist-tag did not resolve ${input.candidateVersion}`); } await defaultDeps.callWs({ @@ -502,6 +595,8 @@ const defaultDeps: VerifyCliUpdateDeps = { }; }, callWs: callActivatedCoderStudioWsCommand, + lookupReleaseMetadata: lookupNpmReleaseMetadata, + startRegistryProxy: startCandidateRegistryProxy, waitForReconcile: async ({ apiUrl, candidateVersion, callWs }) => { const deadline = Date.now() + 120_000; let lastState: Partial = {}; @@ -515,8 +610,13 @@ const defaultDeps: VerifyCliUpdateDeps = { timeoutMs: 5_000, }); if ( + lastState.version === 2 && lastState.currentVersion === candidateVersion && - lastState.updateStatus === "succeeded" + lastState.currentPublishedAt && + lastState.latestVersion === candidateVersion && + lastState.latestPublishedAt === lastState.currentPublishedAt && + lastState.availability === "up_to_date" && + (lastState.updateStatus === "succeeded" || lastState.updateStatus === "idle") ) { return lastState; } @@ -560,14 +660,32 @@ export async function verifyCliUpdate( : resolve(binDirectory, "coder-studio"); const port = await reservePort(); const registryUrl = new URL(options.registryUrl).toString(); - const acceptanceEnv = acceptanceEnvironment({ - prefix, - binDirectory, - registryUrl, - distTag: options.distTag.trim(), - }); + let registryProxy: ManagedRegistryProxy | null = null; let server: ManagedServer | null = null; try { + const release = await deps.lookupReleaseMetadata({ + packageName, + currentVersion: previousVersion, + distTag: options.distTag.trim(), + registryUrl, + }); + if (release.version !== candidateVersion) { + throw new Error(`Selected dist-tag must resolve exact candidate ${candidateVersion}`); + } + if (!release.latestPublishedAt || !Number.isFinite(Date.parse(release.latestPublishedAt))) { + throw new Error("Candidate npm publication time is missing"); + } + registryProxy = await deps.startRegistryProxy({ + registryUrl, + packageName, + candidateVersion, + }); + const acceptanceEnv = acceptanceEnvironment({ + prefix, + binDirectory, + registryUrl: registryProxy.registryUrl, + distTag: options.distTag.trim(), + }); await mkdir(stateDirectory, { recursive: true }); await deps.command( "npm", @@ -580,24 +698,29 @@ export async function verifyCliUpdate( { env: acceptanceEnv } ); server = await deps.startServer({ executable: cliExecutable, env: acceptanceEnv, port }); - const initial = await deps.callWs({ + const initial = await deps.callWs({ apiUrl: server.apiUrl, op: "updates.getState", args: {}, }); - if (initial.currentVersion !== previousVersion || initial.version !== 2) { + if ( + initial.currentVersion !== previousVersion || + (initial.version !== 1 && initial.version !== 2) + ) { throw new Error(`Packaged CLI did not start at exact previous version ${previousVersion}`); } - const checked = await deps.callWs({ - apiUrl: server.apiUrl, - op: "updates.check", - args: {}, - }); - if (checked.latestVersion !== candidateVersion) { - throw new Error(`Selected dist-tag must resolve exact candidate ${candidateVersion}`); - } - if (!checked.latestPublishedAt || !Number.isFinite(Date.parse(checked.latestPublishedAt))) { - throw new Error("Candidate npm publication time is missing"); + if (initial.version === 2) { + const checked = await deps.callWs({ + apiUrl: server.apiUrl, + op: "updates.check", + args: {}, + }); + if ( + checked.latestVersion !== candidateVersion || + checked.latestPublishedAt !== release.latestPublishedAt + ) { + throw new Error(`Selected dist-tag must resolve exact candidate ${candidateVersion}`); + } } const prepared = await deps.callWs({ apiUrl: server.apiUrl, @@ -623,9 +746,13 @@ export async function verifyCliUpdate( callWs: deps.callWs, }); if ( + reconciled.version !== 2 || reconciled.currentVersion !== candidateVersion || - reconciled.currentPublishedAt !== checked.latestPublishedAt || - reconciled.updateStatus !== "succeeded" + reconciled.currentPublishedAt !== release.latestPublishedAt || + reconciled.latestVersion !== candidateVersion || + reconciled.latestPublishedAt !== release.latestPublishedAt || + reconciled.availability !== "up_to_date" || + (reconciled.updateStatus !== "succeeded" && reconciled.updateStatus !== "idle") ) { throw new Error("CLI restart did not preserve npm metadata or reconcile to succeeded"); } @@ -654,7 +781,7 @@ export async function verifyCliUpdate( packageName, previousVersion, candidateVersion, - candidatePublishedAt: checked.latestPublishedAt, + candidatePublishedAt: release.latestPublishedAt, prefix, exactInstallObserved: true, restartObserved: true, @@ -670,6 +797,7 @@ export async function verifyCliUpdate( return report; } finally { await server?.stop().catch(() => undefined); + await registryProxy?.stop().catch(() => undefined); await deps.removePrefix(prefix); } } From 6e945a26d16f142cc0a6b75678961a11c86b7c72 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 13 Aug 2026 02:59:07 +0800 Subject: [PATCH 14/14] retry npm candidate tag propagation (#103) --- scripts/verify-cli-update.test.ts | 53 +++++++++++++++++++++++++++++++ scripts/verify-cli-update.ts | 48 +++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/scripts/verify-cli-update.test.ts b/scripts/verify-cli-update.test.ts index d4d825dd..0760dd48 100644 --- a/scripts/verify-cli-update.test.ts +++ b/scripts/verify-cli-update.test.ts @@ -21,6 +21,7 @@ function createDeps(): VerifyCliUpdateDeps { currentPublishedAt: "2026-07-01T00:00:00.000Z", latestPublishedAt: "2026-08-08T01:02:03.000Z", })), + wait: vi.fn(async () => undefined), startRegistryProxy: vi.fn(async ({ registryUrl }) => ({ registryUrl, stop: vi.fn(async () => undefined), @@ -324,6 +325,58 @@ describe("verify-cli-update", () => { ); }); + it("waits for a just-written candidate dist-tag to propagate", async () => { + const deps = createDeps(); + vi.mocked(deps.lookupReleaseMetadata) + .mockRejectedValueOnce( + new Error("npm registry did not return dist-tag coder-studio-accept-42") + ) + .mockResolvedValue({ + version: "0.6.0", + currentPublishedAt: "2026-07-01T00:00:00.000Z", + latestPublishedAt: "2026-08-08T01:02:03.000Z", + }); + + await verifyCliUpdate( + { + packageName: "@spencer-kit/coder-studio", + previousVersion: "0.5.0", + candidateVersion: "0.6.0", + registryUrl: "https://registry.npmjs.org/", + distTag: "coder-studio-accept-42", + prefix: resolve("/tmp/coder-studio-cli-acceptance-propagation"), + }, + deps + ); + + expect(deps.lookupReleaseMetadata).toHaveBeenCalledTimes(2); + expect(deps.wait).toHaveBeenCalledWith(500); + }); + + it("rejects a propagated candidate dist-tag that points at the wrong version", async () => { + const deps = createDeps(); + vi.mocked(deps.lookupReleaseMetadata).mockResolvedValue({ + version: "0.6.1", + currentPublishedAt: "2026-07-01T00:00:00.000Z", + latestPublishedAt: "2026-08-09T01:02:03.000Z", + }); + + await expect( + verifyCliUpdate( + { + packageName: "@spencer-kit/coder-studio", + previousVersion: "0.5.0", + candidateVersion: "0.6.0", + registryUrl: "https://registry.npmjs.org/", + distTag: "coder-studio-accept-42", + prefix: resolve("/tmp/coder-studio-cli-acceptance-wrong-tag"), + }, + deps + ) + ).rejects.toThrow("resolved 0.6.1, expected exact candidate 0.6.0"); + expect(deps.wait).not.toHaveBeenCalled(); + }); + it("rejects an acceptance prefix that reports active work", async () => { const deps = createDeps(); vi.mocked(deps.callWs).mockImplementation(async ({ op }) => { diff --git a/scripts/verify-cli-update.ts b/scripts/verify-cli-update.ts index 42ce10e5..5d0fc41a 100644 --- a/scripts/verify-cli-update.ts +++ b/scripts/verify-cli-update.ts @@ -111,6 +111,7 @@ export interface VerifyCliUpdateDeps { distTag: string; registryUrl: string; }): Promise; + wait(ms: number): Promise; startRegistryProxy(input: { registryUrl: string; packageName: string; @@ -596,6 +597,7 @@ const defaultDeps: VerifyCliUpdateDeps = { }, callWs: callActivatedCoderStudioWsCommand, lookupReleaseMetadata: lookupNpmReleaseMetadata, + wait: (ms) => new Promise((resolveWait) => setTimeout(resolveWait, ms)), startRegistryProxy: startCandidateRegistryProxy, waitForReconcile: async ({ apiUrl, candidateVersion, callWs }) => { const deadline = Date.now() + 120_000; @@ -640,6 +642,43 @@ function requireVersion(value: string, label: string): string { return normalized; } +async function waitForCandidateRelease(input: { + deps: Pick; + packageName: string; + previousVersion: string; + candidateVersion: string; + distTag: string; + registryUrl: string; + timeoutMs?: number; +}): Promise { + const deadline = Date.now() + (input.timeoutMs ?? 30_000); + let lastError: unknown; + while (Date.now() < deadline) { + try { + const release = await input.deps.lookupReleaseMetadata({ + packageName: input.packageName, + currentVersion: input.previousVersion, + distTag: input.distTag, + registryUrl: input.registryUrl, + }); + if (release.version !== input.candidateVersion) { + throw new Error( + `Selected dist-tag resolved ${release.version}, expected exact candidate ${input.candidateVersion}` + ); + } + return release; + } catch (lookupError) { + lastError = lookupError; + const message = lookupError instanceof Error ? lookupError.message : String(lookupError); + if (!message.includes(`did not return dist-tag ${input.distTag}`)) throw lookupError; + await input.deps.wait(500); + } + } + throw new Error( + `Candidate dist-tag ${input.distTag} did not propagate before timeout: ${lastError instanceof Error ? lastError.message : String(lastError)}` + ); +} + export async function verifyCliUpdate( options: VerifyCliUpdateOptions, deps: VerifyCliUpdateDeps = defaultDeps @@ -663,15 +702,14 @@ export async function verifyCliUpdate( let registryProxy: ManagedRegistryProxy | null = null; let server: ManagedServer | null = null; try { - const release = await deps.lookupReleaseMetadata({ + const release = await waitForCandidateRelease({ + deps, packageName, - currentVersion: previousVersion, + previousVersion, + candidateVersion, distTag: options.distTag.trim(), registryUrl, }); - if (release.version !== candidateVersion) { - throw new Error(`Selected dist-tag must resolve exact candidate ${candidateVersion}`); - } if (!release.latestPublishedAt || !Number.isFinite(Date.parse(release.latestPublishedAt))) { throw new Error("Candidate npm publication time is missing"); }