diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92fc1d48..f40afa1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,11 @@ jobs: platform: mac target: dmg arch: arm64 + - label: macOS x64 + runner: macos-13 + platform: mac + target: dmg + arch: x64 - label: Linux x64 runner: ubuntu-24.04 platform: linux @@ -223,8 +228,11 @@ jobs: "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then args+=(--signed --require-signed) + elif [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then + echo "Stable Windows releases require Azure Trusted Signing secrets." >&2 + exit 1 else - echo "Azure Trusted Signing secrets not configured; building unsigned Windows artifact." >&2 + echo "Azure Trusted Signing secrets not configured; building unsigned Windows prerelease artifact." >&2 fi fi @@ -375,6 +383,13 @@ jobs: merge-multiple: true path: release-assets + - name: Merge macOS updater manifests + run: > + node scripts/merge-mac-update-manifests.ts + release-assets/latest-mac.yml + release-assets/latest-mac-x64.yml + release-assets/latest-mac.yml + - name: Stage release documentation env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} diff --git a/docs/release.md b/docs/release.md index a04ea778..33027588 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,13 +2,13 @@ Canonical release process documentation for OK Code. -**Last updated:** 2026-04-20 +**Last updated:** 2026-04-25 ## Overview The next stable train ships one semver across desktop, CLI, and iOS surfaces: -- macOS arm64 desktop DMG plus updater metadata +- macOS arm64 and x64 desktop DMGs plus updater metadata - Windows x64 signed NSIS installer - Linux x64 AppImage - iOS TestFlight build from the same release tag, dispatched separately @@ -19,7 +19,7 @@ The next stable train ships one semver across desktop, CLI, and iOS surfaces: ## Defaults - iOS is TestFlight-only for this release train. -- Intel mac is non-blocking and runs in the separate `Desktop Intel Compatibility` workflow. +- Both macOS architectures are blocking for the main desktop release, and the published `latest-mac.yml` manifest must contain arm64 and x64 payloads. - Android is non-blocking. - Windows stable support requires signing. Do not ship unsigned Windows artifacts as stable. @@ -79,6 +79,16 @@ bun run release:validate This checks documentation completeness, version alignment, git state, iOS project version, and optionally runs all quality gates. Use `--skip-quality` for a docs-only pass or `--ci` for CI pipelines. +### One-shot release shipping + +Use the end-to-end release command for the normal desktop + CLI train: + +```bash +bun run release:ship +``` + +This command runs local preflight, invokes release preparation, pushes the release tag, waits for `release.yml`, and verifies the published GitHub Release assets plus the merged macOS OTA manifest before returning success. + ## Platform matrix Blocking stable matrix: @@ -86,24 +96,27 @@ Blocking stable matrix: | Surface | Runner | Artifact | Blocking | | ----------- | -------------- | --------------------------------------- | -------- | | macOS arm64 | `macos-14` | signed/notarized DMG + updater metadata | yes | +| macOS x64 | `macos-13` | signed/notarized DMG + updater metadata | yes | | Windows x64 | `windows-2022` | signed NSIS installer | yes | | Linux x64 | `ubuntu-24.04` | AppImage | yes | | iOS | `macos-14` | TestFlight upload | separate | | CLI | `ubuntu-24.04` | npm publish | yes | -Non-blocking compatibility lane: +Optional manual rebuild lane: -| Surface | Workflow | Artifact | -| --------- | --------------------------------------------------------------------------- | --------- | -| macOS x64 | [`release-intel-compat.yml`](../.github/workflows/release-intel-compat.yml) | Intel DMG | +| Surface | Workflow | Artifact | +| --------- | --------------------------------------------------------------------------- | ----------------- | +| macOS x64 | [`release-intel-compat.yml`](../.github/workflows/release-intel-compat.yml) | Intel DMG rebuild | ## Desktop release requirements - Build artifacts with `bun run dist:desktop:artifact`. - Refuse macOS stable release builds unless signing and notarization secrets are present. - Refuse Windows stable release builds unless Azure Trusted Signing secrets are present. +- Publish both macOS arm64 and x64 DMG/ZIP payloads from the main `release.yml` workflow. +- Merge `latest-mac.yml` and `latest-mac-x64.yml` into one published `latest-mac.yml` before creating the GitHub Release. - Validate packaged outputs before upload: - - macOS: DMG exists and updater manifest exists + - macOS: both arch-specific DMGs exist and updater manifests are present - Windows: installer exists - Linux: AppImage exists - Keep `bun run test:desktop-smoke` and `bun run release:smoke` green before tagging. diff --git a/docs/superpowers/plans/2026-04-25-release-ship-and-ota.md b/docs/superpowers/plans/2026-04-25-release-ship-and-ota.md new file mode 100644 index 00000000..932e01a9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-release-ship-and-ota.md @@ -0,0 +1,208 @@ +# Release Ship And OTA Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a single release command that prepares and ships a release end-to-end, then verifies the published desktop OTA assets before reporting success. + +**Architecture:** Keep existing release preparation and validation scripts as building blocks, then add a thin orchestration script that runs preflight, drives the tag push path, waits for the GitHub Actions release workflow, and verifies the live GitHub Release contents through `gh`. Tighten the GitHub Actions release workflow so stable desktop publishing always includes both macOS architectures and a merged updater manifest, with asset validation failing closed if OTA coverage is incomplete. + +**Tech Stack:** Node.js scripts, GitHub CLI, GitHub Actions YAML, Vitest, Bun workspace scripts + +--- + +### Task 1: Add the release shipping orchestrator + +**Files:** + +- Create: `scripts/release-ship.ts` +- Modify: `package.json` +- Test: `scripts/release-ship.test.ts` + +- [ ] **Step 1: Write the failing tests for release shipping orchestration** + +```ts +describe("release-ship", () => { + it("runs validation, preparation, waits for release workflow, and verifies OTA assets", () => { + // Assert the command order and that the verifier checks the live release. + }); + + it("fails when the published release is missing required OTA coverage", () => { + // Assert missing x64 mac assets or merged manifest causes a throw. + }); +}); +``` + +- [ ] **Step 2: Run the script tests to verify the new cases fail** + +Run: `bun run --cwd scripts test` +Expected: FAIL with missing `scripts/release-ship.ts` exports or missing test expectations. + +- [ ] **Step 3: Implement `scripts/release-ship.ts` with explicit phases** + +```ts +run("node", ["scripts/pre-release-validate.ts", version, "--ci"]); +run("node", ["scripts/prepare-release.ts", version, "--skip-checks"]); +const runId = await waitForWorkflowRun({ workflow: "release.yml", tag: `v${version}` }); +await watchWorkflowRun(runId); +await verifyPublishedReleaseAssets({ tag: `v${version}` }); +``` + +- [ ] **Step 4: Add a single package entry point** + +```json +"release:ship": "node scripts/release-ship.ts" +``` + +- [ ] **Step 5: Re-run the script tests** + +Run: `bun run --cwd scripts test` +Expected: PASS for the new `release-ship` tests. + +### Task 2: Make stable desktop publishing fail closed on OTA completeness + +**Files:** + +- Modify: `.github/workflows/release.yml` +- Modify: `scripts/validate-release-assets.ts` +- Test: `scripts/validate-release-assets.test.ts` +- Test: `scripts/release-smoke.ts` + +- [ ] **Step 1: Write the failing asset validation tests** + +```ts +it("requires both macOS arm64 and x64 desktop payloads for coordinated releases", () => { + assert.throws(() => validateReleaseAssets([...missingX64]), /macOS x64 DMG/); +}); + +it("requires a merged latest-mac.yml manifest alongside the dual-arch payloads", () => { + assert.throws(() => validateReleaseAssets([...missingManifest]), /macOS updater manifest/); +}); +``` + +- [ ] **Step 2: Run the script tests to verify the new asset expectations fail** + +Run: `bun run --cwd scripts test` +Expected: FAIL because `validateReleaseAssets` does not yet require dual-arch mac assets. + +- [ ] **Step 3: Tighten release asset validation** + +```ts +{ + label: "macOS arm64 DMG", + matches: (assetName) => assetName.endsWith("-arm64.dmg"), +} +``` + +- [ ] **Step 4: Update `release.yml` to build both macOS architectures and merge manifests before publish** + +```yml +- label: macOS arm64 + runner: macos-14 + platform: mac + arch: arm64 +- label: macOS x64 + runner: macos-13 + platform: mac + arch: x64 +``` + +```yml +- name: Merge macOS update manifests + run: node scripts/merge-mac-update-manifests.ts release-assets/latest-mac.yml release-assets/latest-mac-x64.yml release-assets/latest-mac.yml +``` + +- [ ] **Step 5: Update release smoke fixtures to model the new dual-arch mac release contract** + +```ts +writeReleaseAssetFixtures([ + "OK-Code-9.9.9-smoke.0-arm64.dmg", + "OK-Code-9.9.9-smoke.0-x64.dmg", + "OK-Code-9.9.9-smoke.0-arm64.zip", + "OK-Code-9.9.9-smoke.0-x64.zip", +]); +``` + +- [ ] **Step 6: Re-run the script tests** + +Run: `bun run --cwd scripts test` +Expected: PASS for updated asset validation and smoke coverage. + +### Task 3: Verify the live published release contract + +**Files:** + +- Modify: `scripts/release-ship.ts` +- Test: `scripts/release-ship.test.ts` +- Modify: `docs/release.md` + +- [ ] **Step 1: Add failing tests for published release inspection** + +```ts +it("checks GitHub Release assets for dual-arch mac OTA payloads after workflow success", () => { + // Assert `gh release view` or `gh api` output is parsed and validated. +}); +``` + +- [ ] **Step 2: Run the script tests to verify the published-release checks fail** + +Run: `bun run --cwd scripts test` +Expected: FAIL until `release-ship.ts` validates live release assets. + +- [ ] **Step 3: Implement release verification and operator-facing docs** + +```ts +const assets = await listReleaseAssets(tag); +validateReleaseAssets(assets); +validateMergedMacManifest(await downloadReleaseAsset(tag, "latest-mac.yml")); +``` + +```md +Run `bun run release:ship ` to perform local preflight, push the tag, wait for `release.yml`, and verify OTA assets on the published GitHub Release. +``` + +- [ ] **Step 4: Re-run script tests** + +Run: `bun run --cwd scripts test` +Expected: PASS for release shipping orchestration and live-release verification logic. + +### Task 4: Workspace verification + +**Files:** + +- Modify: `package.json` +- Modify: `scripts/package.json` +- Modify: `.github/workflows/release.yml` +- Modify: `docs/release.md` +- Modify: `scripts/release-smoke.ts` +- Modify: `scripts/validate-release-assets.ts` +- Modify: `scripts/validate-release-assets.test.ts` +- Create: `scripts/release-ship.ts` +- Create: `scripts/release-ship.test.ts` + +- [ ] **Step 1: Run formatting** + +Run: `bun run fmt` +Expected: exit 0 + +- [ ] **Step 2: Run lint** + +Run: `bun run lint` +Expected: exit 0 + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: exit 0 + +- [ ] **Step 4: Run focused script tests and release smoke** + +Run: `bun run --cwd scripts test` +Expected: PASS + +Run: `bun run release:smoke` +Expected: PASS + +- [ ] **Step 5: Run the full required workspace verification** + +Run: `bun run fmt && bun run lint && bun run typecheck` +Expected: all exit 0 diff --git a/package.json b/package.json index 5e8b0b30..4d9e8434 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "release:prepare": "node scripts/prepare-release.ts", "release:validate": "node scripts/pre-release-validate.ts", "release:smoke": "node scripts/release-smoke.ts", + "release:ship": "node scripts/release-ship.ts", "dist:mobile:build": "bun run --cwd apps/mobile build", "dist:mobile:sync:ios": "cd apps/mobile && bunx cap sync ios --deployment", "patch:capacitor-local-notifications": "node scripts/patch-capacitor-local-notifications.ts", diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 14ad772c..e84d0d18 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -388,10 +388,10 @@ Step-by-step playbook for the v${version} release. Each phase must complete befo - [ ] \`okcode-CHANGELOG.md\` is attached. - [ ] \`okcode-RELEASE-NOTES.md\` is attached. - [ ] \`okcode-ASSETS-MANIFEST.md\` is attached. -- [ ] macOS release artifacts are attached: DMG, ZIP, updater manifest, and blockmaps. +- [ ] macOS arm64 release artifacts are attached: DMG, ZIP, updater manifest coverage, and blockmaps. +- [ ] macOS x64 release artifacts are attached: DMG, ZIP, updater manifest coverage, and blockmaps. - [ ] Linux release artifacts are attached: AppImage and updater manifest if generated. - [ ] Windows release artifacts are attached: installer, updater manifest, and blockmaps. -- [ ] If the Intel compatibility workflow is run, confirm the x64 macOS DMG is attached separately. ## Phase 2: Post-release verification @@ -405,7 +405,6 @@ Step-by-step playbook for the v${version} release. Each phase must complete befo ## Phase 3: Follow-through -- [ ] Trigger the Intel compatibility workflow if macOS x64 artifacts are required for this train. - [ ] Update external release references or announcements. - [ ] Monitor reports for regressions in provider onboarding, auth flows, release packaging, and cross-platform install/update behavior. `; diff --git a/scripts/release-ship.test.ts b/scripts/release-ship.test.ts new file mode 100644 index 00000000..135e5cd0 --- /dev/null +++ b/scripts/release-ship.test.ts @@ -0,0 +1,152 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { findWorkflowRunForTag, runReleaseShip, validatePublishedRelease } from "./release-ship.ts"; + +const dualArchReleaseAssets = [ + { name: "OK-Code-1.2.3-arm64.dmg" }, + { name: "OK-Code-1.2.3-arm64.zip" }, + { name: "OK-Code-1.2.3-arm64.zip.blockmap" }, + { name: "OK-Code-1.2.3-x64.dmg" }, + { name: "OK-Code-1.2.3-x64.zip" }, + { name: "OK-Code-1.2.3-x64.zip.blockmap" }, + { name: "OK-Code-1.2.3-x86_64.AppImage" }, + { name: "latest-linux.yml" }, + { name: "OK-Code-1.2.3.exe" }, + { name: "OK-Code-1.2.3.exe.blockmap" }, + { name: "latest.yml" }, + { name: "latest-mac.yml" }, + { name: "okcode-CHANGELOG.md" }, + { name: "okcode-RELEASE-NOTES.md" }, + { name: "okcode-ASSETS-MANIFEST.md" }, +] as const; + +const mergedMacManifest = `version: 1.2.3 +files: + - url: OK-Code-1.2.3-arm64.zip + sha512: arm64zip + size: 101 + - url: OK-Code-1.2.3-arm64.dmg + sha512: arm64dmg + size: 102 + - url: OK-Code-1.2.3-x64.zip + sha512: x64zip + size: 103 + - url: OK-Code-1.2.3-x64.dmg + sha512: x64dmg + size: 104 +releaseDate: '2026-04-25T12:00:00Z' +`; + +describe("findWorkflowRunForTag", () => { + it("selects the workflow run matching the pushed release tag", () => { + const run = findWorkflowRunForTag( + [ + { + id: 10, + headBranch: "main", + status: "completed", + conclusion: "success", + createdAt: "2026-04-25T10:00:00Z", + }, + { + id: 11, + headBranch: "v1.2.3", + status: "in_progress", + conclusion: null, + createdAt: "2026-04-25T10:01:00Z", + }, + ], + "v1.2.3", + ); + + assert.deepStrictEqual(run, { + id: 11, + headBranch: "v1.2.3", + status: "in_progress", + conclusion: null, + createdAt: "2026-04-25T10:01:00Z", + }); + }); +}); + +describe("validatePublishedRelease", () => { + it("accepts a published release with dual-arch mac OTA assets and a merged manifest", () => { + assert.doesNotThrow(() => validatePublishedRelease(dualArchReleaseAssets, mergedMacManifest)); + }); + + it("rejects published releases whose merged mac manifest does not include x64 payloads", () => { + assert.throws( + () => + validatePublishedRelease( + dualArchReleaseAssets, + mergedMacManifest.replace("OK-Code-1.2.3-x64.zip", "OK-Code-1.2.3-missing.zip"), + ), + /merged macOS updater manifest/i, + ); + }); +}); + +describe("runReleaseShip", () => { + it.effect( + "runs validation, preparation, workflow waiting, and published release verification", + () => + Effect.promise(async () => { + const calls: string[] = []; + + await runReleaseShip( + { version: "1.2.3", timeoutMs: 50, pollIntervalMs: 1 }, + { + log: () => undefined, + sleep: async () => undefined, + releaseSteps: { + ensureGitHubAuth: async () => { + calls.push("gh-auth"); + }, + runPreReleaseValidate: async (version) => { + calls.push(`validate:${version}`); + }, + runPrepareRelease: async (version) => { + calls.push(`prepare:${version}`); + }, + }, + github: { + listReleaseWorkflowRuns: async () => { + calls.push("list-runs"); + return [ + { + id: 42, + headBranch: "v1.2.3", + status: "completed", + conclusion: "success", + createdAt: "2026-04-25T12:00:00Z", + }, + ]; + }, + watchWorkflowRun: async (runId) => { + calls.push(`watch:${runId}`); + }, + getReleaseAssets: async (tag) => { + calls.push(`assets:${tag}`); + return dualArchReleaseAssets; + }, + downloadReleaseAssetText: async (tag, assetName) => { + calls.push(`download:${tag}:${assetName}`); + return mergedMacManifest; + }, + }, + }, + ); + + assert.deepStrictEqual(calls, [ + "gh-auth", + "validate:1.2.3", + "prepare:1.2.3", + "list-runs", + "watch:42", + "assets:v1.2.3", + "download:v1.2.3:latest-mac.yml", + ]); + }), + ); +}); diff --git a/scripts/release-ship.ts b/scripts/release-ship.ts new file mode 100644 index 00000000..b3ec71e5 --- /dev/null +++ b/scripts/release-ship.ts @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseMacUpdateManifest } from "./merge-mac-update-manifests.ts"; +import { validateReleaseAssets } from "./validate-release-assets.ts"; + +const DEFAULT_GH_REPO = process.env.OKCODE_RELEASE_GH_REPO?.trim() || "OpenKnots/okcode"; +const DEFAULT_WORKFLOW_ID = "release.yml"; +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; +const DEFAULT_POLL_INTERVAL_MS = 5_000; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export interface WorkflowRunSummary { + readonly id: number; + readonly headBranch: string | null; + readonly status: string; + readonly conclusion: string | null; + readonly createdAt: string; +} + +export interface ReleaseAssetSummary { + readonly name: string; + readonly apiUrl?: string | undefined; +} + +export interface ReleaseShipOptions { + readonly version: string; + readonly timeoutMs?: number; + readonly pollIntervalMs?: number; +} + +export interface ReleaseShipDeps { + readonly log: (message: string) => void; + readonly sleep: (ms: number) => Promise; + readonly releaseSteps: { + readonly ensureGitHubAuth: () => Promise; + readonly runPreReleaseValidate: (version: string) => Promise; + readonly runPrepareRelease: (version: string) => Promise; + }; + readonly github: { + readonly listReleaseWorkflowRuns: () => Promise; + readonly watchWorkflowRun: (runId: number) => Promise; + readonly getReleaseAssets: (tag: string) => Promise; + readonly downloadReleaseAssetText: (tag: string, assetName: string) => Promise; + }; +} + +function runCommand(command: string, args: readonly string[]): string { + return execFileSync(command, [...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function getReleaseAssets(repo: string, tag: string): ReleaseAssetSummary[] { + const payload = parseJson<{ assets: Array> }>( + runCommand("gh", ["release", "view", tag, "--repo", repo, "--json", "assets"]), + "release assets", + ); + + return payload.assets.map((asset) => ({ + name: String(asset.name), + apiUrl: typeof asset.apiUrl === "string" ? asset.apiUrl : undefined, + })); +} + +function parseJson(raw: string, label: string): T { + try { + return JSON.parse(raw) as T; + } catch (error) { + throw new Error( + `Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`, + { + cause: error, + }, + ); + } +} + +export function findWorkflowRunForTag( + runs: readonly WorkflowRunSummary[], + tag: string, +): WorkflowRunSummary | null { + return ( + [...runs] + .filter((run) => run.headBranch === tag) + .toSorted((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0] ?? + null + ); +} + +export function validatePublishedRelease( + assets: readonly ReleaseAssetSummary[], + latestMacManifest: string, +): void { + validateReleaseAssets(assets.map((asset) => asset.name)); + + const manifest = parseMacUpdateManifest(latestMacManifest, "latest-mac.yml"); + const fileUrls = new Set(manifest.files.map((file) => file.url)); + const requiredFilePatterns = [/-arm64\.zip$/, /-arm64\.dmg$/, /-x64\.zip$/, /-x64\.dmg$/]; + + const missing = requiredFilePatterns.filter( + (pattern) => ![...fileUrls].some((url) => pattern.test(url)), + ); + + if (missing.length > 0) { + throw new Error( + `Published release has an incomplete merged macOS updater manifest: missing ${missing.map((pattern) => pattern.source).join(", ")}.`, + ); + } +} + +async function waitForReleaseWorkflowRun( + tag: string, + options: Required>, + deps: ReleaseShipDeps, +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt <= options.timeoutMs) { + const run = findWorkflowRunForTag(await deps.github.listReleaseWorkflowRuns(), tag); + if (run) { + return run; + } + await deps.sleep(options.pollIntervalMs); + } + + throw new Error( + `Timed out after ${options.timeoutMs}ms waiting for ${DEFAULT_WORKFLOW_ID} to start for ${tag}.`, + ); +} + +export async function runReleaseShip( + options: ReleaseShipOptions, + deps: ReleaseShipDeps, +): Promise { + const tag = `v${options.version.replace(/^v/, "")}`; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + deps.log(`[release-ship] Authenticating GitHub CLI...`); + await deps.releaseSteps.ensureGitHubAuth(); + + deps.log(`[release-ship] Running local preflight for ${tag}...`); + await deps.releaseSteps.runPreReleaseValidate(options.version); + + deps.log(`[release-ship] Preparing and pushing ${tag}...`); + await deps.releaseSteps.runPrepareRelease(options.version); + + deps.log(`[release-ship] Waiting for ${DEFAULT_WORKFLOW_ID} to start for ${tag}...`); + const workflowRun = await waitForReleaseWorkflowRun(tag, { timeoutMs, pollIntervalMs }, deps); + + deps.log(`[release-ship] Watching workflow run ${workflowRun.id}...`); + await deps.github.watchWorkflowRun(workflowRun.id); + + deps.log(`[release-ship] Verifying published release assets for ${tag}...`); + const assets = await deps.github.getReleaseAssets(tag); + const latestMacManifest = await deps.github.downloadReleaseAssetText(tag, "latest-mac.yml"); + validatePublishedRelease(assets, latestMacManifest); +} + +function createReleaseShipDeps(repo: string): ReleaseShipDeps { + return { + log: (message) => { + console.log(message); + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + releaseSteps: { + ensureGitHubAuth: async () => { + runCommand("gh", ["auth", "status"]); + }, + runPreReleaseValidate: async (version) => { + execFileSync( + process.execPath, + [resolve(repoRoot, "scripts/pre-release-validate.ts"), version, "--ci"], + { stdio: "inherit", cwd: repoRoot }, + ); + }, + runPrepareRelease: async (version) => { + execFileSync( + process.execPath, + [resolve(repoRoot, "scripts/prepare-release.ts"), version, "--skip-checks"], + { stdio: "inherit", cwd: repoRoot }, + ); + }, + }, + github: { + listReleaseWorkflowRuns: async () => { + const payload = parseJson<{ workflow_runs: Array> }>( + runCommand("gh", [ + "api", + `repos/${repo}/actions/workflows/${DEFAULT_WORKFLOW_ID}/runs?per_page=20`, + ]), + "release workflow runs", + ); + + return payload.workflow_runs.map((run) => ({ + id: Number(run.id), + headBranch: typeof run.head_branch === "string" ? run.head_branch : null, + status: typeof run.status === "string" ? run.status : "unknown", + conclusion: typeof run.conclusion === "string" ? run.conclusion : null, + createdAt: + typeof run.created_at === "string" ? run.created_at : new Date(0).toISOString(), + })); + }, + watchWorkflowRun: async (runId) => { + execFileSync("gh", ["run", "watch", String(runId), "--repo", repo, "--exit-status"], { + stdio: "inherit", + }); + }, + getReleaseAssets: async (tag) => getReleaseAssets(repo, tag), + downloadReleaseAssetText: async (tag, assetName) => { + const assets = getReleaseAssets(repo, tag); + const asset = assets.find((entry) => entry.name === assetName); + if (!asset?.apiUrl) { + throw new Error( + `Could not resolve GitHub API URL for release asset '${assetName}' on ${tag}.`, + ); + } + + return runCommand("gh", [ + "api", + "-H", + "Accept: application/octet-stream", + asset.apiUrl.replace("https://api.github.com/", ""), + ]); + }, + }, + }; +} + +function printHelp(): void { + console.log(`release-ship — run release preflight, ship the tag, wait for GitHub Actions, and verify OTA assets. + +Usage: + node scripts/release-ship.ts [flags] + +Flags: + --repo GitHub repository to inspect (default: ${DEFAULT_GH_REPO}) + --timeout-ms Max time to wait for release workflow discovery + --poll-interval-ms Poll interval while waiting for workflow discovery + --help Show this message +`); +} + +function parseArgs(argv: readonly string[]): { + version: string; + repo: string; + timeoutMs: number; + pollIntervalMs: number; +} { + let version: string | undefined; + let repo = DEFAULT_GH_REPO; + let timeoutMs = DEFAULT_TIMEOUT_MS; + let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument) continue; + + switch (argument) { + case "--help": + case "-h": + printHelp(); + process.exit(0); + break; + case "--repo": + repo = argv[index + 1] ?? repo; + index += 1; + break; + case "--timeout-ms": { + const rawTimeout = argv[index + 1]; + const parsed = Number(rawTimeout); + if (!rawTimeout || !Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `--timeout-ms requires a positive finite number (got: ${rawTimeout ?? ""}).`, + ); + } + timeoutMs = parsed; + index += 1; + break; + } + case "--poll-interval-ms": { + const rawInterval = argv[index + 1]; + const parsed = Number(rawInterval); + if (!rawInterval || !Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `--poll-interval-ms requires a positive finite number (got: ${rawInterval ?? ""}).`, + ); + } + pollIntervalMs = parsed; + index += 1; + break; + } + default: + if (argument.startsWith("--")) { + throw new Error(`Unknown argument: ${argument}`); + } + if (version !== undefined) { + throw new Error("Only one release version may be provided."); + } + version = argument.replace(/^v/, ""); + break; + } + } + + if (!version) { + throw new Error( + "Usage: node scripts/release-ship.ts [--repo ] [--timeout-ms ] [--poll-interval-ms ]", + ); + } + + return { version, repo, timeoutMs, pollIntervalMs }; +} + +const isMain = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const parsed = parseArgs(process.argv.slice(2)); + runReleaseShip( + { + version: parsed.version, + timeoutMs: parsed.timeoutMs, + pollIntervalMs: parsed.pollIntervalMs, + }, + createReleaseShipDeps(parsed.repo), + ).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index e38184e1..1f56dce6 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -78,6 +78,9 @@ function writeReleaseAssetFixtures(targetRoot: string): void { "OK-Code-9.9.9-smoke.0-arm64.dmg", "OK-Code-9.9.9-smoke.0-arm64.zip", "OK-Code-9.9.9-smoke.0-arm64.zip.blockmap", + "OK-Code-9.9.9-smoke.0-x64.dmg", + "OK-Code-9.9.9-smoke.0-x64.zip", + "OK-Code-9.9.9-smoke.0-x64.zip.blockmap", "OK-Code-9.9.9-smoke.0.AppImage", "OK-Code-9.9.9-smoke.0.exe", "OK-Code-9.9.9-smoke.0.exe.blockmap", @@ -150,6 +153,11 @@ try { "OK-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset.", ); + assertContains( + mergedManifest, + "OK-Code-9.9.9-smoke.0-x64.dmg", + "Merged manifest is missing the x64 DMG asset.", + ); execFileSync( process.execPath, diff --git a/scripts/validate-release-assets.test.ts b/scripts/validate-release-assets.test.ts index 6f28a70d..314b4997 100644 --- a/scripts/validate-release-assets.test.ts +++ b/scripts/validate-release-assets.test.ts @@ -7,6 +7,9 @@ describe("validateReleaseAssets", () => { "OK-Code-0.24.0-arm64.dmg", "OK-Code-0.24.0-arm64.zip", "OK-Code-0.24.0-arm64.zip.blockmap", + "OK-Code-0.24.0-x64.dmg", + "OK-Code-0.24.0-x64.zip", + "OK-Code-0.24.0-x64.zip.blockmap", "OK-Code-0.24.0.AppImage", "OK-Code-0.24.0.exe", "OK-Code-0.24.0.exe.blockmap", @@ -29,6 +32,16 @@ describe("validateReleaseAssets", () => { ); }); + it("rejects releases that are missing macOS x64 OTA assets", () => { + assert.throws( + () => + validateReleaseAssets( + completeAssetSet.filter((asset) => asset !== "OK-Code-0.24.0-x64.zip"), + ), + /Missing required release assets: macOS x64 ZIP payload/, + ); + }); + it("rejects releases that are missing required documentation attachments", () => { assert.throws( () => diff --git a/scripts/validate-release-assets.ts b/scripts/validate-release-assets.ts index 5b56b106..a9bf36e4 100644 --- a/scripts/validate-release-assets.ts +++ b/scripts/validate-release-assets.ts @@ -11,16 +11,28 @@ interface RequiredAssetRule { const REQUIRED_ASSET_RULES: readonly RequiredAssetRule[] = [ { - label: "macOS DMG", - matches: (assetName) => assetName.endsWith(".dmg"), + label: "macOS arm64 DMG", + matches: (assetName) => assetName.endsWith("-arm64.dmg"), }, { - label: "macOS ZIP payload", - matches: (assetName) => assetName.endsWith(".zip"), + label: "macOS arm64 ZIP payload", + matches: (assetName) => assetName.endsWith("-arm64.zip"), }, { - label: "macOS ZIP blockmap", - matches: (assetName) => assetName.endsWith(".zip.blockmap"), + label: "macOS arm64 ZIP blockmap", + matches: (assetName) => assetName.endsWith("-arm64.zip.blockmap"), + }, + { + label: "macOS x64 DMG", + matches: (assetName) => assetName.endsWith("-x64.dmg"), + }, + { + label: "macOS x64 ZIP payload", + matches: (assetName) => assetName.endsWith("-x64.zip"), + }, + { + label: "macOS x64 ZIP blockmap", + matches: (assetName) => assetName.endsWith("-x64.zip.blockmap"), }, { label: "macOS updater manifest",