From 91e86b5361fa01dcc818f177165e74dc4dcbe1cb Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 27 Aug 2026 12:04:51 +0800 Subject: [PATCH] fix(release): bootstrap product index for desktop --- .github/workflows/desktop-release.yml | 202 +++++++++++++------ scripts/github-workflows.test.ts | 12 +- scripts/select-product-index-release.test.ts | 45 +++++ scripts/select-product-index-release.ts | 32 ++- 4 files changed, 223 insertions(+), 68 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 992939d0..47c8115a 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -146,7 +146,8 @@ jobs: environment: desktop-production outputs: current_product_tag: ${{ steps.identity.outputs.current_product_tag }} - current_product_index_sha256: ${{ steps.identity.outputs.current_product_index_sha256 }} + current_product_source_asset: ${{ steps.identity.outputs.current_product_source_asset }} + current_product_source_sha256: ${{ steps.identity.outputs.current_product_source_sha256 }} current_product_channel_sha256: ${{ steps.identity.outputs.current_product_channel_sha256 }} current_windows_manifest_sha256: ${{ steps.identity.outputs.current_windows_manifest_sha256 }} current_linux_manifest_sha256: ${{ steps.identity.outputs.current_linux_manifest_sha256 }} @@ -200,9 +201,17 @@ jobs: release/factory-resolution/selection printf '%s' "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" \ > release/factory-resolution/keys/runtime-public.pem - gh release download product-stable \ - --pattern product-index.json \ - --dir release/factory-resolution/stable + selection_source=release/factory-resolution/stable/product-index.json + selection_source_asset=product-index.json + if ! gh release download product-stable \ + --pattern "${selection_source_asset}" \ + --dir release/factory-resolution/stable 2>/dev/null; then + selection_source=release/factory-resolution/stable/product-channel.json + selection_source_asset=product-channel.json + gh release download product-stable \ + --pattern "${selection_source_asset}" \ + --dir release/factory-resolution/stable + fi required_version="" if [[ "${RESUME_CANDIDATE}" == "true" ]]; then gh release download "${CANDIDATE_TAG}" \ @@ -211,7 +220,7 @@ jobs: required_version=$(node -p "require('./release/factory-resolution/desktop/desktop-channel.json').factoryProduct.version") fi node --import tsx scripts/select-product-index-release.ts \ - --index release/factory-resolution/stable/product-index.json \ + --index "${selection_source}" \ --build-info packages/desktop/dist/build-info.json \ --public-key release/factory-resolution/keys/runtime-public.pem \ --output release/factory-resolution/selection/current.json @@ -223,6 +232,10 @@ jobs: fi echo "selected_tag=${selected_tag}" >> "${GITHUB_OUTPUT}" echo "selected_version=${selected_version}" >> "${GITHUB_OUTPUT}" + selection_source_sha256=$(sha256sum "${selection_source}" | cut -d ' ' -f 1) + echo "selection_source=${selection_source}" >> "${GITHUB_OUTPUT}" + echo "selection_source_asset=${selection_source_asset}" >> "${GITHUB_OUTPUT}" + echo "selection_source_sha256=${selection_source_sha256}" >> "${GITHUB_OUTPUT}" - name: Download and validate accepted Product bundle env: @@ -304,65 +317,131 @@ jobs: id: identity env: CURRENT_PRODUCT_TAG: ${{ steps.product.outputs.selected_tag }} + PRODUCT_SOURCE: ${{ steps.product.outputs.selection_source }} + PRODUCT_SOURCE_ASSET: ${{ steps.product.outputs.selection_source_asset }} + PRODUCT_SOURCE_SHA256: ${{ steps.product.outputs.selection_source_sha256 }} run: | current_channel=release/factory-resolution/product/current/product-channel.json current_channel_digest=$(sha256sum "${current_channel}" | cut -d ' ' -f 1) current_windows_digest=$(node -p "require('./${current_channel}').runtimes['win32-x64'].manifestSha256") current_linux_digest=$(node -p "require('./${current_channel}').runtimes['linux-x64'].manifestSha256") - previous_product_tag="" - previous_channel_digest="" - previous_windows_digest="" - previous_linux_digest="" + download_product_bundle() { + local selection=$1 + local bundle=$2 + local source_package=$3 + local product_tag windows_manifest linux_manifest + product_tag=$(node -p "require('./${selection}').releaseTag") || return 1 + windows_manifest=$(node -p "require('./${selection}').runtimes['win32-x64'].manifest") || return 1 + linux_manifest=$(node -p "require('./${selection}').runtimes['linux-x64'].manifest") || return 1 + mkdir -p "${bundle}" + gh release download "${product_tag}" \ + --pattern product-channel.json \ + --pattern coder-studio-cli.tgz \ + --pattern "${windows_manifest}" \ + --pattern "${linux_manifest}" \ + --dir "${bundle}" || return 1 + local windows_digest linux_digest expected_windows_digest expected_linux_digest + windows_digest=$(sha256sum "${bundle}/${windows_manifest}" | cut -d ' ' -f 1) || return 1 + linux_digest=$(sha256sum "${bundle}/${linux_manifest}" | cut -d ' ' -f 1) || return 1 + expected_windows_digest=$(node -p "require('./${selection}').runtimes['win32-x64'].manifestSha256") || return 1 + expected_linux_digest=$(node -p "require('./${selection}').runtimes['linux-x64'].manifestSha256") || return 1 + if [[ "${windows_digest}" != "${expected_windows_digest}" || \ + "${linux_digest}" != "${expected_linux_digest}" ]]; then + return 1 + fi + local windows_package linux_package + windows_package=$(node -p "require('./${bundle}/${windows_manifest}').packageFile") || return 1 + linux_package=$(node -p "require('./${bundle}/${linux_manifest}').packageFile") || return 1 + gh release download "${product_tag}" \ + --pattern "${windows_package}" \ + --pattern "${linux_package}" \ + --dir "${bundle}" || return 1 + tar -xOf "${bundle}/coder-studio-cli.tgz" package/package.json > "${source_package}" || return 1 + pnpm release:artifacts validate-product \ + --directory "${bundle}" \ + --source-package-json "${source_package}" || return 1 + local declared_tag declared_version selected_version + declared_tag=$(node -p "require('./${bundle}/product-channel.json').releaseTag") || return 1 + declared_version=$(node -p "require('./${bundle}/product-channel.json').version") || return 1 + selected_version=$(node -p "require('./${selection}').version") || return 1 + [[ "${declared_tag}" == "${product_tag}" && "${declared_version}" == "${selected_version}" ]] + } + current_version=$(node -p "require('./release/factory-resolution/selection/current.json').version") - node --import tsx scripts/select-product-index-release.ts \ - --index release/factory-resolution/stable/product-index.json \ - --build-info packages/desktop/dist/build-info.json \ - --public-key release/factory-resolution/keys/runtime-public.pem \ - --previous-to-version "${current_version}" \ - --output release/factory-resolution/selection/previous.json + if [[ "${PRODUCT_SOURCE_ASSET}" == "product-index.json" ]]; then + node --import tsx scripts/select-product-index-release.ts \ + --index "${PRODUCT_SOURCE}" \ + --build-info packages/desktop/dist/build-info.json \ + --public-key release/factory-resolution/keys/runtime-public.pem \ + --previous-to-version "${current_version}" \ + --output release/factory-resolution/selection/previous.json + if ! download_product_bundle \ + release/factory-resolution/selection/previous.json \ + release/factory-resolution/product/previous \ + release/factory-resolution/source/previous-package.json; then + echo "Previous Product selected by the signed index is not a complete valid bundle" >&2 + exit 1 + fi + else + mkdir -p \ + release/factory-resolution/selection/previous-candidates \ + release/factory-resolution/product/previous-candidates + mapfile -t product_tags < <( + gh release list --limit 100 --exclude-drafts --exclude-pre-releases --json tagName \ + --jq '.[].tagName' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -Vr + ) + previous_product_tag="" + for candidate_tag in "${product_tags[@]}"; do + candidate_source="release/factory-resolution/selection/previous-candidates/${candidate_tag}/product-channel.json" + candidate_selection="release/factory-resolution/selection/previous-candidates/${candidate_tag}.json" + candidate_bundle="release/factory-resolution/product/previous-candidates/${candidate_tag}" + mkdir -p "$(dirname "${candidate_source}")" + if ! gh release download "${candidate_tag}" \ + --pattern product-channel.json \ + --dir "$(dirname "${candidate_source}")" 2>/dev/null; then + continue + fi + if ! node --import tsx scripts/select-product-index-release.ts \ + --index "${candidate_source}" \ + --build-info packages/desktop/dist/build-info.json \ + --public-key release/factory-resolution/keys/runtime-public.pem \ + --output "${candidate_selection}"; then + continue + fi + candidate_version=$(node -p "require('./${candidate_selection}').version") + if [[ "${candidate_version}" == "${current_version}" || \ + "$(printf '%s\n%s\n' "${candidate_version}" "${current_version}" | sort -V | head -n 1)" != "${candidate_version}" ]]; then + continue + fi + if download_product_bundle \ + "${candidate_selection}" \ + "${candidate_bundle}" \ + "release/factory-resolution/source/previous-${candidate_tag}-package.json"; then + cp "${candidate_selection}" release/factory-resolution/selection/previous.json + mkdir -p release/factory-resolution/product/previous + cp -R "${candidate_bundle}/." release/factory-resolution/product/previous/ + previous_product_tag="${candidate_tag}" + break + fi + done + if [[ -z "${previous_product_tag}" ]]; then + echo "No earlier compatible Product release has a complete valid bundle" >&2 + exit 1 + fi + fi + previous_product_tag=$(node -p "require('./release/factory-resolution/selection/previous.json').releaseTag") previous_windows_manifest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['win32-x64'].manifest") previous_linux_manifest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['linux-x64'].manifest") - mkdir -p release/factory-resolution/product/previous - gh release download "${previous_product_tag}" \ - --pattern product-channel.json \ - --pattern coder-studio-cli.tgz \ - --pattern "${previous_windows_manifest}" \ - --pattern "${previous_linux_manifest}" \ - --dir release/factory-resolution/product/previous previous_windows_digest=$(sha256sum "release/factory-resolution/product/previous/${previous_windows_manifest}" | cut -d ' ' -f 1) previous_linux_digest=$(sha256sum "release/factory-resolution/product/previous/${previous_linux_manifest}" | cut -d ' ' -f 1) - expected_previous_windows_digest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['win32-x64'].manifestSha256") - expected_previous_linux_digest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['linux-x64'].manifestSha256") - if [[ "${previous_windows_digest}" != "${expected_previous_windows_digest}" || \ - "${previous_linux_digest}" != "${expected_previous_linux_digest}" ]]; then - echo "Previous Product Runtime manifest digest does not match the signed Product index" >&2 - exit 1 - fi - previous_windows_package=$(node -p "require('./release/factory-resolution/product/previous/${previous_windows_manifest}').packageFile") - previous_linux_package=$(node -p "require('./release/factory-resolution/product/previous/${previous_linux_manifest}').packageFile") - gh release download "${previous_product_tag}" \ - --pattern "${previous_windows_package}" \ - --pattern "${previous_linux_package}" \ - --dir release/factory-resolution/product/previous - tar -xOf release/factory-resolution/product/previous/coder-studio-cli.tgz package/package.json \ - > release/factory-resolution/source/previous-package.json - pnpm release:artifacts validate-product \ - --directory release/factory-resolution/product/previous \ - --source-package-json release/factory-resolution/source/previous-package.json - declared_previous_tag=$(node -p "require('./release/factory-resolution/product/previous/product-channel.json').releaseTag") - declared_previous_version=$(node -p "require('./release/factory-resolution/product/previous/product-channel.json').version") - selected_previous_version=$(node -p "require('./release/factory-resolution/selection/previous.json').version") - if [[ "${declared_previous_tag}" != "${previous_product_tag}" || \ - "${declared_previous_version}" != "${selected_previous_version}" ]]; then - echo "Previous Product channel does not match the signed Product index selection" >&2 - exit 1 - fi previous_channel_digest=$(sha256sum release/factory-resolution/product/previous/product-channel.json | cut -d ' ' -f 1) - product_index_digest=$(sha256sum release/factory-resolution/stable/product-index.json | cut -d ' ' -f 1) echo "current_product_tag=${CURRENT_PRODUCT_TAG}" >> "${GITHUB_OUTPUT}" - echo "current_product_index_sha256=${product_index_digest}" >> "${GITHUB_OUTPUT}" + echo "current_product_source_asset=${PRODUCT_SOURCE_ASSET}" >> "${GITHUB_OUTPUT}" + echo "current_product_source_sha256=${PRODUCT_SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" echo "current_product_channel_sha256=${current_channel_digest}" >> "${GITHUB_OUTPUT}" echo "current_windows_manifest_sha256=${current_windows_digest}" >> "${GITHUB_OUTPUT}" echo "current_linux_manifest_sha256=${current_linux_digest}" >> "${GITHUB_OUTPUT}" @@ -1255,7 +1334,8 @@ jobs: DESKTOP_CHANNEL_SHA256: ${{ needs.publish-candidate.outputs.desktop_channel_sha256 }} ARTIFACT_DIGESTS: ${{ needs.publish-candidate.outputs.artifact_digests }} PREVIOUS_POINTER_DIGEST: ${{ needs.prepare.outputs.previous_desktop_channel_sha256 }} - ACCEPTED_PRODUCT_INDEX_SHA256: ${{ needs.resolve-factory-product.outputs.current_product_index_sha256 }} + ACCEPTED_PRODUCT_SOURCE_ASSET: ${{ needs.resolve-factory-product.outputs.current_product_source_asset }} + ACCEPTED_PRODUCT_SOURCE_SHA256: ${{ needs.resolve-factory-product.outputs.current_product_source_sha256 }} ACCEPTED_PRODUCT_CHANNEL_SHA256: ${{ needs.resolve-factory-product.outputs.current_product_channel_sha256 }} CANDIDATE_COMMIT: ${{ needs.publish-candidate.outputs.candidate_commit }} steps: @@ -1287,16 +1367,20 @@ jobs: run: | mkdir -p release/desktop-promotion/accepted-product accepted_product_channel=release/desktop-promotion/current-product/product-channel.json - if ! gh release download product-stable \ - --pattern product-index.json \ - --dir release/desktop-promotion/accepted-product 2>/dev/null; then - echo "The signed Product stable index is required for Desktop promotion" >&2 - exit 1 - fi + case "${ACCEPTED_PRODUCT_SOURCE_ASSET}" in + product-index.json|product-channel.json) ;; + *) + echo "Accepted Product stable source asset is invalid" >&2 + exit 1 + ;; + esac + gh release download product-stable \ + --pattern "${ACCEPTED_PRODUCT_SOURCE_ASSET}" \ + --dir release/desktop-promotion/accepted-product current_digest=$(sha256sum "${accepted_product_channel}" | cut -d ' ' -f 1) - current_index_digest=$(sha256sum release/desktop-promotion/accepted-product/product-index.json | cut -d ' ' -f 1) + current_source_digest=$(sha256sum "release/desktop-promotion/accepted-product/${ACCEPTED_PRODUCT_SOURCE_ASSET}" | cut -d ' ' -f 1) if [[ "${current_digest}" != "${ACCEPTED_PRODUCT_CHANNEL_SHA256}" || \ - "${current_index_digest}" != "${ACCEPTED_PRODUCT_INDEX_SHA256}" ]]; then + "${current_source_digest}" != "${ACCEPTED_PRODUCT_SOURCE_SHA256}" ]]; then echo "Product stable pointer changed after compatibility acceptance; rerun the candidate acceptance graph" >&2 exit 1 fi diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 9cf293da..f8d77c86 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -456,11 +456,15 @@ describe("Desktop publication workflow", () => { expect(factoryText).not.toContain("--before-version"); expect(factoryText).toContain("is no longer the highest compatible accepted Runtime"); expect(factoryText).not.toContain("prepare-product-release-bundle.ts"); - expect(factoryText).not.toContain("gh release list"); + expect(factoryText).toContain("gh release list"); + expect(factoryText).toContain( + "No earlier compatible Product release has a complete valid bundle" + ); expect(readFileSync(workflowPath("desktop-release.yml"), "utf8")).not.toContain( "build-product-index.ts" ); expect(factoryText).toContain("product-channel.json"); + expect(factoryText).toContain("PRODUCT_SOURCE_ASSET"); expect(factoryText).toContain("pnpm release:artifacts validate-product"); expect(factoryText).toContain("factory-product.json"); expect(factoryText).toContain("factory-runtime"); @@ -468,7 +472,8 @@ describe("Desktop publication workflow", () => { expect(factoryText).toContain("accepted-product-previous-"); expect(factoryText).not.toMatch(/pnpm (?:build:desktop-runtime|build:wsl-runtime)/); expect(jobs["resolve-factory-product"].outputs).toMatchObject({ - current_product_index_sha256: "${{ steps.identity.outputs.current_product_index_sha256 }}", + current_product_source_asset: "${{ steps.identity.outputs.current_product_source_asset }}", + current_product_source_sha256: "${{ steps.identity.outputs.current_product_source_sha256 }}", current_product_bundle_artifact: "${{ steps.identity.outputs.current_product_bundle_artifact }}", previous_product_bundle_artifact: @@ -627,6 +632,9 @@ describe("Desktop publication workflow", () => { expect(step(promote, "Revalidate accepted Product stable pointer")?.run).toContain( "current-product/product-channel.json" ); + expect(step(promote, "Revalidate accepted Product stable pointer")?.run).toContain( + "ACCEPTED_PRODUCT_SOURCE_ASSET" + ); expect(step(promote, "Promote Desktop versioned release")?.run).toContain( "--prerelease=false --latest=false" ); diff --git a/scripts/select-product-index-release.test.ts b/scripts/select-product-index-release.test.ts index 53398ab4..495000c1 100644 --- a/scripts/select-product-index-release.test.ts +++ b/scripts/select-product-index-release.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import type { ProductChannel } from "../packages/desktop/src/product-channel.js"; import type { ProductIndex, ProductIndexRelease } from "../packages/desktop/src/product-index.js"; import { canonicalSigningPayload } from "../packages/desktop/src/signed-json.js"; import { selectProductIndexRelease } from "./select-product-index-release.js"; @@ -66,6 +67,37 @@ async function fixture() { value: sign(null, canonicalSigningPayload(unsigned), keys.privateKey).toString("base64"), }, }; + const stable = releases[2] as ProductIndexRelease; + const unsignedChannel: Omit = { + schemaVersion: 1, + channel: "product", + version: stable.version, + releaseTag: stable.releaseTag, + generatedAt: stable.publishedAt, + minShellVersion: stable.minShellVersion, + requirements: stable.requirements, + runtimes: { + "win32-x64": { + ...stable.runtimes["win32-x64"], + version: stable.version, + publishedAt: stable.publishedAt, + }, + "linux-x64": { + ...stable.runtimes["linux-x64"], + version: stable.version, + publishedAt: stable.publishedAt, + }, + }, + }; + const productChannel: ProductChannel = { + ...unsignedChannel, + signature: { + algorithm: "ed25519", + value: sign(null, canonicalSigningPayload(unsignedChannel), keys.privateKey).toString( + "base64" + ), + }, + }; const buildInfo = { schemaVersion: 1, shellVersion: "0.1.5", @@ -78,15 +110,18 @@ async function fixture() { dataSchemaVersion: 1, }; const indexFile = join(root, "product-index.json"); + const productChannelFile = join(root, "product-channel.json"); const buildInfoFile = join(root, "build-info.json"); const outputFile = join(root, "selected.json"); await Promise.all([ writeFile(indexFile, JSON.stringify(index)), + writeFile(productChannelFile, JSON.stringify(productChannel)), writeFile(buildInfoFile, JSON.stringify(buildInfo)), ]); return { publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), indexFile, + productChannelFile, buildInfoFile, outputFile, }; @@ -101,6 +136,16 @@ describe("Product index release selector", () => { }); }); + it("bootstraps selection from the signed stable Product channel", async () => { + const value = await fixture(); + await expect( + selectProductIndexRelease({ ...value, indexFile: value.productChannelFile }) + ).resolves.toMatchObject({ + version: "0.5.13", + releaseTag: "v0.5.13", + }); + }); + it("selects the immediately previous accepted Runtime without skipping incompatibility", async () => { const value = await fixture(); await expect( diff --git a/scripts/select-product-index-release.ts b/scripts/select-product-index-release.ts index 348a6946..e19025b5 100644 --- a/scripts/select-product-index-release.ts +++ b/scripts/select-product-index-release.ts @@ -2,17 +2,19 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { writeJsonFileAtomic } from "../packages/desktop/src/atomic-json-file.js"; import { parseDesktopBuildInfo } from "../packages/desktop/src/build-info.js"; +import { parseProductChannel } from "../packages/desktop/src/product-channel.js"; import { listCompatibleProductReleases, listProductReleases, type ProductCompatibilityHost, + type ProductReleaseSource, parseProductIndex, } from "../packages/desktop/src/product-index.js"; import { compareVersions } from "../packages/desktop/src/runtime-manifest.js"; import { error, success } from "./shared/index.js"; import { isDirectExecution } from "./shared/process.js"; -const DEFAULT_PRODUCT_INDEX_URL = +const DEFAULT_PRODUCT_SOURCE_URL = "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-index.json"; export interface SelectProductIndexReleaseOptions { @@ -22,11 +24,11 @@ export interface SelectProductIndexReleaseOptions { outputFile: string; requiredVersion?: string; previousToVersion?: string; - productIndexUrl?: string; + productSourceUrl?: string; } interface SelectProductIndexReleaseCommand - extends Omit { + extends Omit { publicKeyFile: string; } @@ -56,23 +58,39 @@ function compatibilityHost( }; } +function parseProductReleaseSource( + value: unknown, + publicKeyPem: string, + sourceUrl: string +): ProductReleaseSource { + const channel = + value && typeof value === "object" ? (value as Record).channel : undefined; + if (channel === "product-index") { + return parseProductIndex(value, publicKeyPem, sourceUrl); + } + if (channel === "product") { + return parseProductChannel(value, publicKeyPem, sourceUrl); + } + throw new Error("Product release selection source kind is unsupported"); +} + export async function selectProductIndexRelease( options: SelectProductIndexReleaseOptions ): Promise[number]> { if (options.requiredVersion && options.previousToVersion) { throw new Error("A required Product version cannot be combined with --previous-to-version"); } - const index = parseProductIndex( + const source = parseProductReleaseSource( await readJson(options.indexFile), options.publicKeyPem, - options.productIndexUrl ?? DEFAULT_PRODUCT_INDEX_URL + options.productSourceUrl ?? DEFAULT_PRODUCT_SOURCE_URL ); const buildInfo = parseDesktopBuildInfo(await readJson(options.buildInfoFile)); - const compatible = listCompatibleProductReleases(index, compatibilityHost(buildInfo)); + const compatible = listCompatibleProductReleases(source, compatibilityHost(buildInfo)); const selected = options.requiredVersion ? compatible.find((release) => release.version === options.requiredVersion) : options.previousToVersion - ? listProductReleases(index).find( + ? listProductReleases(source).find( (release) => compareVersions(release.version, options.previousToVersion as string) < 0 ) : compatible[0];