diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index b198d81d..9c2f523f 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -25,6 +25,7 @@ jobs: signing_key_artifact: ${{ steps.channel.outputs.signing_key_artifact }} public_key_artifact: ${{ steps.channel.outputs.public_key_artifact }} has_previous_desktop: ${{ steps.channel.outputs.has_previous_desktop }} + release_kind: ${{ steps.channel.outputs.release_kind }} acceptance_scenarios: ${{ steps.channel.outputs.acceptance_scenarios }} steps: @@ -50,6 +51,7 @@ jobs: latest_release_json="${RUNNER_TEMP}/coder-studio-latest-release.json" latest_release_error="${RUNNER_TEMP}/coder-studio-latest-release-error.log" has_previous_desktop=false + release_kind=full if gh api "repos/${GITHUB_REPOSITORY}/releases/latest" > "${latest_release_json}" 2> "${latest_release_error}"; then has_previous_desktop=$(node -e "const release=require(process.argv[1]); process.stdout.write(Array.isArray(release.assets)&&release.assets.some((asset)=>asset?.name==='desktop-channel.json')?'true':'false')" "${latest_release_json}") if [[ "${has_previous_desktop}" == "true" ]]; then @@ -62,17 +64,32 @@ jobs: current_shell=$(node -p "require('./packages/desktop/package.json').version") current_runtime=$(node -p "require('./packages/cli/package.json').version") has_previous_desktop=$(node -e "const channel=require(process.argv[1]); const currentShell=process.argv[2]; const currentRuntime=process.argv[3]; process.stdout.write(channel?.shell?.version===currentShell&&channel?.runtimes?.['win32-x64']?.version===currentRuntime&&channel?.runtimes?.['linux-x64']?.version===currentRuntime?'false':'true')" "${latest_channel}" "${current_shell}" "${current_runtime}") + if [[ "${has_previous_desktop}" == "true" ]]; then + previous_shell=$(node -e "const channel=require(process.argv[1]); if(typeof channel?.shell?.version!=='string'||!channel.shell.version) process.exit(1); process.stdout.write(channel.shell.version)" "${latest_channel}") + shell_change=$(node -e "const parse=(value)=>{const match=/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value);if(!match)throw new Error('Invalid Shell version: '+value);return {core:match.slice(1,4).map(Number),pre:match[4]??null}};const [current,previous]=process.argv.slice(1).map(parse);let order=0;for(let i=0;i<3&&!order;i++)order=Math.sign(current.core[i]-previous.core[i]);if(!order&¤t.pre!==previous.pre)order=current.pre===null?1:previous.pre===null?-1:current.pre.localeCompare(previous.pre,undefined,{numeric:true});process.stdout.write(order>0?'upgrade':order<0?'downgrade':'same')" "${current_shell}" "${previous_shell}") + if [[ "${shell_change}" == "same" ]]; then + release_kind=runtime-only + elif [[ "${shell_change}" == "downgrade" ]]; then + echo "Desktop Shell ${current_shell} is older than stable ${previous_shell}" >&2 + exit 1 + fi + fi fi elif ! grep -q '(HTTP 404)' "${latest_release_error}"; then cat "${latest_release_error}" >&2 exit 1 fi if [[ "${has_previous_desktop}" == "true" ]]; then - acceptance_scenarios='["runtime-only","combined","wsl","wsl-combined","runtime-health-rollback","interrupted-download","restart-journal-recovery","external-sidecar-browser"]' + if [[ "${release_kind}" == "full" ]]; then + acceptance_scenarios='["combined","wsl-combined","runtime-health-rollback","interrupted-download","restart-journal-recovery","external-sidecar-browser"]' + else + acceptance_scenarios='["runtime-only","combined","wsl","wsl-combined","runtime-health-rollback","interrupted-download","restart-journal-recovery","external-sidecar-browser"]' + fi else acceptance_scenarios='["fresh-native","fresh-wsl"]' fi echo "has_previous_desktop=${has_previous_desktop}" >> "${GITHUB_OUTPUT}" + echo "release_kind=${release_kind}" >> "${GITHUB_OUTPUT}" echo "acceptance_scenarios=${acceptance_scenarios}" >> "${GITHUB_OUTPUT}" - name: Generate ephemeral Runtime signing key @@ -297,13 +314,15 @@ jobs: $previous = 'release/desktop-installed-previous' $scenario = '${{ matrix.scenario }}' $hasPreviousDesktop = '${{ needs.prepare.outputs.has_previous_desktop }}' -eq 'true' + $releaseKind = '${{ needs.prepare.outputs.release_kind }}' + $useRuntimeOnlyChannel = $hasPreviousDesktop -and $releaseKind -eq 'runtime-only' -and $scenario -notin @('combined', 'wsl-combined') $originalChannel = Get-Content (Join-Path $candidate 'desktop-channel.json') -Raw | ConvertFrom-Json $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')) { + if ($useRuntimeOnlyChannel) { pnpm desktop:channel -- --directory $candidate --carry-forward-from $previous if ($LASTEXITCODE -ne 0) { throw 'Unable to carry forward the previous Shell for the Runtime-only scenario' } } @@ -320,7 +339,7 @@ jobs: '--directory', $candidate, '--components', 'desktop,win-runtime,wsl-engine,wsl-runtime' ) - if (-not $hasPreviousDesktop -or $scenario -in @('combined', 'wsl-combined')) { + if (-not $useRuntimeOnlyChannel) { $validation += @('--release-kind', 'full') } else { $validation += @( @@ -348,14 +367,16 @@ jobs: } $components = if ($scenario -in @('fresh-native', 'fresh-wsl')) { '' + } elseif ($scenario -eq 'external-sidecar-browser') { + '' + } elseif ($releaseKind -eq 'full') { + 'shell,runtime:win32-x64' } elseif ($scenario -eq 'combined') { 'shell,runtime:win32-x64' } elseif ($scenario -eq 'wsl-combined') { 'shell,runtime:win32-x64' } elseif ($scenario -eq 'wsl') { 'runtime:win32-x64' - } elseif ($scenario -eq 'external-sidecar-browser') { - '' } else { 'runtime:win32-x64' } @@ -427,6 +448,10 @@ jobs: } pnpm acceptance:desktop:installed @arguments if ($LASTEXITCODE -ne 0) { throw 'Installed Desktop acceptance failed' } + $reportPath = 'release/desktop-installed-report/${{ matrix.scenario }}.json' + $report = Get-Content $reportPath -Raw | ConvertFrom-Json + $report | Add-Member -NotePropertyName releaseKind -NotePropertyValue '${{ needs.prepare.outputs.release_kind }}' -Force + $report | ConvertTo-Json -Depth 32 | Set-Content $reportPath -Encoding utf8 - name: Upload installed-upgrade report if: always() diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index ea7333e7..a8a6993e 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -673,6 +673,7 @@ jobs: return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); }; const channel = parseJson("release/promotion-reports/channel/desktop-channel.json"); + const releaseKind = "${{ needs.prepare.outputs.release_kind }}"; const channelSignatureDigest = createHash("sha256") .update(channel.signature.value, "utf8") .digest("hex"); @@ -699,10 +700,13 @@ jobs: if (requiredProductionScenarios.size) throw new Error(`Production reports are missing: ${[...requiredProductionScenarios].join(", ")}`); const matrix = readReports("release/promotion-reports/desktop-matrix"); const requiredScenarios = new Set(${{ needs.prepare.outputs.has_previous_desktop == 'true' }} - ? ["runtime-only", "combined", "wsl", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"] + ? releaseKind === "full" + ? ["combined", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"] + : ["runtime-only", "combined", "wsl", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"] : ["fresh-native", "fresh-wsl"]); for (const report of matrix) { if (report.commitSha !== commitSha) throw new Error("Desktop matrix commitSha does not match"); + if (report.releaseKind !== releaseKind) throw new Error("Desktop matrix releaseKind does not match"); if ((report.scenario === "fresh-wsl" || report.scenario === "wsl" || report.scenario === "wsl-combined") && report.wslRuntimeVersion !== productVersion) { throw new Error("Desktop matrix WSL report does not match the shared Web product version"); } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42527ded..9e6c41b6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -220,14 +220,29 @@ jobs: .filter((name) => String(name).endsWith(".json")) .map((name) => parseJson(join(root, String(name)))); const bootstrap = reports.some((report) => report.scenario === "fresh-native"); + const releaseKinds = new Set(reports.map((report) => report.releaseKind)); + if (releaseKinds.size !== 1) throw new Error("Desktop reports do not agree on releaseKind"); + const releaseKind = [...releaseKinds][0]; + if (releaseKind !== "full" && releaseKind !== "runtime-only") { + throw new Error(`Desktop report releaseKind is invalid: ${releaseKind}`); + } + if (bootstrap && releaseKind !== "full") { + throw new Error("Fresh Desktop acceptance must use a full release"); + } const required = new Set(bootstrap ? ["fresh-native", "fresh-wsl"] - : ["runtime-only", "combined", "wsl", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"]); + : releaseKind === "full" + ? ["combined", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"] + : ["runtime-only", "combined", "wsl", "wsl-combined", "runtime-health-rollback", "interrupted-download", "restart-journal-recovery", "external-sidecar-browser"]); for (const report of reports) { if (report.commitSha !== process.env.GITHUB_SHA) throw new Error("Desktop report commitSha does not match"); - if (report.scenario !== "external-sidecar-browser" && report.actualRuntimeVersion !== process.env.CANDIDATE_VERSION) { + if (report.releaseKind !== releaseKind) throw new Error("Desktop report releaseKind does not match"); + if (report.scenario !== "external-sidecar-browser" && report.scenario !== "runtime-health-rollback" && report.actualRuntimeVersion !== process.env.CANDIDATE_VERSION) { throw new Error("Desktop report product version does not match CLI candidate"); } + if (report.scenario === "runtime-health-rollback" && (!report.rollbackRuntimeVersion || report.actualRuntimeVersion !== report.rollbackRuntimeVersion || report.actualRuntimeVersion === process.env.CANDIDATE_VERSION)) { + throw new Error("Desktop Runtime rollback report does not prove fallback to the previous Runtime"); + } if ((report.scenario === "fresh-wsl" || report.scenario === "wsl" || report.scenario === "wsl-combined") && report.wslRuntimeVersion !== process.env.CANDIDATE_VERSION) { throw new Error("Desktop WSL report does not match the shared Web product version"); } diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 934d2ce0..4e6185e0 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -244,6 +244,7 @@ describe("GitHub workflow boundaries", () => { signing_key_artifact: "${{ steps.channel.outputs.signing_key_artifact }}", public_key_artifact: "${{ steps.channel.outputs.public_key_artifact }}", has_previous_desktop: "${{ steps.channel.outputs.has_previous_desktop }}", + release_kind: "${{ steps.channel.outputs.release_kind }}", acceptance_scenarios: "${{ steps.channel.outputs.acceptance_scenarios }}", }); expect(resolveChannel?.run).toContain( @@ -267,6 +268,10 @@ describe("GitHub workflow boundaries", () => { expect(resolveChannel?.run).toContain("require('./packages/cli/package.json').version"); expect(resolveChannel?.run).toContain("channel?.shell?.version===currentShell"); expect(resolveChannel?.run).toContain("?'false':'true'"); + expect(resolveChannel?.run).toContain("release_kind=runtime-only"); + expect(resolveChannel?.run).toContain( + 'acceptance_scenarios=\'["combined","wsl-combined","runtime-health-rollback","interrupted-download","restart-journal-recovery","external-sidecar-browser"]\'' + ); expect(resolveChannel?.run).toContain('acceptance_scenarios=\'["fresh-native","fresh-wsl"]\''); expect(generateKey?.run).toContain("openssl genpkey -algorithm Ed25519"); expect(signingKeyUpload?.with).toMatchObject({ @@ -479,6 +484,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("$useRuntimeOnlyChannel"); + expect(prepareScenario?.run).toContain("$releaseKind -eq 'full'"); expect(prepareScenario?.run).toContain("yyyy-MM-ddTHH:mm:ss.fffZ"); expect(prepareScenario?.run).toContain("InvariantCulture"); expect(prepareScenario?.run).toContain("'desktop:artifacts', 'validate'"); @@ -524,6 +531,7 @@ describe("GitHub workflow boundaries", () => { expect(validateReports?.run).toContain("commitSha"); expect(validateReports?.run).toContain("wslRuntimeVersion"); expect(validateReports?.run).toContain("wsl-combined"); + expect(validateReports?.run).toContain("report.releaseKind !== releaseKind"); expect(promote?.run?.trim()).toBe( 'gh release edit "${{ needs.prepare.outputs.tag }}" --prerelease=false --latest' ); @@ -589,6 +597,12 @@ describe("GitHub workflow boundaries", () => { expect(steps[desktopReportIndex]?.run).toContain("wsl-combined"); expect(steps[desktopReportIndex]?.run).toContain("fresh-native"); expect(steps[desktopReportIndex]?.run).toContain("fresh-wsl"); + expect(steps[desktopReportIndex]?.run).toContain("releaseKinds"); + expect(steps[desktopReportIndex]?.run).toContain('releaseKind === "full"'); + expect(steps[desktopReportIndex]?.run).toContain( + 'report.scenario !== "runtime-health-rollback"' + ); + expect(steps[desktopReportIndex]?.run).toContain("report.rollbackRuntimeVersion"); expect(steps[desktopReportIndex]?.run).toContain("text.charCodeAt(0) === 0xfeff"); expect(steps[preserveDesktopIndex]?.if).toBe("inputs.promote"); expect(steps[promoteIndex]?.run).toContain("npm dist-tag add");