diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f4bb1bb5b..e42832181 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -18,6 +18,14 @@ on: required: false default: false type: boolean + executor_run_id: + description: >- + Publish Executor run id for this tag. Set by the automatic trigger; + the promote gate waits for exactly this run. Leave empty for manual + dispatches. + required: false + default: "" + type: string permissions: contents: read @@ -231,6 +239,8 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + # Read Publish Executor run state for the promote gate below. + actions: read steps: - name: Checkout validation script @@ -278,6 +288,52 @@ jobs: | xargs -0 -n1 -P8 -I{} sh -c \ 'echo "Uploading: $1"; exec gh release upload "$RELEASE_TAG" "$1" --repo "$GITHUB_REPOSITORY" --clobber' _ {} + # A published release must imply the npm packages for this tag exist. + # Publish Executor triggers this workflow before its npm publishes (to + # overlap them with the desktop build) and hands over its own run id, + # so wait for exactly that run to conclude successfully before going + # public. A run-name search would fail open whenever the run fell + # outside the listing window, so only an explicitly empty run id (a + # manual dispatch) skips the gate — the operator owns the invariant + # then. + - name: Wait for executor package publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXECUTOR_RUN_ID: ${{ inputs.executor_run_id }} + run: | + set -euo pipefail + if [ -z "$EXECUTOR_RUN_ID" ]; then + echo "No executor_run_id given (manual dispatch); skipping the publish gate." + exit 0 + fi + deadline=$((SECONDS + 1200)) + while :; do + run=$(gh run view "$EXECUTOR_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --json status,conclusion,url,displayTitle) + title=$(echo "$run" | jq -r .displayTitle) + if [ "$title" != "publish executor $RELEASE_TAG" ]; then + echo "Run $EXECUTOR_RUN_ID is '$title', not 'publish executor $RELEASE_TAG' — refusing to promote." + exit 1 + fi + status=$(echo "$run" | jq -r .status) + if [ "$status" = "completed" ]; then + conclusion=$(echo "$run" | jq -r .conclusion) + if [ "$conclusion" = "success" ]; then + echo "Publish Executor succeeded." + break + fi + echo "Publish Executor concluded '$conclusion' — leaving the release in draft." + echo "$run" | jq -r .url + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + echo "Timed out waiting for Publish Executor — leaving the release in draft." + exit 1 + fi + echo "Publish Executor status: $status; waiting..." + sleep 15 + done + # Flip draft → published only after every desktop asset is uploaded — # this is the atomic point where the new tag becomes "latest". - name: Promote release (draft → published) diff --git a/.github/workflows/publish-executor-package.yml b/.github/workflows/publish-executor-package.yml index fefdf8d89..b3d0d669e 100644 --- a/.github/workflows/publish-executor-package.yml +++ b/.github/workflows/publish-executor-package.yml @@ -83,19 +83,34 @@ jobs: - name: Run release checks run: bun run release:check - - name: Publish package and create release + # --skip-build reuses the artifacts release:check's dry-run just built + # in this same tree (validated against the release version) instead of + # rebuilding everything a second time. + - name: Stage release artifacts and draft GitHub release env: GH_TOKEN: ${{ github.token }} run: | export GITHUB_REF_TYPE=tag export GITHUB_REF_NAME="$RELEASE_TAG" export GITHUB_REF="refs/tags/$RELEASE_TAG" - bun run release:publish + bun run --cwd apps/cli src/release.ts --stage-only --skip-build + # Desktop needs only the tag and the draft release staged above — it + # compiles its own sidecar from the tag's source. Triggering it before + # the npm publishes overlaps its build with them; its release job waits + # for THIS run (by the id passed here) to succeed before promoting the + # release, so a failed npm publish still leaves the release in draft. - name: Trigger desktop build env: GH_TOKEN: ${{ github.token }} - run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" + run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" -f executor_run_id="$GITHUB_RUN_ID" + + - name: Publish executor to npm + run: | + export GITHUB_REF_TYPE=tag + export GITHUB_REF_NAME="$RELEASE_TAG" + export GITHUB_REF="refs/tags/$RELEASE_TAG" + bun run --cwd apps/cli src/release.ts --publish-only - name: Trigger self-host Docker publish env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 171b42a14..ea447113f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,7 @@ jobs: # triggers publish-executor-package.yml. A second workflow_dispatch path # races a duplicate publish for the same release. - # Desktop build downloads CLI binaries from the release, so it must - # run after CLI publish completes. Trigger it from the CLI workflow - # or manually via: gh workflow run publish-desktop.yml -f tag=vX.Y.Z + # Desktop compiles its own sidecar from the tag's source; it needs only + # the tag and the staged draft release, so publish-executor-package.yml + # triggers it as soon as those exist. Manual repair: + # gh workflow run publish-desktop.yml -f tag=vX.Y.Z diff --git a/apps/cli/src/release.ts b/apps/cli/src/release.ts index 4471fe56f..3f3331adc 100644 --- a/apps/cli/src/release.ts +++ b/apps/cli/src/release.ts @@ -5,8 +5,11 @@ import { fileURLToPath } from "node:url"; type ReleaseChannel = "latest" | "beta"; +type ReleaseMode = "full" | "dry-run" | "stage-only" | "publish-only"; + type ReleaseCliOptions = { - readonly dryRun: boolean; + readonly mode: ReleaseMode; + readonly skipBuild: boolean; }; type CommandInput = { @@ -31,18 +34,40 @@ const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const parseArgs = (argv: ReadonlyArray): ReleaseCliOptions => { - let dryRun = false; + let mode: ReleaseMode = "full"; + let skipBuild = false; for (const arg of argv) { - if (arg === "--dry-run") { - dryRun = true; + if (arg === "--skip-build") { + skipBuild = true; continue; } - throw new Error(`Unknown argument: ${arg}`); + const next: ReleaseMode | null = + arg === "--dry-run" + ? "dry-run" + : arg === "--stage-only" + ? "stage-only" + : arg === "--publish-only" + ? "publish-only" + : null; + if (next === null) { + throw new Error(`Unknown argument: ${arg}`); + } + if (mode !== "full") { + throw new Error(`--${next} conflicts with --${mode}; pass at most one mode flag`); + } + mode = next; } - return { dryRun }; + if (mode === "publish-only" && skipBuild) { + throw new Error("--skip-build is implied by --publish-only; drop it"); + } + if (mode === "dry-run" && skipBuild) { + throw new Error("--skip-build with --dry-run would validate artifacts and do nothing else"); + } + + return { mode, skipBuild }; }; const runCommand = async (input: CommandInput): Promise => { @@ -223,7 +248,7 @@ const syncGitHubRelease = async (input: { // Draft until publish-desktop.yml finishes uploading installers and flips // it; otherwise /releases/latest/download/ 404s during the - // ~15-20 min desktop build window. + // desktop build window. const args = [ "release", "create", @@ -249,6 +274,83 @@ const syncGitHubRelease = async (input: { }); }; +/** Locate the wrapper archive a previous build of this exact version left in + * dist/release. Used by --skip-build and --publish-only so a run that just + * built (release:check's dry-run, or a --stage-only step) is not repeated. + * Fails hard on any mismatch — reusing stale artifacts must never be a + * silent fallback. */ +const locateBuiltArtifacts = async (version: string): Promise => { + const wrapperPkgPath = join(wrapperDir, "package.json"); + if (!existsSync(wrapperPkgPath)) { + throw new Error(`No built wrapper package at ${wrapperPkgPath}; run without --skip-build.`); + } + + const wrapperPkg = (await Bun.file(wrapperPkgPath).json()) as { + version?: string; + optionalDependencies?: Record; + }; + if (wrapperPkg.version !== version) { + throw new Error( + `Built wrapper version ${wrapperPkg.version} does not match ${version}; run without --skip-build.`, + ); + } + + const expectedArchive = join(releaseDir, `executor-${version}.tgz`); + if (!existsSync(expectedArchive)) { + throw new Error(`Missing packed wrapper ${expectedArchive}; run without --skip-build.`); + } + + // The wrapper's optionalDependencies are the source of truth for which + // platform variants this release ships. `build.ts publish` globs the + // dist/executor-*/ directories and publishes whatever it finds, so a + // missing dir would ship a wrapper referencing a variant that never + // reached npm, and an extra stale dir (say a leftover beta variant) + // would be published alongside. Require the exact set, each at the exact + // aliased version, each with its release archive present. + const optional = wrapperPkg.optionalDependencies ?? {}; + const expectedVariants = Object.keys(optional).sort(); + if (expectedVariants.length === 0) { + throw new Error(`Built wrapper has no optionalDependencies; run without --skip-build.`); + } + + const variantDirs = [...new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })] + .map((entry) => dirname(entry)) + .sort(); + if (variantDirs.join(",") !== expectedVariants.join(",")) { + throw new Error( + `Platform variant dirs [${variantDirs.join(", ")}] do not match the wrapper's ` + + `optionalDependencies [${expectedVariants.join(", ")}]; run without --skip-build.`, + ); + } + + for (const name of expectedVariants) { + const spec = optional[name]!; + const aliasPrefix = "npm:executor@"; + if (!spec.startsWith(aliasPrefix)) { + throw new Error(`Unexpected optionalDependency spec for ${name}: ${spec}`); + } + const aliasVersion = spec.slice(aliasPrefix.length); + + const variantPkg = (await Bun.file(join(distDir, name, "package.json")).json()) as { + version?: string; + }; + if (variantPkg.version !== aliasVersion) { + throw new Error( + `${name} version ${variantPkg.version} does not match the wrapper's ` + + `${aliasVersion}; run without --skip-build.`, + ); + } + + if (!existsSync(join(distDir, `${name}.tar.gz`)) && !existsSync(join(distDir, `${name}.zip`))) { + throw new Error( + `Missing release archive for ${name} in ${distDir}; run without --skip-build.`, + ); + } + } + + return expectedArchive; +}; + const main = async () => { const options = parseArgs(process.argv.slice(2)); const version = await readVersion(); @@ -262,22 +364,38 @@ const main = async () => { throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`); } - await rm(releaseDir, { recursive: true, force: true }); - await mkdir(releaseDir, { recursive: true }); + if (options.mode === "publish-only") { + await locateBuiltArtifacts(version); + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "publish", channel], + cwd: cliRoot, + }); + return; + } - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "binary"], - cwd: cliRoot, - }); + let wrapperArchivePath: string; + if (options.skipBuild) { + wrapperArchivePath = await locateBuiltArtifacts(version); + } else { + await rm(releaseDir, { recursive: true, force: true }); + await mkdir(releaseDir, { recursive: true }); - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "release-assets"], - cwd: cliRoot, - }); + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "binary"], + cwd: cliRoot, + }); + + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "release-assets"], + cwd: cliRoot, + }); + + wrapperArchivePath = await packWrapperPackage(); + } - const wrapperArchivePath = await packWrapperPackage(); const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath); console.log(`Prepared executor@${version} for ${channel}`); @@ -285,7 +403,7 @@ const main = async () => { console.log(`- ${assetPath}`); } - if (options.dryRun) { + if (options.mode === "dry-run") { return; } @@ -295,6 +413,10 @@ const main = async () => { assetPaths, }); + if (options.mode === "stage-only") { + return; + } + await runCommand({ command: "bun", args: ["run", "src/build.ts", "publish", channel], diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index 1fa543439..62034b988 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -273,12 +273,20 @@ const applyPublishConfig = async (pkgDir: string): Promise<() => Promise> }; }; -const publishPackage = async ( +type PackedPackage = { + readonly pkgDir: string; + readonly name: string; + readonly version: string; + readonly channel: Channel; + readonly tarball: string; +}; + +const packPackage = async ( pkgDir: string, dryRun: boolean, publishable: ReadonlySet, publishableVersions: ReadonlyMap, -) => { +): Promise => { const { name, version } = await readPackageMeta(pkgDir); const channel = resolveChannel(version); @@ -314,26 +322,62 @@ const publishPackage = async ( `Expected exactly 1 .tgz in ${pkgDir}, found ${produced.length}: ${produced.join(", ")}`, ); } - const tarball = produced[0]!; - if (dryRun) { - return; - } + return { pkgDir, name, version, channel, tarball: produced[0]! }; +}; +const publishPacked = async (packed: PackedPackage) => { // Skip publishing already-shipped versions. The pack still ran above so // smoke tests / pkg-pr-new previews always have a fresh tarball. - if (await packageAlreadyPublished(name, version)) { - console.log(`[skip] ${name}@${version} already on npm`); + if (await packageAlreadyPublished(packed.name, packed.version)) { + console.log(`[skip] ${packed.name}@${packed.version} already on npm`); return; } - console.log(`[publish] ${name}@${version} (${channel})`); + console.log(`[publish] ${packed.name}@${packed.version} (${packed.channel})`); - const args = ["publish", tarball, "--access", "public", "--tag", channel]; + const args = ["publish", packed.tarball, "--access", "public", "--tag", packed.channel]; if (process.env.GITHUB_ACTIONS === "true") { args.push("--provenance"); } - await $`npm ${args}`.cwd(pkgDir); + + // Buffer output and replay it prefixed: several publishes run at once and + // interleaved npm output is unreadable. + const result = await $`npm ${args}`.cwd(packed.pkgDir).nothrow().quiet(); + const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim(); + if (output.length > 0) { + console.log(output.replace(/^/gm, `[${packed.name}] `)); + } + if (result.exitCode !== 0) { + throw new Error(`npm publish failed for ${packed.name} (exit ${result.exitCode})`); + } +}; + +/** Publish concurrently: these are distinct packages, so no two publishes + * touch the same npm packument (the 409 hazard that forces the executor CLI + * platform variants to publish serially does not apply here). Each publish + * is network + sigstore dominated (~15-25s), so this is where the release + * job's minutes go. */ +const PUBLISH_CONCURRENCY = 4; + +const publishAll = async (packed: ReadonlyArray) => { + const queue = [...packed]; + const failures: string[] = []; + + const workers = Array.from({ length: Math.min(PUBLISH_CONCURRENCY, queue.length) }, async () => { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + try { + await publishPacked(next); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + }); + await Promise.all(workers); + + if (failures.length > 0) { + throw new Error(`${failures.length} package(s) failed to publish:\n${failures.join("\n")}`); + } }; /** @@ -386,9 +430,21 @@ const main = async () => { return; } + // Pack sequentially: packing rewrites each package.json in place and `bun + // pm pack` resolves against the shared workspace tree, so overlapping packs + // could observe each other's temporary manifests. + const packed: PackedPackage[] = []; for (const relDir of PUBLIC_PACKAGE_DIRS) { - await publishPackage(join(repoRoot, relDir), dryRun, publishable, publishableVersions); + packed.push( + await packPackage(join(repoRoot, relDir), dryRun, publishable, publishableVersions), + ); } + + if (dryRun) { + return; + } + + await publishAll(packed); }; await main();