From 7b602e41a2225381c5eb201011d519ce9ba314a2 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Wed, 8 Jul 2026 10:34:19 -0400 Subject: [PATCH 1/5] feat: add dev snapshot release foundation --- .github/workflows/dev-snapshot-release.yml | 547 ++++++++++++++++++ CONCEPTS.md | 8 + package.json | 4 + packages/core/test/attach-cli.test.ts | 1 + .../core/test/dev-snapshot-bootstrap.test.ts | 149 +++++ .../core/test/dev-snapshot-release.test.ts | 148 +++++ scripts/check-dev-snapshot-bootstrap.d.mts | 62 ++ scripts/check-dev-snapshot-bootstrap.mjs | 220 +++++++ scripts/dev-snapshot-release.d.mts | 86 +++ scripts/dev-snapshot-release.mjs | 393 +++++++++++++ 10 files changed, 1618 insertions(+) create mode 100644 .github/workflows/dev-snapshot-release.yml create mode 100644 packages/core/test/dev-snapshot-bootstrap.test.ts create mode 100644 packages/core/test/dev-snapshot-release.test.ts create mode 100644 scripts/check-dev-snapshot-bootstrap.d.mts create mode 100644 scripts/check-dev-snapshot-bootstrap.mjs create mode 100644 scripts/dev-snapshot-release.d.mts create mode 100644 scripts/dev-snapshot-release.mjs diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml new file mode 100644 index 00000000..4031c175 --- /dev/null +++ b/.github/workflows/dev-snapshot-release.yml @@ -0,0 +1,547 @@ +name: Dev Snapshot Release + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + dry_run: + description: Skip npm and GHCR side effects. + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: dev-snapshot-${{ github.ref }} + cancel-in-progress: false + +env: + HUSKY: 0 + +jobs: + plan: + name: Derive dev snapshot manifest + runs-on: ubuntu-latest + outputs: + has_public_releases: ${{ steps.manifest.outputs.has_public_releases }} + validation_kind: ${{ steps.manifest.outputs.validation_kind }} + staging_tag: ${{ steps.manifest.outputs.staging_tag }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run repository gate + run: pnpm verify + + - name: Capture Changesets release plan + run: | + mkdir -p .tmp + pnpm changeset status --output .tmp/changeset-status.json + node scripts/dev-snapshot-release.mjs status \ + --status-file .tmp/changeset-status.json \ + --output .tmp/dev-snapshot-manifest.json + + - name: Derive workflow outputs + id: manifest + run: | + node --input-type=module <<'NODE' + import { readFileSync, appendFileSync } from 'node:fs'; + const manifest = JSON.parse(readFileSync('.tmp/dev-snapshot-manifest.json', 'utf8')); + appendFileSync(process.env.GITHUB_OUTPUT, `has_public_releases=${manifest.hasPublicReleases}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `validation_kind=${manifest.validation.kind}\n`); + appendFileSync( + process.env.GITHUB_OUTPUT, + `staging_tag=dev-staged-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}\n`, + ); + NODE + + - name: Upload manifest artifact + uses: actions/upload-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp/dev-snapshot-manifest.json + + publish: + name: Publish staged snapshots + needs: plan + if: needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + STAGING_TAG: ${{ needs.plan.outputs.staging_tag }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Patch snapshot config + run: node scripts/dev-snapshot-release.mjs patch-snapshot-config + + - name: Apply snapshot versions + run: pnpm changeset version --snapshot dev + + - name: Refresh manifest to snapshot versions + run: | + node scripts/dev-snapshot-release.mjs refresh-manifest \ + --manifest .tmp/dev-snapshot-manifest.json \ + --output .tmp/dev-snapshot-manifest.json + + - name: Rewrite public closure + run: node scripts/dev-snapshot-release.mjs rewrite-closure --manifest .tmp/dev-snapshot-manifest.json + + - name: Build disposable snapshot workspace artifacts + run: pnpm build + + - name: Publish exact snapshots with run-scoped staging tag + run: pnpm changeset publish --tag "$STAGING_TAG" --no-git-tag + + - name: Upload refreshed snapshot manifest + uses: actions/upload-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp/dev-snapshot-manifest.json + overwrite: true + + validate_cli: + name: Validate CLI or core snapshot line + needs: [plan, publish] + if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Create isolated install root + id: install-root + run: | + install_root="$(mktemp -d -t caplets-dev-install-XXXXXX)" + echo "install_root=${install_root}" >> "$GITHUB_OUTPUT" + + - name: Install and smoke exact CLI snapshot + env: + INSTALL_ROOT: ${{ steps.install-root.outputs.install_root }} + run: | + export HOME="$INSTALL_ROOT/home" + export USERPROFILE="$HOME" + export npm_config_prefix="$INSTALL_ROOT" + export npm_config_cache="$INSTALL_ROOT/cache" + export XDG_CONFIG_HOME="$INSTALL_ROOT/xdg-config" + export XDG_STATE_HOME="$INSTALL_ROOT/xdg-state" + export CAPLETS_CONFIG="$INSTALL_ROOT/caplets-config.json" + export NO_COLOR=1 + export PATH="$INSTALL_ROOT/bin:$PATH" + node scripts/check-dev-snapshot-bootstrap.mjs plan --manifest .tmp/dev-snapshot-manifest.json > .tmp/bootstrap-plan.json + cli_version=$(node --input-type=module <<'NODE' + import { readJson } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const release = manifest.releases.find((entry) => entry.name === 'caplets'); + console.log(release?.newVersion ?? ''); + NODE + ) + if [ -z "$cli_version" ]; then + echo "Could not resolve caplets snapshot version from manifest." >&2 + exit 1 + fi + npm install -g "caplets@${cli_version}" + caplets --version + caplets setup mcp-client --output "$INSTALL_ROOT/caplets.mcp.json" --format json + node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" + + validate_packages: + name: Validate package-only snapshot line + needs: [plan, publish] + if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'package-only' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Create isolated install root + id: install-root + run: | + install_root="$(mktemp -d -t caplets-dev-install-XXXXXX)" + echo "install_root=${install_root}" >> "$GITHUB_OUTPUT" + + - name: Install package-only snapshot line + env: + INSTALL_ROOT: ${{ steps.install-root.outputs.install_root }} + run: | + export HOME="$INSTALL_ROOT/home" + export USERPROFILE="$HOME" + export npm_config_prefix="$INSTALL_ROOT" + export npm_config_cache="$INSTALL_ROOT/cache" + export XDG_CONFIG_HOME="$INSTALL_ROOT/xdg-config" + export XDG_STATE_HOME="$INSTALL_ROOT/xdg-state" + export CAPLETS_CONFIG="$INSTALL_ROOT/caplets-config.json" + export NO_COLOR=1 + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readJson } from './scripts/dev-snapshot-release.mjs'; + import { buildValidationCommands } from './scripts/check-dev-snapshot-bootstrap.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + for (const command of buildValidationCommands(manifest, { installRoot: process.env.INSTALL_ROOT })) { + const [binary, ...args] = command.split(' '); + execFileSync(binary, args, { + stdio: 'inherit', + env: process.env, + }); + } + NODE + node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" + + validation_complete: + name: Validation barrier + needs: [plan, validate_cli, validate_packages] + if: always() && needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require one validation lane to succeed + run: | + cli_result='${{ needs.validate_cli.result }}' + package_result='${{ needs.validate_packages.result }}' + case "${cli_result}:${package_result}" in + success:skipped|skipped:success) + ;; + *) + echo "Unexpected validation results: cli=${cli_result} package=${package_result}" >&2 + exit 1 + ;; + esac + + promote: + name: Promote validated snapshots to dev + needs: [plan, publish, validation_complete] + if: needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Stop if the run was not triggered from main + run: | + test "${GITHUB_REF}" = "refs/heads/main" || { + echo "Dev tag promotion is main-only." >&2 + exit 1 + } + + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Capture pre-run dist-tags + run: | + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { writeFileSync } from 'node:fs'; + import { readJson } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const currentTags = {}; + for (const release of manifest.releases) { + const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }); + currentTags[release.name] = JSON.parse(raw); + } + writeFileSync('.tmp/pre-run-tags.json', `${JSON.stringify(currentTags, null, 2)}\n`); + NODE + + - name: Upload pre-run tag snapshot + uses: actions/upload-artifact@v4 + with: + name: dev-snapshot-pre-run-tags + path: .tmp/pre-run-tags.json + + - name: Promote validated versions to dev with reconciliation + run: | + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + import { readJson } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const preRunTags = JSON.parse(readFileSync('.tmp/pre-run-tags.json', 'utf8')); + const promoted = []; + try { + for (const release of manifest.releases) { + execFileSync('npm', ['dist-tag', 'add', `${release.name}@${release.newVersion}`, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + promoted.push(release); + const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }); + const tags = JSON.parse(raw); + if ((preRunTags[release.name]?.latest ?? null) !== (tags.latest ?? null)) { + throw new Error(`${release.name} latest changed unexpectedly during dev promotion.`); + } + } + } catch (error) { + for (const release of promoted.reverse()) { + const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }); + const tags = JSON.parse(raw); + if (tags.dev !== release.newVersion) continue; + const previousDev = preRunTags[release.name]?.dev; + if (typeof previousDev === 'string' && previousDev.length > 0) { + execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + } else { + execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + } + } + throw error; + } + NODE + + verify_promoted_cli: + name: Verify promoted caplets@dev line + needs: [plan, promote] + if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Create install root for promoted smoke + id: install-root + run: | + install_root="$(mktemp -d -t caplets-dev-promoted-XXXXXX)" + echo "install_root=${install_root}" >> "$GITHUB_OUTPUT" + + - name: Smoke promoted caplets@dev line + env: + INSTALL_ROOT: ${{ steps.install-root.outputs.install_root }} + run: | + export HOME="$INSTALL_ROOT/home" + export USERPROFILE="$HOME" + export npm_config_prefix="$INSTALL_ROOT" + export npm_config_cache="$INSTALL_ROOT/cache" + export XDG_CONFIG_HOME="$INSTALL_ROOT/xdg-config" + export XDG_STATE_HOME="$INSTALL_ROOT/xdg-state" + export CAPLETS_CONFIG="$INSTALL_ROOT/caplets-config.json" + export NO_COLOR=1 + export PATH="$INSTALL_ROOT/bin:$PATH" + npm install -g caplets@dev + caplets --version + caplets setup mcp-client --output "$INSTALL_ROOT/caplets.mcp.json" --format json + node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" + + reconcile_promoted_cli_failure: + name: Reconcile failed promoted dev line + needs: [plan, promote, verify_promoted_cli] + if: always() && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'failure' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-manifest + path: .tmp + + - name: Download pre-run tag snapshot + uses: actions/download-artifact@v4 + with: + name: dev-snapshot-pre-run-tags + path: .tmp + + - name: Restore dev tags after promoted smoke failure + run: | + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + import { readJson } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const preRunTags = JSON.parse(readFileSync('.tmp/pre-run-tags.json', 'utf8')); + for (const release of [...manifest.releases].reverse()) { + const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }); + const tags = JSON.parse(raw); + if (tags.dev !== release.newVersion) continue; + const previousDev = preRunTags[release.name]?.dev; + if (typeof previousDev === 'string' && previousDev.length > 0) { + execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + } else { + execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + } + } + NODE + + - name: Fail after reconciliation + run: | + echo "Promoted caplets@dev did not resolve the validated snapshot line." >&2 + exit 1 + + dev_image: + name: Publish best-effort dev image + needs: [plan, promote, verify_promoted_cli] + if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish dev image tags + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/spiritledsoftware/caplets:dev + ghcr.io/spiritledsoftware/caplets:dev-${{ github.sha }} + build-args: | + CAPLETS_SENTRY_RELEASE=caplets-runtime@dev-${{ github.sha }} + CAPLETS_SENTRY_ENVIRONMENT=development diff --git a/CONCEPTS.md b/CONCEPTS.md index 9f2fd85c..8e05c125 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -261,3 +261,11 @@ Operator Clients can approve or revoke remote clients, administer Caplets and ca A host-owned record of sensitive Operator Client actions performed through the dashboard or operator admin surfaces. Operator Activity Log answers what changed, when it changed, and which Operator Client performed the action. It is narrower than a compliance audit system and separate from daemon or protocol logs. + +## Release + +### Dev Snapshot Channel + +A non-production release channel that publishes traceable package snapshots for dogfooding and release confidence before stable package promotion. + +Dev Snapshot Channel publishes immutable snapshot versions first, validates each promoted snapshot line with the required proof for that package set — real `caplets` bootstrap for CLI/core lines, exact package-level install or import smoke for non-CLI-only lines — and only then advances the floating development tag. It is separate from stable release versioning, changelog generation, npm `latest`, and production image tags. diff --git a/package.json b/package.json index f9571e31..a05d9c55 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "prepare": "husky", "release": "turbo build --force && node scripts/check-package-runtime.mjs && changeset publish", "release:publish": "pnpm telemetry:prepare-release-env && pnpm release", + "dev-snapshot:manifest": "node scripts/dev-snapshot-release.mjs status", + "dev-snapshot:patch-config": "node scripts/dev-snapshot-release.mjs patch-snapshot-config", + "dev-snapshot:rewrite-closure": "node scripts/dev-snapshot-release.mjs rewrite-closure", + "dev-snapshot:bootstrap-check": "node scripts/check-dev-snapshot-bootstrap.mjs plan", "schema:check": "tsx ./scripts/generate-config-schema.ts --check", "schema:generate": "tsx ./scripts/generate-config-schema.ts", "test": "vitest run", diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index ba3e7a95..653cdc42 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -74,6 +74,7 @@ describe("caplets attach CLI", () => { runCli(["attach", "--remote-url", "https://caplets.example.com/caplets", "--user", "alice"], { env: { CAPLETS_MODE: "remote" }, attachServe: async () => undefined, + writeErr: () => undefined, } as never), ).rejects.toThrow(/unknown option '--user'/u); }); diff --git a/packages/core/test/dev-snapshot-bootstrap.test.ts b/packages/core/test/dev-snapshot-bootstrap.test.ts new file mode 100644 index 00000000..1d213037 --- /dev/null +++ b/packages/core/test/dev-snapshot-bootstrap.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { PI_PACKAGE_NAME, OPENCODE_PACKAGE_NAME } from "../../../scripts/dev-snapshot-release.mjs"; +import { + assertInstalledSnapshotLine, + buildValidationCommands, + createIsolatedValidationEnv, + derivePackageOnlyTargets, + deriveValidationPlan, + type BootstrapManifest, +} from "../../../scripts/check-dev-snapshot-bootstrap.mjs"; + +const tempPaths: string[] = []; + +afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("dev snapshot bootstrap helpers", () => { + it("derives cli bootstrap validation when caplets or core is present", () => { + const manifest: BootstrapManifest = { + validation: { kind: "cli-bootstrap", packages: ["caplets", "@caplets/core"] }, + releases: [ + { name: "caplets", newVersion: "0.25.7-dev-abc-20260708120000" }, + { name: "@caplets/core", newVersion: "0.33.0-dev-abc-20260708120000" }, + ], + }; + + expect(deriveValidationPlan(manifest)).toEqual({ + kind: "cli-bootstrap", + cliPackage: "caplets", + expectedCorePackage: "@caplets/core", + packages: ["caplets", "@caplets/core"], + }); + expect(buildValidationCommands(manifest)).toEqual([ + "npm install -g caplets@0.25.7-dev-abc-20260708120000", + "caplets --version", + "caplets setup mcp-client --output ${INSTALL_ROOT}/caplets.mcp.json --format json", + ]); + }); + + it("derives package-only validation targets and retains peer-host metadata", () => { + const manifest: BootstrapManifest = { + validation: { kind: "package-only", packages: [PI_PACKAGE_NAME, OPENCODE_PACKAGE_NAME] }, + releases: [ + { name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }, + { name: OPENCODE_PACKAGE_NAME, newVersion: "0.8.15-dev-abc-20260708120000" }, + ], + }; + + const targets = derivePackageOnlyTargets(manifest); + expect(targets).toEqual([ + { + name: PI_PACKAGE_NAME, + peerDependencies: ["@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"], + validationKind: "install-only", + }, + { + name: OPENCODE_PACKAGE_NAME, + peerDependencies: ["@opencode-ai/plugin"], + validationKind: "install-only", + }, + ]); + expect(buildValidationCommands(manifest)).toEqual([ + "npm install -g @caplets/pi@0.9.14-dev-abc-20260708120000", + "npm install -g @caplets/opencode@0.8.15-dev-abc-20260708120000", + ]); + }); + + it("creates an isolated validation environment", () => { + const isolated = createIsolatedValidationEnv(); + tempPaths.push(isolated.baseDirectory); + + expect(isolated.env.CAPLETS_CONFIG).toContain("caplets-config.json"); + expect(isolated.env.npm_config_prefix).toContain("npm-prefix"); + expect(isolated.env.HOME).toContain("home"); + }); + + it("asserts installed exact versions and peer declarations for package-only lines", () => { + const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); + tempPaths.push(installRoot); + const piDir = join(installRoot, "lib", "node_modules", "@caplets", "pi"); + mkdirSync(piDir, { recursive: true }); + writeFileSync( + join(piDir, "package.json"), + JSON.stringify( + { + name: PI_PACKAGE_NAME, + version: "0.9.14-dev-abc-20260708120000", + peerDependencies: { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + }, + }, + null, + 2, + ), + ); + + const manifest: BootstrapManifest = { + validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, + releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], + }; + + expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([]); + }); + + it("reports missing peer declarations for peer-host package lines", () => { + const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); + tempPaths.push(installRoot); + const piDir = join(installRoot, "lib", "node_modules", "@caplets", "pi"); + mkdirSync(piDir, { recursive: true }); + writeFileSync( + join(piDir, "package.json"), + JSON.stringify({ name: PI_PACKAGE_NAME, version: "0.9.14-dev-abc-20260708120000" }, null, 2), + ); + + const manifest: BootstrapManifest = { + validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, + releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], + }; + + expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([ + `${PI_PACKAGE_NAME} is missing peer dependency declaration for @earendil-works/pi-coding-agent.`, + `${PI_PACKAGE_NAME} is missing peer dependency declaration for @earendil-works/pi-tui.`, + ]); + }); + + it("reports missing installed packages as descriptive errors", () => { + const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); + tempPaths.push(installRoot); + + const manifest: BootstrapManifest = { + validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, + releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], + }; + + const errors = assertInstalledSnapshotLine(manifest, installRoot); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain( + `${PI_PACKAGE_NAME} is not installed at ${join(installRoot, "lib", "node_modules", "@caplets", "pi", "package.json")}`, + ); + expect(errors[0]).toContain("ENOENT"); + }); +}); diff --git a/packages/core/test/dev-snapshot-release.test.ts b/packages/core/test/dev-snapshot-release.test.ts new file mode 100644 index 00000000..59442932 --- /dev/null +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CLI_PACKAGE_NAME, + CORE_PACKAGE_NAME, + createStagingTag, + deriveChangesetManifest, + discoverWorkspacePackageManifests, + expandPublicReleaseClosure, + listPublicPublishablePackages, + patchSnapshotConfig, + readJson, +} from "../../../scripts/dev-snapshot-release.mjs"; + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const tempPaths: string[] = []; + +afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("dev snapshot release helpers", () => { + it("discovers the public publishable package universe", () => { + const publicPackages = listPublicPublishablePackages(repoRoot) + .map((entry) => entry.name) + .sort(); + + expect(publicPackages).toEqual([ + "@caplets/core", + "@caplets/opencode", + "@caplets/pi", + "caplets", + ]); + }); + + it("expands release closure through workspace dependencies", () => { + const manifests = discoverWorkspacePackageManifests(repoRoot); + expect(expandPublicReleaseClosure([CORE_PACKAGE_NAME], manifests)).toEqual([ + "@caplets/core", + "@caplets/opencode", + "@caplets/pi", + "caplets", + ]); + }); + + it("derives a manifest with cli bootstrap validation for core changes", () => { + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: CORE_PACKAGE_NAME, + type: "minor", + oldVersion: "0.32.4", + newVersion: "0.33.0", + changesets: ["admin-dashboard"], + }, + ], + }, + { repoRoot }, + ); + + expect(manifest.hasPublicReleases).toBe(true); + expect(manifest.validation).toEqual({ + kind: "cli-bootstrap", + packages: [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME], + }); + expect(manifest.releases.map((entry) => entry.name).sort()).toEqual([ + "@caplets/core", + "@caplets/opencode", + "@caplets/pi", + "caplets", + ]); + }); + + it("returns no public releases when status contains only ignored or private workspaces", () => { + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: "@caplets/catalog", + type: "none", + oldVersion: "0.1.0", + newVersion: "0.1.0", + changesets: [], + }, + ], + }, + { repoRoot }, + ); + + expect(manifest.hasPublicReleases).toBe(false); + expect(manifest.releases).toEqual([]); + }); + + it("creates a run-scoped staging tag", () => { + expect(createStagingTag("123-4")).toBe("dev-staged-123-4"); + expect(() => createStagingTag("")).toThrow(/run identifier/i); + }); + + it("patches changeset snapshot config for calculated versions", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "caplets-dev-snapshot-config-")); + tempPaths.push(tempRoot); + const changesetDir = join(tempRoot, ".changeset"); + mkdirSync(changesetDir, { recursive: true }); + writeFileSync(join(tempRoot, "package.json"), "{}\n"); + writeFileSync( + join(changesetDir, "config.json"), + `${JSON.stringify(readJson(join(repoRoot, ".changeset/config.json")), null, 2)}\n`, + ); + + const patched = patchSnapshotConfig(tempRoot); + expect(patched.snapshot).toMatchObject({ + useCalculatedVersion: true, + prereleaseTemplate: "dev-{commit}-{datetime}", + }); + }); + + it("wires the dev snapshot workflow with separate validation and promotion barriers", () => { + const workflow = readFileSync( + join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), + "utf8", + ); + + expect(workflow).toContain("name: Dev Snapshot Release"); + expect(workflow).toContain("cancel-in-progress: false"); + expect(workflow).not.toContain("cancel-in-progress: true"); + expect(workflow).toContain("needs: [plan, publish, validation_complete]"); + expect(workflow).toContain("name: Validation barrier"); + expect(workflow).toContain("name: Verify promoted caplets@dev line"); + expect(workflow).toContain("name: Reconcile failed promoted dev line"); + expect(workflow).toContain("needs: [plan, promote, verify_promoted_cli]"); + expect(workflow).toContain( + "staging_tag=dev-staged-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}", + ); + expect(workflow.split('export PATH="$INSTALL_ROOT/bin:$PATH"').length - 1).toBe(2); + expect(workflow.split("persist-credentials: false").length - 1).toBe(8); + expect(workflow).not.toContain('eval "$command"'); + expect(workflow).not.toContain("registry-url: https://registry.npmjs.org"); + expect(workflow).not.toContain("Stop when dry run is requested"); + expect(workflow).toContain( + "if: always() && needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)", + ); + }); +}); diff --git a/scripts/check-dev-snapshot-bootstrap.d.mts b/scripts/check-dev-snapshot-bootstrap.d.mts new file mode 100644 index 00000000..e367bacd --- /dev/null +++ b/scripts/check-dev-snapshot-bootstrap.d.mts @@ -0,0 +1,62 @@ +export type BootstrapReleaseEntry = { + name: string; + newVersion: string; +}; +export type BootstrapManifest = { + validation: + | { kind: "cli-bootstrap"; packages: string[] } + | { kind: "package-only"; packages: string[] }; + releases: BootstrapReleaseEntry[]; +}; + +export type PackageOnlyTarget = { + name: string; + peerDependencies: string[]; + validationKind: "install-only"; +}; + +export type CliBootstrapValidationPlan = { + kind: "cli-bootstrap"; + cliPackage: string; + expectedCorePackage: string; + packages: string[]; +}; + +export type PackageOnlyValidationPlan = { + kind: "package-only"; + packages: PackageOnlyTarget[]; +}; + +export type ValidationPlan = CliBootstrapValidationPlan | PackageOnlyValidationPlan; + +export type IsolatedValidationEnv = { + baseDirectory: string; + home: string; + npmPrefix: string; + npmCache: string; + xdgConfig: string; + xdgState: string; + capletsConfig: string; + env: Record; +}; + +export function derivePackageOnlyTargets( + snapshotManifest: BootstrapManifest, + root?: string, +): PackageOnlyTarget[]; +export function deriveValidationPlan(snapshotManifest: BootstrapManifest): ValidationPlan; +export function createIsolatedValidationEnv(baseDirectory?: string): IsolatedValidationEnv; +export function findInstalledPackageJson(installRoot: string, packageName: string): string; +export function readInstalledPackageManifest( + installRoot: string, + packageName: string, +): Record; +export function readInstalledPackageVersion(installRoot: string, packageName: string): string; +export function assertInstalledSnapshotLine( + snapshotManifest: BootstrapManifest, + installRoot: string, +): string[]; +export function buildValidationCommands( + snapshotManifest: BootstrapManifest, + options?: { installRoot?: string }, +): string[]; diff --git a/scripts/check-dev-snapshot-bootstrap.mjs b/scripts/check-dev-snapshot-bootstrap.mjs new file mode 100644 index 00000000..e83b3d5a --- /dev/null +++ b/scripts/check-dev-snapshot-bootstrap.mjs @@ -0,0 +1,220 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CLI_PACKAGE_NAME, + CORE_PACKAGE_NAME, + listPublicPublishablePackages, + readJson, +} from "./dev-snapshot-release.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export function derivePackageOnlyTargets(snapshotManifest, root = repoRoot) { + const publicPackages = new Map( + listPublicPublishablePackages(root).map((entry) => [entry.name, entry.manifest]), + ); + return ( + snapshotManifest.validation?.packages ?? + snapshotManifest.releases.map((release) => release.name) + ).map((packageName) => { + const manifest = publicPackages.get(packageName) ?? {}; + const peerDependencies = Object.keys(manifest.peerDependencies ?? {}); + return { + name: packageName, + peerDependencies, + validationKind: "install-only", + }; + }); +} + +export function deriveValidationPlan(snapshotManifest) { + if (snapshotManifest.validation?.kind === "cli-bootstrap") { + return { + kind: "cli-bootstrap", + cliPackage: CLI_PACKAGE_NAME, + expectedCorePackage: CORE_PACKAGE_NAME, + packages: snapshotManifest.validation.packages ?? [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME], + }; + } + return { + kind: "package-only", + packages: derivePackageOnlyTargets(snapshotManifest), + }; +} + +export function createIsolatedValidationEnv( + baseDirectory = mkdtempSync(join(tmpdir(), "caplets-dev-snapshot-")), +) { + const home = join(baseDirectory, "home"); + const npmPrefix = join(baseDirectory, "npm-prefix"); + const npmCache = join(baseDirectory, "npm-cache"); + const xdgConfig = join(baseDirectory, "xdg-config"); + const xdgState = join(baseDirectory, "xdg-state"); + const capletsConfig = join(baseDirectory, "caplets-config.json"); + return { + baseDirectory, + home, + npmPrefix, + npmCache, + xdgConfig, + xdgState, + capletsConfig, + env: { + HOME: home, + USERPROFILE: home, + npm_config_prefix: npmPrefix, + npm_config_cache: npmCache, + XDG_CONFIG_HOME: xdgConfig, + XDG_STATE_HOME: xdgState, + CAPLETS_CONFIG: capletsConfig, + NO_COLOR: "1", + }, + }; +} + +export function findInstalledPackageJson(installRoot, packageName) { + return join(installRoot, "lib", "node_modules", packageName, "package.json"); +} + +export function readInstalledPackageManifest(installRoot, packageName) { + const packageJsonPath = findInstalledPackageJson(installRoot, packageName); + return JSON.parse(readFileSync(packageJsonPath, "utf8")); +} +export function readInstalledPackageVersion(installRoot, packageName) { + const packageJson = readInstalledPackageManifest(installRoot, packageName); + return packageJson.version; +} + +export function assertInstalledSnapshotLine(snapshotManifest, installRoot) { + const errors = []; + const expectedVersions = new Map( + snapshotManifest.releases.map((release) => [release.name, release.newVersion]), + ); + const plan = deriveValidationPlan(snapshotManifest); + if (plan.kind === "cli-bootstrap") { + for (const packageName of [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME]) { + const expectedVersion = expectedVersions.get(packageName); + if (!expectedVersion) continue; + try { + const installedVersion = readInstalledPackageVersion(installRoot, packageName); + if (installedVersion !== expectedVersion) { + errors.push( + `${packageName} installed version ${installedVersion} did not match expected ${expectedVersion}.`, + ); + } + } catch (error) { + errors.push( + `${packageName} is not installed at ${findInstalledPackageJson(installRoot, packageName)} (${error instanceof Error ? error.message : String(error)}).`, + ); + } + } + } else { + for (const target of plan.packages) { + const expectedVersion = expectedVersions.get(target.name); + if (!expectedVersion) continue; + try { + const installedVersion = readInstalledPackageVersion(installRoot, target.name); + if (installedVersion !== expectedVersion) { + errors.push( + `${target.name} installed version ${installedVersion} did not match expected ${expectedVersion}.`, + ); + } + if (target.peerDependencies.length > 0) { + const installedManifest = readInstalledPackageManifest(installRoot, target.name); + const installedPeers = installedManifest.peerDependencies ?? {}; + for (const peerDependency of target.peerDependencies) { + if (typeof installedPeers[peerDependency] !== "string") { + errors.push( + `${target.name} is missing peer dependency declaration for ${peerDependency}.`, + ); + } + } + } + } catch (error) { + errors.push( + `${target.name} is not installed at ${findInstalledPackageJson(installRoot, target.name)} (${error instanceof Error ? error.message : String(error)}).`, + ); + } + } + } + return errors; +} + +export function buildValidationCommands(snapshotManifest, options = {}) { + const versionByName = new Map( + snapshotManifest.releases.map((release) => [release.name, release.newVersion]), + ); + const plan = deriveValidationPlan(snapshotManifest); + const installRoot = options.installRoot ?? "${INSTALL_ROOT}"; + if (plan.kind === "cli-bootstrap") { + const capletsVersion = versionByName.get(CLI_PACKAGE_NAME); + return [ + `npm install -g ${CLI_PACKAGE_NAME}@${capletsVersion}`, + `${CLI_PACKAGE_NAME} --version`, + `${CLI_PACKAGE_NAME} setup mcp-client --output ${join(installRoot, "caplets.mcp.json")} --format json`, + ]; + } + return plan.packages.map( + (target) => `npm install -g ${target.name}@${versionByName.get(target.name)}`, + ); +} + +function printJson(result) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +function parseOptionMap(args) { + const optionMap = new Map(); + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (!key?.startsWith("--")) continue; + const next = args[index + 1]; + if (next === undefined || next.startsWith("--")) { + optionMap.set(key.replace(/^--/, ""), "true"); + continue; + } + optionMap.set(key.replace(/^--/, ""), next); + index += 1; + } + return optionMap; +} + +if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) { + const [subcommand = "plan", ...args] = process.argv.slice(2); + const optionMap = parseOptionMap(args); + if (subcommand === "plan") { + const manifestPath = optionMap.get("manifest"); + if (!manifestPath) { + console.error("Missing required --manifest option."); + process.exit(1); + } + const manifest = readJson(manifestPath); + printJson({ + validation: deriveValidationPlan(manifest), + commands: buildValidationCommands(manifest), + }); + } else if (subcommand === "assert-installed") { + const manifestPath = optionMap.get("manifest"); + const installRoot = optionMap.get("install-root"); + if (!manifestPath) { + console.error("Missing required --manifest option."); + process.exit(1); + } + if (!installRoot) { + console.error("Missing required --install-root option."); + process.exit(1); + } + const manifest = readJson(manifestPath); + const errors = assertInstalledSnapshotLine(manifest, installRoot); + if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); + } + printJson({ ok: true }); + } else { + console.error(`Unknown subcommand: ${subcommand}`); + process.exit(1); + } +} diff --git a/scripts/dev-snapshot-release.d.mts b/scripts/dev-snapshot-release.d.mts new file mode 100644 index 00000000..10c4064c --- /dev/null +++ b/scripts/dev-snapshot-release.d.mts @@ -0,0 +1,86 @@ +export type WorkspacePackageEntry = { + name: string; + version: string | undefined; + private: boolean; + publishAccess: string | undefined; + path: string; + directory: string; + manifest: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + publishConfig?: { access?: string }; + private?: boolean; + version?: string; + }; +}; + +export type ReleaseEntry = { + name: string; + directory: string; + oldVersion: string | undefined; + newVersion: string | undefined; + type: string; + changesets: string[]; + direct: boolean; +}; + +export type ValidationMode = + | { + kind: "cli-bootstrap"; + packages: string[]; + } + | { + kind: "package-only"; + packages: string[]; + }; + +export type SnapshotManifest = { + hasPublicReleases: boolean; + releases: ReleaseEntry[]; + validation: ValidationMode; + fingerprint?: string; + changesetFiles?: string[]; +}; + +export const CLI_PACKAGE_NAME: string; +export const CORE_PACKAGE_NAME: string; +export const OPENCODE_PACKAGE_NAME: string; +export const PI_PACKAGE_NAME: string; + +export function createStagingTag(runIdentifier: string): string; +export function readJson(path: string): unknown; +export function writeJson(path: string, value: unknown): void; +export function discoverWorkspacePackageManifests(root?: string): WorkspacePackageEntry[]; +export function isPublicPublishableManifest(entry: WorkspacePackageEntry): boolean; +export function listPublicPublishablePackages(root?: string): WorkspacePackageEntry[]; +export function expandPublicReleaseClosure( + releaseNames: string[], + manifests: WorkspacePackageEntry[], +): string[]; +export function buildWorkspaceDependencyGraph( + manifests: WorkspacePackageEntry[], +): Map>; +export function chooseValidationMode(releaseNames: string[]): ValidationMode; +export function toSnapshotVersion(baseVersion: string, commit: string, timestamp: string): string; +export function deriveChangesetManifest( + statusJson: { releases?: Array> }, + options?: { manifests?: WorkspacePackageEntry[]; repoRoot?: string }, +): SnapshotManifest; +export function listChangesetFiles(root?: string): string[]; +export function collectRelevantFingerprintFiles( + manifest: SnapshotManifest, + root?: string, +): string[]; +export function computeRelevantFingerprint(manifest: SnapshotManifest, root?: string): string; +export function withFingerprint(manifest: SnapshotManifest, root?: string): SnapshotManifest; +export function patchSnapshotConfig(root?: string): Record; +export function writePatchedSnapshotConfig(root?: string): Record; +export function refreshSnapshotManifestVersions( + snapshotManifest: SnapshotManifest, + root?: string, +): SnapshotManifest; +export function rewriteClosureManifests(snapshotManifest: SnapshotManifest, root?: string): void; +export function assertRewrittenClosure(snapshotManifest: SnapshotManifest, root?: string): string[]; +export function parseArgs(argv: string[]): { subcommand: string; options: Map }; diff --git a/scripts/dev-snapshot-release.mjs b/scripts/dev-snapshot-release.mjs new file mode 100644 index 00000000..230830ae --- /dev/null +++ b/scripts/dev-snapshot-release.mjs @@ -0,0 +1,393 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const stableWorkflowPath = ".github/workflows/release.yml"; +const devWorkflowPath = ".github/workflows/dev-snapshot-release.yml"; + +export const CLI_PACKAGE_NAME = "caplets"; +export const CORE_PACKAGE_NAME = "@caplets/core"; +export const OPENCODE_PACKAGE_NAME = "@caplets/opencode"; +export const PI_PACKAGE_NAME = "@caplets/pi"; +export function createStagingTag(runIdentifier) { + if (!runIdentifier || typeof runIdentifier !== "string") { + throw new Error("A run identifier is required to build a staging tag."); + } + return `dev-staged-${runIdentifier}`; +} + +const rootFingerprintFiles = [ + "package.json", + ".changeset/config.json", + stableWorkflowPath, + devWorkflowPath, + "scripts/check-package-runtime.mjs", + "scripts/dev-snapshot-release.mjs", + "scripts/check-dev-snapshot-bootstrap.mjs", +]; + +export function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +export function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export function discoverWorkspacePackageManifests(root = repoRoot) { + const manifests = []; + for (const workspaceRoot of ["packages", "apps"]) { + const base = join(root, workspaceRoot); + if (!existsSync(base)) continue; + for (const entry of readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifestPath = join(base, entry.name, "package.json"); + if (!existsSync(manifestPath)) continue; + const manifest = readJson(manifestPath); + manifests.push({ + name: manifest.name, + version: manifest.version, + private: manifest.private === true, + publishAccess: manifest.publishConfig?.access, + path: manifestPath, + directory: relative(root, dirname(manifestPath)).split(sep).join("/"), + manifest, + }); + } + } + return manifests; +} + +export function isPublicPublishableManifest(entry) { + return Boolean(entry?.name) && entry.private !== true && entry.publishAccess === "public"; +} + +export function listPublicPublishablePackages(root = repoRoot) { + return discoverWorkspacePackageManifests(root).filter(isPublicPublishableManifest); +} + +export function buildWorkspaceDependencyGraph(manifests) { + const names = new Set(manifests.map((entry) => entry.name)); + const dependents = new Map(); + for (const entry of manifests) { + dependents.set(entry.name, new Set()); + } + for (const entry of manifests) { + const dependencySections = [ + entry.manifest.dependencies ?? {}, + entry.manifest.devDependencies ?? {}, + entry.manifest.optionalDependencies ?? {}, + entry.manifest.peerDependencies ?? {}, + ]; + for (const deps of dependencySections) { + for (const [dependencyName, dependencyRange] of Object.entries(deps)) { + if (!names.has(dependencyName)) continue; + if (typeof dependencyRange !== "string") continue; + if (!dependencyRange.startsWith("workspace:")) continue; + dependents.get(dependencyName)?.add(entry.name); + } + } + } + return dependents; +} + +export function expandPublicReleaseClosure(releaseNames, manifests) { + const publicManifests = manifests.filter(isPublicPublishableManifest); + const dependents = buildWorkspaceDependencyGraph(publicManifests); + const queue = [...new Set(releaseNames.filter((name) => dependents.has(name)))]; + const visited = new Set(queue); + while (queue.length > 0) { + const current = queue.shift(); + for (const dependent of dependents.get(current) ?? []) { + if (visited.has(dependent)) continue; + visited.add(dependent); + queue.push(dependent); + } + } + return [...visited].sort(); +} + +export function chooseValidationMode(releaseNames) { + const names = new Set(releaseNames); + if (names.has(CLI_PACKAGE_NAME) || names.has(CORE_PACKAGE_NAME)) { + return { + kind: "cli-bootstrap", + packages: [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME].filter((name) => names.has(name)), + }; + } + return { + kind: "package-only", + packages: [...names].sort(), + }; +} + +export function toSnapshotVersion(baseVersion, commit, timestamp) { + return `${baseVersion}-dev-${commit}-${timestamp}`; +} + +export function deriveChangesetManifest(statusJson, options = {}) { + const manifests = + options.manifests ?? listPublicPublishablePackages(options.repoRoot ?? repoRoot); + const statusReleases = Array.isArray(statusJson?.releases) ? statusJson.releases : []; + const publicByName = new Map(manifests.map((entry) => [entry.name, entry])); + const directPublicReleases = statusReleases.filter( + (release) => release?.type && release.type !== "none" && publicByName.has(release.name), + ); + const closureNames = expandPublicReleaseClosure( + directPublicReleases.map((release) => release.name), + manifests, + ); + const releases = closureNames.map((name) => { + const manifestEntry = publicByName.get(name); + const release = statusReleases.find((candidate) => candidate.name === name); + return { + name, + directory: manifestEntry.directory, + oldVersion: release?.oldVersion ?? manifestEntry.version, + newVersion: release?.newVersion ?? manifestEntry.version, + type: + release?.type ?? + (directPublicReleases.some((entry) => entry.name === name) ? "unknown" : "patch"), + changesets: release?.changesets ?? [], + direct: directPublicReleases.some((entry) => entry.name === name), + }; + }); + const validation = chooseValidationMode(closureNames); + return { + hasPublicReleases: releases.length > 0, + releases, + validation, + }; +} + +export function listChangesetFiles(root = repoRoot) { + const directory = join(root, ".changeset"); + if (!existsSync(directory)) return []; + return readdirSync(directory) + .filter((name) => name.endsWith(".md")) + .map((name) => `.changeset/${name}`) + .sort(); +} + +function hashFile(path, hash) { + hash.update(readFileSync(path)); +} + +function walkFiles(path, files) { + if (!existsSync(path)) return; + const stats = statSync(path); + if (stats.isFile()) { + files.push(path); + return; + } + for (const entry of readdirSync(path, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + walkFiles(join(path, entry.name), files); + } +} + +export function collectRelevantFingerprintFiles(manifest, root = repoRoot) { + const files = []; + for (const relativePath of rootFingerprintFiles) { + const absolutePath = join(root, relativePath); + if (existsSync(absolutePath)) files.push(absolutePath); + } + for (const release of manifest.releases) { + walkFiles(join(root, release.directory), files); + } + for (const changesetFile of listChangesetFiles(root)) { + files.push(join(root, changesetFile)); + } + return [...new Set(files)].sort(); +} + +export function computeRelevantFingerprint(manifest, root = repoRoot) { + const hash = createHash("sha256"); + const files = collectRelevantFingerprintFiles(manifest, root); + for (const file of files) { + hash.update(relative(root, file)); + hash.update("\0"); + hashFile(file, hash); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function withFingerprint(manifest, root = repoRoot) { + return { + ...manifest, + fingerprint: computeRelevantFingerprint(manifest, root), + changesetFiles: listChangesetFiles(root), + }; +} + +export function patchSnapshotConfig(root = repoRoot) { + const config = readJson(join(root, ".changeset", "config.json")); + return { + ...config, + snapshot: { + ...config.snapshot, + useCalculatedVersion: true, + prereleaseTemplate: "dev-{commit}-{datetime}", + }, + }; +} + +export function writePatchedSnapshotConfig(root = repoRoot) { + const patched = patchSnapshotConfig(root); + writeJson(join(root, ".changeset", "config.json"), patched); + return patched; +} + +export function refreshSnapshotManifestVersions(snapshotManifest, root = repoRoot) { + const publicPackages = new Map( + listPublicPublishablePackages(root).map((entry) => [entry.name, readJson(entry.path)]), + ); + return { + ...snapshotManifest, + releases: snapshotManifest.releases.map((release) => { + const manifest = publicPackages.get(release.name); + return { + ...release, + newVersion: typeof manifest?.version === "string" ? manifest.version : release.newVersion, + }; + }), + }; +} + +export function rewriteClosureManifests(snapshotManifest, root = repoRoot) { + const publicPackages = new Map( + listPublicPublishablePackages(root).map((entry) => [entry.name, entry]), + ); + const versionByName = new Map( + snapshotManifest.releases.map((release) => [release.name, release.newVersion]), + ); + for (const release of snapshotManifest.releases) { + const entry = publicPackages.get(release.name); + if (!entry) continue; + const nextManifest = structuredClone(entry.manifest); + nextManifest.version = release.newVersion; + for (const sectionName of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + const section = nextManifest[sectionName]; + if (!section || typeof section !== "object") continue; + for (const [dependencyName, dependencyVersion] of Object.entries(section)) { + if (!versionByName.has(dependencyName)) continue; + if (typeof dependencyVersion !== "string") continue; + section[dependencyName] = versionByName.get(dependencyName); + } + } + writeJson(entry.path, nextManifest); + } +} + +export function assertRewrittenClosure(snapshotManifest, root = repoRoot) { + const publicPackages = new Map( + listPublicPublishablePackages(root).map((entry) => [entry.name, entry]), + ); + const versionByName = new Map( + snapshotManifest.releases.map((release) => [release.name, release.newVersion]), + ); + const failures = []; + for (const release of snapshotManifest.releases) { + const entry = publicPackages.get(release.name); + if (!entry) { + failures.push(`Missing public package metadata for ${release.name}.`); + continue; + } + const manifest = readJson(entry.path); + if (manifest.version !== release.newVersion) { + failures.push( + `${release.name} version is ${manifest.version}, expected ${release.newVersion}.`, + ); + } + for (const sectionName of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + const section = manifest[sectionName] ?? {}; + for (const [dependencyName, expectedVersion] of versionByName) { + if (section[dependencyName] === undefined) continue; + if (section[dependencyName] !== expectedVersion) { + failures.push( + `${release.name} ${sectionName}.${dependencyName} is ${section[dependencyName]}, expected ${expectedVersion}.`, + ); + } + } + } + } + return failures; +} + +export function parseArgs(argv) { + const [subcommand = "status", ...rest] = argv; + const options = new Map(); + for (let index = 0; index < rest.length; index += 1) { + const key = rest[index]; + const value = rest[index + 1]; + if (!key.startsWith("--")) continue; + options.set(key.slice(2), value ?? "true"); + index += 1; + } + return { subcommand, options }; +} + +function ensureOption(options, key) { + const value = options.get(key); + if (!value) { + throw new Error(`Missing required --${key} option.`); + } + return value; +} + +function printJson(result) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) { + const { subcommand, options } = parseArgs(process.argv.slice(2)); + try { + if (subcommand === "status") { + const statusFile = ensureOption(options, "status-file"); + const output = options.get("output"); + const manifest = withFingerprint(deriveChangesetManifest(readJson(statusFile))); + if (output) writeJson(output, manifest); + printJson(manifest); + } else if (subcommand === "patch-snapshot-config") { + const result = writePatchedSnapshotConfig( + options.get("root") ? resolve(options.get("root")) : repoRoot, + ); + printJson(result); + } else if (subcommand === "refresh-manifest") { + const manifestFile = ensureOption(options, "manifest"); + const output = options.get("output") ?? manifestFile; + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const refreshed = refreshSnapshotManifestVersions(readJson(manifestFile), root); + writeJson(output, refreshed); + printJson(refreshed); + } else if (subcommand === "rewrite-closure") { + const manifestFile = ensureOption(options, "manifest"); + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const manifest = readJson(manifestFile); + rewriteClosureManifests(manifest, root); + const failures = assertRewrittenClosure(manifest, root); + if (failures.length > 0) { + throw new Error(`Closure rewrite failed:\n- ${failures.join("\n- ")}`); + } + printJson({ ok: true, rewritten: manifest.releases.map((release) => release.name) }); + } else { + throw new Error(`Unknown subcommand: ${subcommand}`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} From 9b8f1da9a53cbf42c09b78cd77a117d6bfc5b34b Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 9 Jul 2026 16:01:56 -0400 Subject: [PATCH 2/5] fix: harden dev snapshot release pipeline --- .github/workflows/dev-snapshot-release.yml | 290 +++++++--- .github/workflows/release.yml | 9 +- .../core/test/dev-snapshot-bootstrap.test.ts | 272 ++++++--- .../core/test/dev-snapshot-release.test.ts | 532 ++++++++++++++++-- scripts/check-dev-snapshot-bootstrap.d.mts | 13 +- scripts/check-dev-snapshot-bootstrap.mjs | 125 ++-- scripts/dev-snapshot-release.mjs | 36 +- 7 files changed, 993 insertions(+), 284 deletions(-) diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml index 4031c175..e9c090b4 100644 --- a/.github/workflows/dev-snapshot-release.yml +++ b/.github/workflows/dev-snapshot-release.yml @@ -1,16 +1,7 @@ name: Dev Snapshot Release on: - push: - branches: - - main - workflow_dispatch: - inputs: - dry_run: - description: Skip npm and GHCR side effects. - required: false - default: false - type: boolean + workflow_call: permissions: contents: read @@ -83,8 +74,9 @@ jobs: publish: name: Publish staged snapshots needs: plan - if: needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' runs-on: ubuntu-latest + environment: npm-release permissions: contents: read id-token: write @@ -105,6 +97,7 @@ jobs: with: node-version: 24 cache: pnpm + registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -133,6 +126,15 @@ jobs: - name: Build disposable snapshot workspace artifacts run: pnpm build + - name: Require current main commit before publishing + run: | + git fetch --no-tags origin main + current_main="$(git rev-parse origin/main)" + test "$GITHUB_SHA" = "$current_main" || { + echo "Refusing to publish stale main commit $GITHUB_SHA; current main is $current_main." >&2 + exit 1 + } + - name: Publish exact snapshots with run-scoped staging tag run: pnpm changeset publish --tag "$STAGING_TAG" --no-git-tag @@ -146,7 +148,7 @@ jobs: validate_cli: name: Validate CLI or core snapshot line needs: [plan, publish] - if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' runs-on: ubuntu-latest permissions: contents: read @@ -189,27 +191,32 @@ jobs: export CAPLETS_CONFIG="$INSTALL_ROOT/caplets-config.json" export NO_COLOR=1 export PATH="$INSTALL_ROOT/bin:$PATH" - node scripts/check-dev-snapshot-bootstrap.mjs plan --manifest .tmp/dev-snapshot-manifest.json > .tmp/bootstrap-plan.json - cli_version=$(node --input-type=module <<'NODE' + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; import { readJson } from './scripts/dev-snapshot-release.mjs'; + import { buildValidationCommands } from './scripts/check-dev-snapshot-bootstrap.mjs'; const manifest = readJson('.tmp/dev-snapshot-manifest.json'); - const release = manifest.releases.find((entry) => entry.name === 'caplets'); - console.log(release?.newVersion ?? ''); + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + for (const command of buildValidationCommands(manifest, { installRoot: process.env.INSTALL_ROOT })) { + const [binary, ...args] = command.split(' '); + const attempts = binary === 'npm' && args[0] === 'install' ? 12 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + execFileSync(binary, args, { stdio: 'inherit', env: process.env }); + break; + } catch (error) { + if (attempt === attempts) throw error; + await sleep(5_000); + } + } + } NODE - ) - if [ -z "$cli_version" ]; then - echo "Could not resolve caplets snapshot version from manifest." >&2 - exit 1 - fi - npm install -g "caplets@${cli_version}" - caplets --version - caplets setup mcp-client --output "$INSTALL_ROOT/caplets.mcp.json" --format json node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" validate_packages: name: Validate package-only snapshot line needs: [plan, publish] - if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'package-only' + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'package-only' runs-on: ubuntu-latest permissions: contents: read @@ -256,12 +263,19 @@ jobs: import { readJson } from './scripts/dev-snapshot-release.mjs'; import { buildValidationCommands } from './scripts/check-dev-snapshot-bootstrap.mjs'; const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); for (const command of buildValidationCommands(manifest, { installRoot: process.env.INSTALL_ROOT })) { const [binary, ...args] = command.split(' '); - execFileSync(binary, args, { - stdio: 'inherit', - env: process.env, - }); + const attempts = binary === 'npm' && args[0] === 'install' ? 12 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + execFileSync(binary, args, { stdio: 'inherit', env: process.env }); + break; + } catch (error) { + if (attempt === attempts) throw error; + await sleep(5_000); + } + } } NODE node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" @@ -269,7 +283,7 @@ jobs: validation_complete: name: Validation barrier needs: [plan, validate_cli, validate_packages] - if: always() && needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -290,11 +304,11 @@ jobs: promote: name: Promote validated snapshots to dev needs: [plan, publish, validation_complete] - if: needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' runs-on: ubuntu-latest + environment: npm-release permissions: contents: read - id-token: write steps: - name: Stop if the run was not triggered from main run: | @@ -313,6 +327,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24 + registry-url: https://registry.npmjs.org - name: Download manifest artifact uses: actions/download-artifact@v4 @@ -320,6 +335,15 @@ jobs: name: dev-snapshot-manifest path: .tmp + - name: Require current main commit before promotion + run: | + git fetch --no-tags origin main + current_main="$(git rev-parse origin/main)" + test "$GITHUB_SHA" = "$current_main" || { + echo "Refusing to promote stale main commit $GITHUB_SHA; current main is $current_main." >&2 + exit 1 + } + - name: Capture pre-run dist-tags run: | node --input-type=module <<'NODE' @@ -345,6 +369,8 @@ jobs: path: .tmp/pre-run-tags.json - name: Promote validated versions to dev with reconciliation + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} run: | node --input-type=module <<'NODE' import { execFileSync } from 'node:child_process'; @@ -352,44 +378,78 @@ jobs: import { readJson } from './scripts/dev-snapshot-release.mjs'; const manifest = readJson('.tmp/dev-snapshot-manifest.json'); const preRunTags = JSON.parse(readFileSync('.tmp/pre-run-tags.json', 'utf8')); - const promoted = []; + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const readTags = (packageName) => JSON.parse( + execFileSync('npm', ['view', packageName, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }), + ); + async function waitForTags(packageName, predicate, description) { + const deadline = Date.now() + 120_000; + let tags; + do { + tags = readTags(packageName); + if (predicate(tags)) return tags; + await sleep(5_000); + } while (Date.now() < deadline); + throw new Error(`${packageName} did not ${description}; last tags: ${JSON.stringify(tags)}`); + } + const attempted = []; try { for (const release of manifest.releases) { + attempted.push(release); execFileSync('npm', ['dist-tag', 'add', `${release.name}@${release.newVersion}`, 'dev'], { stdio: 'inherit', env: process.env, }); - promoted.push(release); - const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { - encoding: 'utf8', - env: process.env, - }); - const tags = JSON.parse(raw); + const tags = await waitForTags( + release.name, + (candidate) => candidate.dev === release.newVersion, + `publish dev=${release.newVersion}`, + ); if ((preRunTags[release.name]?.latest ?? null) !== (tags.latest ?? null)) { throw new Error(`${release.name} latest changed unexpectedly during dev promotion.`); } } } catch (error) { - for (const release of promoted.reverse()) { - const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { - encoding: 'utf8', - env: process.env, - }); - const tags = JSON.parse(raw); - if (tags.dev !== release.newVersion) continue; - const previousDev = preRunTags[release.name]?.dev; - if (typeof previousDev === 'string' && previousDev.length > 0) { - execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { - stdio: 'inherit', - env: process.env, - }); - } else { - execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { - stdio: 'inherit', - env: process.env, - }); + const rollbackFailures = []; + for (const release of attempted.reverse()) { + try { + await waitForTags( + release.name, + (candidate) => candidate.dev === release.newVersion, + `expose attempted dev=${release.newVersion}`, + ); + const previousDev = preRunTags[release.name]?.dev; + if (typeof previousDev === 'string' && previousDev.length > 0) { + execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + await waitForTags( + release.name, + (candidate) => candidate.dev === previousDev, + `restore dev=${previousDev}`, + ); + } else { + execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + await waitForTags( + release.name, + (candidate) => candidate.dev === undefined, + 'remove dev tag', + ); + } + } catch (rollbackError) { + rollbackFailures.push(rollbackError); } } + if (rollbackFailures.length > 0) { + throw new AggregateError([error, ...rollbackFailures], 'Dev promotion and reconciliation failed.'); + } throw error; } NODE @@ -397,7 +457,7 @@ jobs: verify_promoted_cli: name: Verify promoted caplets@dev line needs: [plan, promote] - if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' runs-on: ubuntu-latest permissions: contents: read @@ -437,7 +497,44 @@ jobs: export CAPLETS_CONFIG="$INSTALL_ROOT/caplets-config.json" export NO_COLOR=1 export PATH="$INSTALL_ROOT/bin:$PATH" - npm install -g caplets@dev + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readJson, CORE_PACKAGE_NAME } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const readTags = (packageName) => JSON.parse( + execFileSync('npm', ['view', packageName, 'dist-tags', '--json'], { + encoding: 'utf8', + env: process.env, + }), + ); + for (const release of manifest.releases) { + const deadline = Date.now() + 120_000; + let tags; + do { + tags = readTags(release.name); + if (tags.dev === release.newVersion) break; + await sleep(5_000); + } while (Date.now() < deadline); + if (tags.dev !== release.newVersion) { + throw new Error(`${release.name} dev resolved to ${tags.dev}, expected ${release.newVersion}.`); + } + if (release.name !== CORE_PACKAGE_NAME) { + for (let attempt = 1; attempt <= 12; attempt += 1) { + try { + execFileSync('npm', ['install', '-g', `${release.name}@dev`], { + stdio: 'inherit', + env: process.env, + }); + break; + } catch (error) { + if (attempt === 12) throw error; + await sleep(5_000); + } + } + } + } + NODE caplets --version caplets setup mcp-client --output "$INSTALL_ROOT/caplets.mcp.json" --format json node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" @@ -445,11 +542,11 @@ jobs: reconcile_promoted_cli_failure: name: Reconcile failed promoted dev line needs: [plan, promote, verify_promoted_cli] - if: always() && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'failure' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'failure' runs-on: ubuntu-latest + environment: npm-release permissions: contents: read - id-token: write steps: - name: Checkout uses: actions/checkout@v7 @@ -460,6 +557,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24 + registry-url: https://registry.npmjs.org - name: Download manifest artifact uses: actions/download-artifact@v4 @@ -474,6 +572,8 @@ jobs: path: .tmp - name: Restore dev tags after promoted smoke failure + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} run: | node --input-type=module <<'NODE' import { execFileSync } from 'node:child_process'; @@ -481,26 +581,57 @@ jobs: import { readJson } from './scripts/dev-snapshot-release.mjs'; const manifest = readJson('.tmp/dev-snapshot-manifest.json'); const preRunTags = JSON.parse(readFileSync('.tmp/pre-run-tags.json', 'utf8')); - for (const release of [...manifest.releases].reverse()) { - const raw = execFileSync('npm', ['view', release.name, 'dist-tags', '--json'], { + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const readTags = (packageName) => JSON.parse( + execFileSync('npm', ['view', packageName, 'dist-tags', '--json'], { encoding: 'utf8', env: process.env, - }); - const tags = JSON.parse(raw); - if (tags.dev !== release.newVersion) continue; - const previousDev = preRunTags[release.name]?.dev; - if (typeof previousDev === 'string' && previousDev.length > 0) { - execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { - stdio: 'inherit', - env: process.env, - }); - } else { - execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { - stdio: 'inherit', - env: process.env, - }); + }), + ); + async function waitForTags(packageName, predicate, description) { + const deadline = Date.now() + 120_000; + let tags; + do { + tags = readTags(packageName); + if (predicate(tags)) return; + await sleep(5_000); + } while (Date.now() < deadline); + throw new Error(`${packageName} did not ${description}; last tags: ${JSON.stringify(tags)}`); + } + const failures = []; + for (const release of [...manifest.releases].reverse()) { + try { + const tags = readTags(release.name); + if (tags.dev !== release.newVersion) continue; + const previousDev = preRunTags[release.name]?.dev; + if (typeof previousDev === 'string' && previousDev.length > 0) { + execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + await waitForTags( + release.name, + (candidate) => candidate.dev === previousDev, + `restore dev=${previousDev}`, + ); + } else { + execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + await waitForTags( + release.name, + (candidate) => candidate.dev === undefined, + 'remove dev tag', + ); + } + } catch (error) { + failures.push(error); } } + if (failures.length > 0) { + throw new AggregateError(failures, 'Failed to reconcile all promoted dev tags.'); + } NODE - name: Fail after reconciliation @@ -511,8 +642,9 @@ jobs: dev_image: name: Publish best-effort dev image needs: [plan, promote, verify_promoted_cli] - if: needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'success' runs-on: ubuntu-latest + environment: npm-release permissions: contents: read packages: write @@ -538,7 +670,7 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 - push: true + "push": true tags: | ghcr.io/spiritledsoftware/caplets:dev ghcr.io/spiritledsoftware/caplets:dev-${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7af98e3..5f069e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: branches: - main - workflow_dispatch: permissions: contents: write @@ -133,3 +132,11 @@ jobs: CAPLETS_RUNTIME_SENTRY_DSN=${{ secrets.CAPLETS_RUNTIME_SENTRY_DSN }} CAPLETS_SENTRY_RELEASE=caplets-runtime@${{ github.sha }} CAPLETS_SENTRY_ENVIRONMENT=production + dev_snapshot: + name: Release dev snapshots + needs: release + uses: ./.github/workflows/dev-snapshot-release.yml + permissions: + contents: read + id-token: write + packages: write diff --git a/packages/core/test/dev-snapshot-bootstrap.test.ts b/packages/core/test/dev-snapshot-bootstrap.test.ts index 1d213037..53a9c8cd 100644 --- a/packages/core/test/dev-snapshot-bootstrap.test.ts +++ b/packages/core/test/dev-snapshot-bootstrap.test.ts @@ -1,13 +1,12 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { PI_PACKAGE_NAME, OPENCODE_PACKAGE_NAME } from "../../../scripts/dev-snapshot-release.mjs"; import { assertInstalledSnapshotLine, buildValidationCommands, - createIsolatedValidationEnv, derivePackageOnlyTargets, + createIsolatedValidationEnv, deriveValidationPlan, type BootstrapManifest, } from "../../../scripts/check-dev-snapshot-bootstrap.mjs"; @@ -20,130 +19,225 @@ afterEach(() => { } }); +function createTempDirectory(prefix: string) { + const path = mkdtempSync(join(tmpdir(), prefix)); + tempPaths.push(path); + return path; +} + +function writeInstalledPackage( + installRoot: string, + packageName: string, + manifest: Record, + parentPackageName?: string, +) { + const packageDirectory = parentPackageName + ? join(installRoot, "lib", "node_modules", parentPackageName, "node_modules", packageName) + : join(installRoot, "lib", "node_modules", packageName); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync(join(packageDirectory, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); +} + +function fullCliSnapshotManifest(): BootstrapManifest { + return { + validation: { + kind: "cli-bootstrap", + packages: ["caplets", "@caplets/core", "@caplets/pi", "@caplets/opencode"], + }, + releases: [ + { name: "caplets", newVersion: "1.0.0-dev-fixture-20260708120000" }, + { name: "@caplets/core", newVersion: "2.0.0-dev-fixture-20260708120000" }, + { name: "@caplets/pi", newVersion: "3.0.0-dev-fixture-20260708120000" }, + { name: "@caplets/opencode", newVersion: "4.0.0-dev-fixture-20260708120000" }, + ], + }; +} + describe("dev snapshot bootstrap helpers", () => { - it("derives cli bootstrap validation when caplets or core is present", () => { - const manifest: BootstrapManifest = { - validation: { kind: "cli-bootstrap", packages: ["caplets", "@caplets/core"] }, - releases: [ - { name: "caplets", newVersion: "0.25.7-dev-abc-20260708120000" }, - { name: "@caplets/core", newVersion: "0.33.0-dev-abc-20260708120000" }, - ], - }; + it("installs every promoted CLI closure package after bootstrapping caplets", () => { + const manifest = fullCliSnapshotManifest(); expect(deriveValidationPlan(manifest)).toEqual({ kind: "cli-bootstrap", cliPackage: "caplets", expectedCorePackage: "@caplets/core", - packages: ["caplets", "@caplets/core"], + packages: ["caplets", "@caplets/core", "@caplets/pi", "@caplets/opencode"], }); expect(buildValidationCommands(manifest)).toEqual([ - "npm install -g caplets@0.25.7-dev-abc-20260708120000", + "npm install -g caplets@1.0.0-dev-fixture-20260708120000", "caplets --version", "caplets setup mcp-client --output ${INSTALL_ROOT}/caplets.mcp.json --format json", + "npm install -g @caplets/pi@3.0.0-dev-fixture-20260708120000", + "npm install -g @caplets/opencode@4.0.0-dev-fixture-20260708120000", ]); }); - it("derives package-only validation targets and retains peer-host metadata", () => { + it("propagates peer declarations from a synthetic public package", () => { + const root = createTempDirectory("caplets-dev-snapshot-workspace-"); + const packageDirectory = join(root, "packages", "peer-host"); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + join(packageDirectory, "package.json"), + `${JSON.stringify( + { + name: "@fixture/peer-host", + version: "1.0.0", + publishConfig: { access: "public" }, + peerDependencies: { "@fixture/agent-api": ">=2" }, + }, + null, + 2, + )}\n`, + ); const manifest: BootstrapManifest = { - validation: { kind: "package-only", packages: [PI_PACKAGE_NAME, OPENCODE_PACKAGE_NAME] }, - releases: [ - { name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }, - { name: OPENCODE_PACKAGE_NAME, newVersion: "0.8.15-dev-abc-20260708120000" }, - ], + validation: { kind: "package-only", packages: ["@fixture/peer-host"] }, + releases: [{ name: "@fixture/peer-host", newVersion: "1.0.1-dev-fixture" }], }; - const targets = derivePackageOnlyTargets(manifest); - expect(targets).toEqual([ + expect(derivePackageOnlyTargets(manifest, root)).toEqual([ { - name: PI_PACKAGE_NAME, - peerDependencies: ["@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"], - validationKind: "install-only", - }, - { - name: OPENCODE_PACKAGE_NAME, - peerDependencies: ["@opencode-ai/plugin"], + name: "@fixture/peer-host", + peerDependencies: ["@fixture/agent-api"], validationKind: "install-only", }, ]); expect(buildValidationCommands(manifest)).toEqual([ - "npm install -g @caplets/pi@0.9.14-dev-abc-20260708120000", - "npm install -g @caplets/opencode@0.8.15-dev-abc-20260708120000", + "npm install -g @fixture/peer-host@1.0.1-dev-fixture", ]); }); - it("creates an isolated validation environment", () => { - const isolated = createIsolatedValidationEnv(); - tempPaths.push(isolated.baseDirectory); + it("accepts npm's shallow global CLI layout with nested core and root closure packages", () => { + const manifest = fullCliSnapshotManifest(); + const installRoot = createTempDirectory("caplets-dev-install-root-"); + const targets = new Map( + derivePackageOnlyTargets(manifest).map((target) => [target.name, target]), + ); + const installedManifest = (packageName: string, version: string) => ({ + name: packageName, + version, + peerDependencies: Object.fromEntries( + (targets.get(packageName)?.peerDependencies ?? []).map((peerDependency) => [ + peerDependency, + "*", + ]), + ), + }); + + writeInstalledPackage( + installRoot, + "caplets", + installedManifest("caplets", "1.0.0-dev-fixture-20260708120000"), + ); + writeInstalledPackage( + installRoot, + "@caplets/core", + installedManifest("@caplets/core", "2.0.0-dev-fixture-20260708120000"), + "caplets", + ); + writeInstalledPackage( + installRoot, + "@caplets/pi", + installedManifest("@caplets/pi", "3.0.0-dev-fixture-20260708120000"), + ); + writeInstalledPackage( + installRoot, + "@caplets/opencode", + installedManifest("@caplets/opencode", "4.0.0-dev-fixture-20260708120000"), + ); - expect(isolated.env.CAPLETS_CONFIG).toContain("caplets-config.json"); - expect(isolated.env.npm_config_prefix).toContain("npm-prefix"); - expect(isolated.env.HOME).toContain("home"); + expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([]); }); - it("asserts installed exact versions and peer declarations for package-only lines", () => { - const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); - tempPaths.push(installRoot); - const piDir = join(installRoot, "lib", "node_modules", "@caplets", "pi"); - mkdirSync(piDir, { recursive: true }); - writeFileSync( - join(piDir, "package.json"), - JSON.stringify( + it("rejects CLI manifests that omit an expected caplets or core snapshot version", () => { + const completeManifest = fullCliSnapshotManifest(); + for (const missingPackage of ["caplets", "@caplets/core"]) { + const installRoot = createTempDirectory("caplets-dev-install-root-"); + writeInstalledPackage(installRoot, "caplets", { + name: "caplets", + version: "1.0.0-dev-fixture-20260708120000", + }); + writeInstalledPackage( + installRoot, + "@caplets/core", { - name: PI_PACKAGE_NAME, - version: "0.9.14-dev-abc-20260708120000", - peerDependencies: { - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - }, + name: "@caplets/core", + version: "2.0.0-dev-fixture-20260708120000", }, - null, - 2, - ), - ); - - const manifest: BootstrapManifest = { - validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, - releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], - }; + "caplets", + ); + const manifest: BootstrapManifest = { + ...completeManifest, + releases: completeManifest.releases.filter((release) => release.name !== missingPackage), + }; + + const errors = assertInstalledSnapshotLine(manifest, installRoot); + expect(errors.join("\n")).toMatch( + new RegExp( + `${missingPackage.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*(?:version|release)|(?:version|release).*${missingPackage.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + "i", + ), + ); + } + }); - expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([]); + it("keeps a caller-supplied validation root isolated across npm and user state", () => { + const root = createTempDirectory("caplets-dev-validation-root-"); + const isolated = createIsolatedValidationEnv(root); + + const isolatedPaths = [ + isolated.env.HOME, + isolated.env.USERPROFILE, + isolated.env.npm_config_prefix, + isolated.env.npm_config_cache, + isolated.env.XDG_CONFIG_HOME, + isolated.env.XDG_STATE_HOME, + isolated.env.CAPLETS_CONFIG, + ]; + expect( + isolatedPaths.every( + (path): path is string => typeof path === "string" && path.startsWith(root), + ), + ).toBe(true); + expect(isolated.env.NO_COLOR).toBe("1"); }); - it("reports missing peer declarations for peer-host package lines", () => { - const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); - tempPaths.push(installRoot); - const piDir = join(installRoot, "lib", "node_modules", "@caplets", "pi"); - mkdirSync(piDir, { recursive: true }); - writeFileSync( - join(piDir, "package.json"), - JSON.stringify({ name: PI_PACKAGE_NAME, version: "0.9.14-dev-abc-20260708120000" }, null, 2), + it("reports the missing locations for globally installed CLI packages", () => { + const installRoot = createTempDirectory("caplets-dev-install-root-"); + const errors = assertInstalledSnapshotLine(fullCliSnapshotManifest(), installRoot); + + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `caplets is not installed at ${join(installRoot, "lib", "node_modules", "caplets", "package.json")}`, + ), + expect.stringContaining( + `@caplets/core is not installed at ${join(installRoot, "lib", "node_modules", "caplets", "node_modules", "@caplets", "core", "package.json")}`, + ), + ]), ); - - const manifest: BootstrapManifest = { - validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, - releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], - }; - - expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([ - `${PI_PACKAGE_NAME} is missing peer dependency declaration for @earendil-works/pi-coding-agent.`, - `${PI_PACKAGE_NAME} is missing peer dependency declaration for @earendil-works/pi-tui.`, - ]); }); - it("reports missing installed packages as descriptive errors", () => { - const installRoot = mkdtempSync(join(tmpdir(), "caplets-dev-install-root-")); - tempPaths.push(installRoot); - + it("reports installed peer hosts that omit the package manifest's peer declarations", () => { + const packageName = "@caplets/pi"; + const installRoot = createTempDirectory("caplets-dev-install-root-"); + const expectedPeers = Object.keys( + JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "pi", "package.json"), "utf8")) + .peerDependencies ?? {}, + ); const manifest: BootstrapManifest = { - validation: { kind: "package-only", packages: [PI_PACKAGE_NAME] }, - releases: [{ name: PI_PACKAGE_NAME, newVersion: "0.9.14-dev-abc-20260708120000" }], + validation: { kind: "package-only", packages: [packageName] }, + releases: [{ name: packageName, newVersion: "3.0.0-dev-fixture-20260708120000" }], }; + writeInstalledPackage(installRoot, packageName, { + name: packageName, + version: "3.0.0-dev-fixture-20260708120000", + }); - const errors = assertInstalledSnapshotLine(manifest, installRoot); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain( - `${PI_PACKAGE_NAME} is not installed at ${join(installRoot, "lib", "node_modules", "@caplets", "pi", "package.json")}`, + expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual( + expectedPeers.map( + (peerDependency) => + `${packageName} is missing peer dependency declaration for ${peerDependency}.`, + ), ); - expect(errors[0]).toContain("ENOENT"); }); }); diff --git a/packages/core/test/dev-snapshot-release.test.ts b/packages/core/test/dev-snapshot-release.test.ts index 59442932..c7f14f9a 100644 --- a/packages/core/test/dev-snapshot-release.test.ts +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -1,17 +1,17 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { - CLI_PACKAGE_NAME, - CORE_PACKAGE_NAME, createStagingTag, deriveChangesetManifest, discoverWorkspacePackageManifests, expandPublicReleaseClosure, listPublicPublishablePackages, patchSnapshotConfig, - readJson, + refreshSnapshotManifestVersions, + writePatchedSnapshotConfig, } from "../../../scripts/dev-snapshot-release.mjs"; const repoRoot = resolve(import.meta.dirname, "../../.."); @@ -23,73 +23,317 @@ afterEach(() => { } }); +function writeWorkspacePackage(root: string, directory: string, manifest: Record) { + const packageDirectory = join(root, "packages", directory); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync(join(packageDirectory, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); +} + +function createWorkspaceFixture( + packages: Array<{ directory: string; manifest: Record }>, +) { + const root = mkdtempSync(join(tmpdir(), "caplets-dev-snapshot-workspace-")); + tempPaths.push(root); + for (const entry of packages) { + writeWorkspacePackage(root, entry.directory, entry.manifest); + } + return root; +} + +function gitFixtureEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const name of execFileSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }) + .trim() + .split("\n")) { + delete env[name]; + } + env.GIT_CONFIG_GLOBAL = "/dev/null"; + env.GIT_CONFIG_NOSYSTEM = "1"; + return env; +} + +function workflowJob(workflow: string, jobName: string) { + const start = workflow.indexOf(` ${jobName}:\n`); + expect(start).toBeGreaterThanOrEqual(0); + const remainder = workflow.slice(start + 1); + const nextJob = remainder.search(/\n [a-z_][a-z0-9_]*:\n/); + return nextJob === -1 ? remainder : remainder.slice(0, nextJob); +} + +function workflowStep(job: string, stepName: string) { + const start = job.indexOf(` - name: ${stepName}\n`); + expect(start).toBeGreaterThanOrEqual(0); + const remainder = job.slice(start); + const nextStep = remainder.indexOf("\n - name: ", 1); + return nextStep === -1 ? remainder : remainder.slice(0, nextStep); +} +function workflowJobContaining(workflow: string, content: string) { + const jobNames = [...workflow.matchAll(/^ ([a-z_][a-z0-9_-]*):$/gm)].map((match) => match[1]!); + const matchingJobs = jobNames + .map((jobName) => workflowJob(workflow, jobName)) + .filter((job) => job.includes(content)); + expect(matchingJobs).toHaveLength(1); + return matchingJobs[0]!; +} + describe("dev snapshot release helpers", () => { - it("discovers the public publishable package universe", () => { - const publicPackages = listPublicPublishablePackages(repoRoot) - .map((entry) => entry.name) - .sort(); + it("filters public workspace packages and expands their dependent release closure", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@fixture/core", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "fixture-cli", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@fixture/core": "workspace:^" }, + }, + }, + { + directory: "peer-host", + manifest: { + name: "@fixture/peer-host", + version: "1.0.0", + publishConfig: { access: "public" }, + peerDependencies: { "@fixture/core": "workspace:*" }, + }, + }, + { + directory: "private-dependent", + manifest: { + name: "@fixture/private-dependent", + version: "1.0.0", + private: true, + publishConfig: { access: "public" }, + dependencies: { "@fixture/core": "workspace:^" }, + }, + }, + { + directory: "restricted", + manifest: { name: "@fixture/restricted", version: "1.0.0" }, + }, + ]); + + expect( + listPublicPublishablePackages(root) + .map((entry) => entry.name) + .sort(), + ).toEqual(["@fixture/core", "@fixture/peer-host", "fixture-cli"]); + expect( + expandPublicReleaseClosure(["@fixture/core"], discoverWorkspacePackageManifests(root)), + ).toEqual(["@fixture/core", "@fixture/peer-host", "fixture-cli"]); + }); + + it("derives a CLI bootstrap manifest from a synthetic public dependency closure", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@caplets/core", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "caplets", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + { + directory: "native-host", + manifest: { + name: "@fixture/native-host", + version: "1.0.0", + publishConfig: { access: "public" }, + peerDependencies: { "@caplets/core": "workspace:*" }, + }, + }, + ]); + + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: "@caplets/core", + type: "minor", + oldVersion: "1.0.0", + newVersion: "1.1.0", + changesets: ["core-feature"], + }, + ], + }, + { repoRoot: root }, + ); - expect(publicPackages).toEqual([ + expect(manifest).toMatchObject({ + hasPublicReleases: true, + validation: { + kind: "cli-bootstrap", + packages: ["@caplets/core", "@fixture/native-host", "caplets"], + }, + }); + expect(manifest.releases.map((entry) => entry.name)).toEqual([ "@caplets/core", - "@caplets/opencode", - "@caplets/pi", + "@fixture/native-host", "caplets", ]); + expect(manifest.releases.find((entry) => entry.name === "@caplets/core")).toMatchObject({ + newVersion: "1.1.0", + direct: true, + }); + expect(manifest.releases.find((entry) => entry.name === "caplets")).toMatchObject({ + newVersion: "1.0.0", + direct: false, + }); }); - it("expands release closure through workspace dependencies", () => { - const manifests = discoverWorkspacePackageManifests(repoRoot); - expect(expandPublicReleaseClosure([CORE_PACKAGE_NAME], manifests)).toEqual([ + it("seeds core for a caplets-only release and snapshots the complete public line", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@caplets/core", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "caplets", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + { + directory: "pi", + manifest: { + name: "@caplets/pi", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + { + directory: "opencode", + manifest: { + name: "@caplets/opencode", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + ]); + + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: "caplets", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["cli-only"], + }, + ], + }, + { repoRoot: root }, + ); + + expect(manifest.releases.map((entry) => entry.name)).toEqual([ "@caplets/core", "@caplets/opencode", "@caplets/pi", "caplets", ]); + expect(manifest.validation).toEqual({ + kind: "cli-bootstrap", + packages: ["@caplets/core", "@caplets/opencode", "@caplets/pi", "caplets"], + }); }); - it("derives a manifest with cli bootstrap validation for core changes", () => { - const manifest = deriveChangesetManifest( + it("gives closure-only packages the snapshot suffix produced for direct releases", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@fixture/core", + version: "1.1.0-dev-abc123-20260708120000", + publishConfig: { access: "public" }, + }, + }, + { + directory: "closure-only", + manifest: { + name: "@fixture/closure-only", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@fixture/core": "workspace:^" }, + }, + }, + ]); + + const snapshotManifest = deriveChangesetManifest( { releases: [ { - name: CORE_PACKAGE_NAME, + name: "@fixture/core", type: "minor", - oldVersion: "0.32.4", - newVersion: "0.33.0", - changesets: ["admin-dashboard"], + oldVersion: "1.0.0", + newVersion: "1.1.0", + changesets: ["core-snapshot"], }, ], }, - { repoRoot }, + { repoRoot: root }, ); + const refreshed = refreshSnapshotManifestVersions(snapshotManifest, root); - expect(manifest.hasPublicReleases).toBe(true); - expect(manifest.validation).toEqual({ - kind: "cli-bootstrap", - packages: [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME], + expect( + Object.fromEntries(refreshed.releases.map((release) => [release.name, release.newVersion])), + ).toEqual({ + "@fixture/core": "1.1.0-dev-abc123-20260708120000", + "@fixture/closure-only": "1.0.0-dev-abc123-20260708120000", }); - expect(manifest.releases.map((entry) => entry.name).sort()).toEqual([ - "@caplets/core", - "@caplets/opencode", - "@caplets/pi", - "caplets", - ]); }); - it("returns no public releases when status contains only ignored or private workspaces", () => { + it("returns no public releases when status contains only unknown workspaces", () => { + const root = createWorkspaceFixture([ + { + directory: "public", + manifest: { + name: "@fixture/public", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + ]); + const manifest = deriveChangesetManifest( { releases: [ { - name: "@caplets/catalog", - type: "none", + name: "@fixture/unknown", + type: "patch", oldVersion: "0.1.0", - newVersion: "0.1.0", - changesets: [], + newVersion: "0.1.1", + changesets: ["unrelated"], }, ], }, - { repoRoot }, + { repoRoot: root }, ); expect(manifest.hasPublicReleases).toBe(false); @@ -101,29 +345,102 @@ describe("dev snapshot release helpers", () => { expect(() => createStagingTag("")).toThrow(/run identifier/i); }); - it("patches changeset snapshot config for calculated versions", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "caplets-dev-snapshot-config-")); - tempPaths.push(tempRoot); - const changesetDir = join(tempRoot, ".changeset"); - mkdirSync(changesetDir, { recursive: true }); - writeFileSync(join(tempRoot, "package.json"), "{}\n"); + it("patches Changesets config and composes a dev snapshot prerelease", () => { + const root = createWorkspaceFixture([ + { + directory: "snapshot-target", + manifest: { + name: "@fixture/snapshot-target", + version: "1.2.3", + publishConfig: { access: "public" }, + }, + }, + ]); + const changesetDirectory = join(root, ".changeset"); + mkdirSync(changesetDirectory, { recursive: true }); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify( + { + name: "snapshot-fixture", + private: true, + packageManager: "pnpm@11.9.0", + workspaces: ["packages/*"], + }, + null, + 2, + )}\n`, + ); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"); + writeFileSync( + join(changesetDirectory, "config.json"), + `${JSON.stringify( + { + changelog: false, + commit: false, + fixed: [], + linked: [], + access: "public", + baseBranch: "main", + updateInternalDependencies: "patch", + ignore: [], + }, + null, + 2, + )}\n`, + ); writeFileSync( - join(changesetDir, "config.json"), - `${JSON.stringify(readJson(join(repoRoot, ".changeset/config.json")), null, 2)}\n`, + join(changesetDirectory, "snapshot.md"), + '---\n"@fixture/snapshot-target": patch\n---\n\nSnapshot fixture.\n', ); - const patched = patchSnapshotConfig(tempRoot); - expect(patched.snapshot).toMatchObject({ + expect(patchSnapshotConfig(root).snapshot).toMatchObject({ useCalculatedVersion: true, - prereleaseTemplate: "dev-{commit}-{datetime}", + prereleaseTemplate: "{tag}-{commit}-{datetime}", + }); + writePatchedSnapshotConfig(root); + + execFileSync("git", ["init", "--initial-branch=main", root], { + env: gitFixtureEnv(), + stdio: "pipe", + }); + execFileSync("git", ["-C", root, "config", "user.email", "tests@example.invalid"], { + env: gitFixtureEnv(), }); + execFileSync("git", ["-C", root, "config", "user.name", "Snapshot Test"], { + env: gitFixtureEnv(), + }); + execFileSync("git", ["-C", root, "add", "."], { env: gitFixtureEnv() }); + execFileSync("git", ["-C", root, "commit", "-m", "snapshot fixture"], { + env: gitFixtureEnv(), + stdio: "pipe", + }); + execFileSync( + process.execPath, + [ + join(repoRoot, "node_modules", "@changesets", "cli", "bin.js"), + "version", + "--snapshot", + "dev", + ], + { cwd: root, env: gitFixtureEnv(), stdio: "pipe" }, + ); + + const snapshotPackage = JSON.parse( + readFileSync(join(root, "packages", "snapshot-target", "package.json"), "utf8"), + ) as { version: string }; + expect(snapshotPackage.version).toMatch(/^1\.2\.4-dev-[0-9a-f]+-\d{14}$/); }); - it("wires the dev snapshot workflow with separate validation and promotion barriers", () => { + it("requires the main ref and a fresh main commit before publishing and promotion", () => { const workflow = readFileSync( join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), "utf8", ); + const publish = workflowJob(workflow, "publish"); + const promote = workflowJob(workflow, "promote"); + const mainOnlyCondition = + "github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'"; expect(workflow).toContain("name: Dev Snapshot Release"); expect(workflow).toContain("cancel-in-progress: false"); @@ -139,10 +456,125 @@ describe("dev snapshot release helpers", () => { expect(workflow.split('export PATH="$INSTALL_ROOT/bin:$PATH"').length - 1).toBe(2); expect(workflow.split("persist-credentials: false").length - 1).toBe(8); expect(workflow).not.toContain('eval "$command"'); - expect(workflow).not.toContain("registry-url: https://registry.npmjs.org"); expect(workflow).not.toContain("Stop when dry run is requested"); expect(workflow).toContain( - "if: always() && needs.plan.outputs.has_public_releases == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)", + "if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'", + ); + expect(publish).toContain(`if: ${mainOnlyCondition}`); + expect(promote).toContain(`if: ${mainOnlyCondition}`); + for (const job of [publish, promote]) { + expect(job).toMatch( + /git fetch --no-tags origin main\n\s+current_main="\$\(git rev-parse origin\/main\)"\n\s+test "\$GITHUB_SHA" = "\$current_main"/, + ); + } + expect(publish.indexOf("Require current main commit before publishing")).toBeLessThan( + publish.indexOf("Publish exact snapshots"), + ); + expect(promote.indexOf("Require current main commit before promotion")).toBeLessThan( + promote.indexOf("Promote validated versions"), + ); + }); + + it("limits snapshot release authority to the reusable protected jobs", () => { + const snapshotWorkflow = readFileSync( + join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), + "utf8", ); + const releaseWorkflow = readFileSync(join(repoRoot, ".github/workflows/release.yml"), "utf8"); + const publish = workflowJob(snapshotWorkflow, "publish"); + const promote = workflowJob(snapshotWorkflow, "promote"); + const reconcilePromotedCliFailure = workflowJob( + snapshotWorkflow, + "reconcile_promoted_cli_failure", + ); + const devImage = workflowJob(snapshotWorkflow, "dev_image"); + const environmentProtectedJobs = [publish, promote, reconcilePromotedCliFailure, devImage]; + const registryJobs = [publish, promote, reconcilePromotedCliFailure]; + const jobsWithoutNpmToken = [ + workflowJob(snapshotWorkflow, "plan"), + publish, + workflowJob(snapshotWorkflow, "validate_cli"), + workflowJob(snapshotWorkflow, "validate_packages"), + workflowJob(snapshotWorkflow, "validation_complete"), + workflowJob(snapshotWorkflow, "verify_promoted_cli"), + devImage, + ]; + const jobsWithoutNpmRegistry = jobsWithoutNpmToken.filter((job) => job !== publish); + const npmRegistryUrl = /registry-url: "?https:\/\/registry\.npmjs\.org"?/; + + const tokenScopedSteps = [ + { + job: promote, + mutationStep: workflowStep( + promote, + "Promote validated versions to dev with reconciliation", + ), + unprivilegedSteps: ["Checkout", "Setup Node", "Download manifest artifact"], + }, + { + job: reconcilePromotedCliFailure, + mutationStep: workflowStep( + reconcilePromotedCliFailure, + "Restore dev tags after promoted smoke failure", + ), + unprivilegedSteps: [ + "Checkout", + "Setup Node", + "Download manifest artifact", + "Download pre-run tag snapshot", + ], + }, + ]; + + expect(snapshotWorkflow).toContain(" workflow_call:"); + expect(snapshotWorkflow).not.toMatch(/^ {2}push:$/m); + expect(snapshotWorkflow).not.toContain("workflow_dispatch:"); + expect(releaseWorkflow).not.toContain("workflow_dispatch:"); + expect(releaseWorkflow).toContain("uses: ./.github/workflows/dev-snapshot-release.yml"); + + const snapshotCall = workflowJobContaining( + releaseWorkflow, + "uses: ./.github/workflows/dev-snapshot-release.yml", + ); + expect(snapshotCall).toContain("needs: release"); + expect(snapshotCall).toContain("contents: read"); + expect(snapshotCall).toContain("id-token: write"); + expect(snapshotCall).toContain("packages: write"); + + expect(publish).toContain("id-token: write"); + expect(promote).not.toContain("id-token: write"); + expect(reconcilePromotedCliFailure).not.toContain("id-token: write"); + expect(publish).not.toContain("NODE_AUTH_TOKEN"); + + for (const job of environmentProtectedJobs) { + expect(job).toContain("environment: npm-release"); + } + expect(snapshotWorkflow.split("environment: npm-release").length - 1).toBe(4); + + for (const job of registryJobs) { + expect(job).toMatch(npmRegistryUrl); + } + expect( + snapshotWorkflow.match(/registry-url: "?https:\/\/registry\.npmjs\.org"?/g) ?? [], + ).toHaveLength(3); + + for (const { job, mutationStep, unprivilegedSteps } of tokenScopedSteps) { + const jobDefinition = job.slice(0, job.indexOf(" steps:\n")); + expect(jobDefinition).not.toContain("NODE_AUTH_TOKEN"); + expect(mutationStep).toContain("NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }}"); + for (const stepName of unprivilegedSteps) { + expect(workflowStep(job, stepName)).not.toContain("NODE_AUTH_TOKEN"); + } + } + expect( + snapshotWorkflow.split("NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }}").length - 1, + ).toBe(2); + + for (const job of jobsWithoutNpmToken) { + expect(job).not.toContain("NODE_AUTH_TOKEN"); + } + for (const job of jobsWithoutNpmRegistry) { + expect(job).not.toMatch(npmRegistryUrl); + } }); }); diff --git a/scripts/check-dev-snapshot-bootstrap.d.mts b/scripts/check-dev-snapshot-bootstrap.d.mts index e367bacd..e7c89612 100644 --- a/scripts/check-dev-snapshot-bootstrap.d.mts +++ b/scripts/check-dev-snapshot-bootstrap.d.mts @@ -46,12 +46,21 @@ export function derivePackageOnlyTargets( ): PackageOnlyTarget[]; export function deriveValidationPlan(snapshotManifest: BootstrapManifest): ValidationPlan; export function createIsolatedValidationEnv(baseDirectory?: string): IsolatedValidationEnv; -export function findInstalledPackageJson(installRoot: string, packageName: string): string; +export function findInstalledPackageJson( + installRoot: string, + packageName: string, + parentPackageName?: string, +): string; export function readInstalledPackageManifest( installRoot: string, packageName: string, + parentPackageName?: string, ): Record; -export function readInstalledPackageVersion(installRoot: string, packageName: string): string; +export function readInstalledPackageVersion( + installRoot: string, + packageName: string, + parentPackageName?: string, +): string; export function assertInstalledSnapshotLine( snapshotManifest: BootstrapManifest, installRoot: string, diff --git a/scripts/check-dev-snapshot-bootstrap.mjs b/scripts/check-dev-snapshot-bootstrap.mjs index e83b3d5a..6c11f08c 100644 --- a/scripts/check-dev-snapshot-bootstrap.mjs +++ b/scripts/check-dev-snapshot-bootstrap.mjs @@ -74,19 +74,60 @@ export function createIsolatedValidationEnv( }; } -export function findInstalledPackageJson(installRoot, packageName) { - return join(installRoot, "lib", "node_modules", packageName, "package.json"); +export function findInstalledPackageJson(installRoot, packageName, parentPackageName) { + const globalModules = join(installRoot, "lib", "node_modules"); + if (parentPackageName) { + return join(globalModules, parentPackageName, "node_modules", packageName, "package.json"); + } + return join(globalModules, packageName, "package.json"); } -export function readInstalledPackageManifest(installRoot, packageName) { - const packageJsonPath = findInstalledPackageJson(installRoot, packageName); +export function readInstalledPackageManifest(installRoot, packageName, parentPackageName) { + const packageJsonPath = findInstalledPackageJson(installRoot, packageName, parentPackageName); return JSON.parse(readFileSync(packageJsonPath, "utf8")); } -export function readInstalledPackageVersion(installRoot, packageName) { - const packageJson = readInstalledPackageManifest(installRoot, packageName); + +export function readInstalledPackageVersion(installRoot, packageName, parentPackageName) { + const packageJson = readInstalledPackageManifest(installRoot, packageName, parentPackageName); return packageJson.version; } +function assertInstalledTarget(errors, expectedVersions, installRoot, target, parentPackageName) { + const expectedVersion = expectedVersions.get(target.name); + if (!expectedVersion) return; + try { + const installedVersion = readInstalledPackageVersion( + installRoot, + target.name, + parentPackageName, + ); + if (installedVersion !== expectedVersion) { + errors.push( + `${target.name} installed version ${installedVersion} did not match expected ${expectedVersion}.`, + ); + } + if (target.peerDependencies.length > 0) { + const installedManifest = readInstalledPackageManifest( + installRoot, + target.name, + parentPackageName, + ); + const installedPeers = installedManifest.peerDependencies ?? {}; + for (const peerDependency of target.peerDependencies) { + if (typeof installedPeers[peerDependency] !== "string") { + errors.push( + `${target.name} is missing peer dependency declaration for ${peerDependency}.`, + ); + } + } + } + } catch (error) { + errors.push( + `${target.name} is not installed at ${findInstalledPackageJson(installRoot, target.name, parentPackageName)} (${error instanceof Error ? error.message : String(error)}).`, + ); + } +} + export function assertInstalledSnapshotLine(snapshotManifest, installRoot) { const errors = []; const expectedVersions = new Map( @@ -95,50 +136,18 @@ export function assertInstalledSnapshotLine(snapshotManifest, installRoot) { const plan = deriveValidationPlan(snapshotManifest); if (plan.kind === "cli-bootstrap") { for (const packageName of [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME]) { - const expectedVersion = expectedVersions.get(packageName); - if (!expectedVersion) continue; - try { - const installedVersion = readInstalledPackageVersion(installRoot, packageName); - if (installedVersion !== expectedVersion) { - errors.push( - `${packageName} installed version ${installedVersion} did not match expected ${expectedVersion}.`, - ); - } - } catch (error) { - errors.push( - `${packageName} is not installed at ${findInstalledPackageJson(installRoot, packageName)} (${error instanceof Error ? error.message : String(error)}).`, - ); - } - } - } else { - for (const target of plan.packages) { - const expectedVersion = expectedVersions.get(target.name); - if (!expectedVersion) continue; - try { - const installedVersion = readInstalledPackageVersion(installRoot, target.name); - if (installedVersion !== expectedVersion) { - errors.push( - `${target.name} installed version ${installedVersion} did not match expected ${expectedVersion}.`, - ); - } - if (target.peerDependencies.length > 0) { - const installedManifest = readInstalledPackageManifest(installRoot, target.name); - const installedPeers = installedManifest.peerDependencies ?? {}; - for (const peerDependency of target.peerDependencies) { - if (typeof installedPeers[peerDependency] !== "string") { - errors.push( - `${target.name} is missing peer dependency declaration for ${peerDependency}.`, - ); - } - } - } - } catch (error) { - errors.push( - `${target.name} is not installed at ${findInstalledPackageJson(installRoot, target.name)} (${error instanceof Error ? error.message : String(error)}).`, - ); + if (!expectedVersions.get(packageName)) { + errors.push(`Snapshot manifest is missing an expected version for ${packageName}.`); } } } + for (const target of derivePackageOnlyTargets(snapshotManifest)) { + const parentPackageName = + plan.kind === "cli-bootstrap" && target.name === CORE_PACKAGE_NAME + ? CLI_PACKAGE_NAME + : undefined; + assertInstalledTarget(errors, expectedVersions, installRoot, target, parentPackageName); + } return errors; } @@ -146,19 +155,31 @@ export function buildValidationCommands(snapshotManifest, options = {}) { const versionByName = new Map( snapshotManifest.releases.map((release) => [release.name, release.newVersion]), ); + const installCommand = (packageName) => { + const version = versionByName.get(packageName); + if (!version) { + throw new Error(`Missing snapshot version for ${packageName}.`); + } + return `npm install -g ${packageName}@${version}`; + }; const plan = deriveValidationPlan(snapshotManifest); const installRoot = options.installRoot ?? "${INSTALL_ROOT}"; if (plan.kind === "cli-bootstrap") { - const capletsVersion = versionByName.get(CLI_PACKAGE_NAME); + if (!versionByName.get(CORE_PACKAGE_NAME)) { + throw new Error(`Missing snapshot version for ${CORE_PACKAGE_NAME}.`); + } return [ - `npm install -g ${CLI_PACKAGE_NAME}@${capletsVersion}`, + installCommand(CLI_PACKAGE_NAME), `${CLI_PACKAGE_NAME} --version`, `${CLI_PACKAGE_NAME} setup mcp-client --output ${join(installRoot, "caplets.mcp.json")} --format json`, + ...plan.packages + .filter( + (packageName) => packageName !== CLI_PACKAGE_NAME && packageName !== CORE_PACKAGE_NAME, + ) + .map(installCommand), ]; } - return plan.packages.map( - (target) => `npm install -g ${target.name}@${versionByName.get(target.name)}`, - ); + return plan.packages.map((target) => installCommand(target.name)); } function printJson(result) { diff --git a/scripts/dev-snapshot-release.mjs b/scripts/dev-snapshot-release.mjs index 230830ae..456f477d 100644 --- a/scripts/dev-snapshot-release.mjs +++ b/scripts/dev-snapshot-release.mjs @@ -114,7 +114,7 @@ export function chooseValidationMode(releaseNames) { if (names.has(CLI_PACKAGE_NAME) || names.has(CORE_PACKAGE_NAME)) { return { kind: "cli-bootstrap", - packages: [CLI_PACKAGE_NAME, CORE_PACKAGE_NAME].filter((name) => names.has(name)), + packages: [...names].sort(), }; } return { @@ -135,10 +135,11 @@ export function deriveChangesetManifest(statusJson, options = {}) { const directPublicReleases = statusReleases.filter( (release) => release?.type && release.type !== "none" && publicByName.has(release.name), ); - const closureNames = expandPublicReleaseClosure( - directPublicReleases.map((release) => release.name), - manifests, - ); + const releaseSeeds = directPublicReleases.map((release) => release.name); + if (releaseSeeds.includes(CLI_PACKAGE_NAME) && publicByName.has(CORE_PACKAGE_NAME)) { + releaseSeeds.push(CORE_PACKAGE_NAME); + } + const closureNames = expandPublicReleaseClosure(releaseSeeds, manifests); const releases = closureNames.map((name) => { const manifestEntry = publicByName.get(name); const release = statusReleases.find((candidate) => candidate.name === name); @@ -230,7 +231,7 @@ export function patchSnapshotConfig(root = repoRoot) { snapshot: { ...config.snapshot, useCalculatedVersion: true, - prereleaseTemplate: "dev-{commit}-{datetime}", + prereleaseTemplate: "{tag}-{commit}-{datetime}", }, }; } @@ -245,14 +246,27 @@ export function refreshSnapshotManifestVersions(snapshotManifest, root = repoRoo const publicPackages = new Map( listPublicPublishablePackages(root).map((entry) => [entry.name, readJson(entry.path)]), ); + const snapshotSuffix = snapshotManifest.releases + .map((release) => { + const version = publicPackages.get(release.name)?.version; + const plannedVersion = release.newVersion; + if (typeof version !== "string" || typeof plannedVersion !== "string") return undefined; + const prefix = `${plannedVersion}-`; + return version.startsWith(prefix) ? version.slice(prefix.length) : undefined; + }) + .find(Boolean); return { ...snapshotManifest, releases: snapshotManifest.releases.map((release) => { - const manifest = publicPackages.get(release.name); - return { - ...release, - newVersion: typeof manifest?.version === "string" ? manifest.version : release.newVersion, - }; + const version = publicPackages.get(release.name)?.version; + if (typeof version !== "string") return release; + if (version !== release.newVersion) { + return { ...release, newVersion: version }; + } + if (!snapshotSuffix) { + throw new Error(`Could not derive a snapshot suffix for closure package ${release.name}.`); + } + return { ...release, newVersion: `${version}-${snapshotSuffix}` }; }), }; } From d3898024cff0d955b1b8f72d48ca2ad52c33ed0a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 9 Jul 2026 16:18:01 -0400 Subject: [PATCH 3/5] fix: reconcile cancelled snapshot verification --- .github/workflows/dev-snapshot-release.yml | 2 +- packages/core/test/dev-snapshot-release.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml index e9c090b4..6a6ecda0 100644 --- a/.github/workflows/dev-snapshot-release.yml +++ b/.github/workflows/dev-snapshot-release.yml @@ -542,7 +542,7 @@ jobs: reconcile_promoted_cli_failure: name: Reconcile failed promoted dev line needs: [plan, promote, verify_promoted_cli] - if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'failure' + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' && needs.verify_promoted_cli.result != 'success' runs-on: ubuntu-latest environment: npm-release permissions: diff --git a/packages/core/test/dev-snapshot-release.test.ts b/packages/core/test/dev-snapshot-release.test.ts index c7f14f9a..b12718f4 100644 --- a/packages/core/test/dev-snapshot-release.test.ts +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -439,6 +439,7 @@ describe("dev snapshot release helpers", () => { ); const publish = workflowJob(workflow, "publish"); const promote = workflowJob(workflow, "promote"); + const reconcilePromotedCliFailure = workflowJob(workflow, "reconcile_promoted_cli_failure"); const mainOnlyCondition = "github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'"; @@ -462,6 +463,12 @@ describe("dev snapshot release helpers", () => { ); expect(publish).toContain(`if: ${mainOnlyCondition}`); expect(promote).toContain(`if: ${mainOnlyCondition}`); + expect(reconcilePromotedCliFailure).toContain( + "if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' && needs.verify_promoted_cli.result != 'success'", + ); + expect(reconcilePromotedCliFailure).not.toContain( + "needs.verify_promoted_cli.result == 'failure'", + ); for (const job of [publish, promote]) { expect(job).toMatch( /git fetch --no-tags origin main\n\s+current_main="\$\(git rev-parse origin\/main\)"\n\s+test "\$GITHUB_SHA" = "\$current_main"/, From 30e169de69cb2951bf57e2057c6ee784698e1ad8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 9 Jul 2026 16:27:27 -0400 Subject: [PATCH 4/5] fix: keep snapshot rollback in approved job --- .github/workflows/dev-snapshot-release.yml | 70 ++----------------- .../core/test/dev-snapshot-release.test.ts | 61 +++++++++------- 2 files changed, 42 insertions(+), 89 deletions(-) diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml index 6a6ecda0..51c79391 100644 --- a/.github/workflows/dev-snapshot-release.yml +++ b/.github/workflows/dev-snapshot-release.yml @@ -369,6 +369,7 @@ jobs: path: .tmp/pre-run-tags.json - name: Promote validated versions to dev with reconciliation + id: promote_dev_tags env: NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} run: | @@ -454,37 +455,16 @@ jobs: } NODE - verify_promoted_cli: - name: Verify promoted caplets@dev line - needs: [plan, promote] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Download manifest artifact - uses: actions/download-artifact@v4 - with: - name: dev-snapshot-manifest - path: .tmp - - name: Create install root for promoted smoke + if: needs.plan.outputs.validation_kind == 'cli-bootstrap' id: install-root run: | install_root="$(mktemp -d -t caplets-dev-promoted-XXXXXX)" echo "install_root=${install_root}" >> "$GITHUB_OUTPUT" - name: Smoke promoted caplets@dev line + if: needs.plan.outputs.validation_kind == 'cli-bootstrap' + id: smoke_promoted_cli env: INSTALL_ROOT: ${{ steps.install-root.outputs.install_root }} run: | @@ -539,39 +519,8 @@ jobs: caplets setup mcp-client --output "$INSTALL_ROOT/caplets.mcp.json" --format json node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" - reconcile_promoted_cli_failure: - name: Reconcile failed promoted dev line - needs: [plan, promote, verify_promoted_cli] - if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' && needs.verify_promoted_cli.result != 'success' - runs-on: ubuntu-latest - environment: npm-release - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Download manifest artifact - uses: actions/download-artifact@v4 - with: - name: dev-snapshot-manifest - path: .tmp - - - name: Download pre-run tag snapshot - uses: actions/download-artifact@v4 - with: - name: dev-snapshot-pre-run-tags - path: .tmp - - name: Restore dev tags after promoted smoke failure + if: ${{ always() && needs.plan.outputs.validation_kind == 'cli-bootstrap' && steps.promote_dev_tags.outcome == 'success' && steps.smoke_promoted_cli.outcome != 'success' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} run: | @@ -634,15 +583,10 @@ jobs: } NODE - - name: Fail after reconciliation - run: | - echo "Promoted caplets@dev did not resolve the validated snapshot line." >&2 - exit 1 - dev_image: name: Publish best-effort dev image - needs: [plan, promote, verify_promoted_cli] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.verify_promoted_cli.result == 'success' + needs: [plan, promote] + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' runs-on: ubuntu-latest environment: npm-release permissions: diff --git a/packages/core/test/dev-snapshot-release.test.ts b/packages/core/test/dev-snapshot-release.test.ts index b12718f4..e277ea9e 100644 --- a/packages/core/test/dev-snapshot-release.test.ts +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -439,7 +439,6 @@ describe("dev snapshot release helpers", () => { ); const publish = workflowJob(workflow, "publish"); const promote = workflowJob(workflow, "promote"); - const reconcilePromotedCliFailure = workflowJob(workflow, "reconcile_promoted_cli_failure"); const mainOnlyCondition = "github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'"; @@ -448,14 +447,13 @@ describe("dev snapshot release helpers", () => { expect(workflow).not.toContain("cancel-in-progress: true"); expect(workflow).toContain("needs: [plan, publish, validation_complete]"); expect(workflow).toContain("name: Validation barrier"); - expect(workflow).toContain("name: Verify promoted caplets@dev line"); - expect(workflow).toContain("name: Reconcile failed promoted dev line"); - expect(workflow).toContain("needs: [plan, promote, verify_promoted_cli]"); + expect(workflow).not.toMatch(/^ reconcile_promoted_cli_failure:$/m); + expect(workflow).not.toMatch(/^ verify_promoted_cli:$/m); expect(workflow).toContain( "staging_tag=dev-staged-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}", ); expect(workflow.split('export PATH="$INSTALL_ROOT/bin:$PATH"').length - 1).toBe(2); - expect(workflow.split("persist-credentials: false").length - 1).toBe(8); + expect(workflow.split("persist-credentials: false").length - 1).toBe(6); expect(workflow).not.toContain('eval "$command"'); expect(workflow).not.toContain("Stop when dry run is requested"); expect(workflow).toContain( @@ -463,12 +461,20 @@ describe("dev snapshot release helpers", () => { ); expect(publish).toContain(`if: ${mainOnlyCondition}`); expect(promote).toContain(`if: ${mainOnlyCondition}`); - expect(reconcilePromotedCliFailure).toContain( - "if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' && needs.verify_promoted_cli.result != 'success'", + const promotedCliSmoke = workflowStep(promote, "Smoke promoted caplets@dev line"); + const restorePromotedTags = workflowStep( + promote, + "Restore dev tags after promoted smoke failure", ); - expect(reconcilePromotedCliFailure).not.toContain( - "needs.verify_promoted_cli.result == 'failure'", + expect(promote.indexOf("Smoke promoted caplets@dev line")).toBeLessThan( + promote.indexOf("Restore dev tags after promoted smoke failure"), ); + expect(promotedCliSmoke).toContain("id: smoke_promoted_cli"); + expect(restorePromotedTags).toContain("if: ${{ always()"); + expect(restorePromotedTags).toContain("needs.plan.outputs.validation_kind == 'cli-bootstrap'"); + expect(restorePromotedTags).toContain("steps.promote_dev_tags.outcome == 'success'"); + expect(restorePromotedTags).toContain("steps.smoke_promoted_cli.outcome != 'success'"); + expect(promotedCliSmoke).not.toContain("NODE_AUTH_TOKEN"); for (const job of [publish, promote]) { expect(job).toMatch( /git fetch --no-tags origin main\n\s+current_main="\$\(git rev-parse origin\/main\)"\n\s+test "\$GITHUB_SHA" = "\$current_main"/, @@ -490,20 +496,15 @@ describe("dev snapshot release helpers", () => { const releaseWorkflow = readFileSync(join(repoRoot, ".github/workflows/release.yml"), "utf8"); const publish = workflowJob(snapshotWorkflow, "publish"); const promote = workflowJob(snapshotWorkflow, "promote"); - const reconcilePromotedCliFailure = workflowJob( - snapshotWorkflow, - "reconcile_promoted_cli_failure", - ); const devImage = workflowJob(snapshotWorkflow, "dev_image"); - const environmentProtectedJobs = [publish, promote, reconcilePromotedCliFailure, devImage]; - const registryJobs = [publish, promote, reconcilePromotedCliFailure]; + const environmentProtectedJobs = [publish, promote, devImage]; + const registryJobs = [publish, promote]; const jobsWithoutNpmToken = [ workflowJob(snapshotWorkflow, "plan"), publish, workflowJob(snapshotWorkflow, "validate_cli"), workflowJob(snapshotWorkflow, "validate_packages"), workflowJob(snapshotWorkflow, "validation_complete"), - workflowJob(snapshotWorkflow, "verify_promoted_cli"), devImage, ]; const jobsWithoutNpmRegistry = jobsWithoutNpmToken.filter((job) => job !== publish); @@ -516,19 +517,23 @@ describe("dev snapshot release helpers", () => { promote, "Promote validated versions to dev with reconciliation", ), - unprivilegedSteps: ["Checkout", "Setup Node", "Download manifest artifact"], + unprivilegedSteps: [ + "Checkout", + "Setup Node", + "Download manifest artifact", + "Create install root for promoted smoke", + "Smoke promoted caplets@dev line", + ], }, { - job: reconcilePromotedCliFailure, - mutationStep: workflowStep( - reconcilePromotedCliFailure, - "Restore dev tags after promoted smoke failure", - ), + job: promote, + mutationStep: workflowStep(promote, "Restore dev tags after promoted smoke failure"), unprivilegedSteps: [ "Checkout", "Setup Node", "Download manifest artifact", - "Download pre-run tag snapshot", + "Create install root for promoted smoke", + "Smoke promoted caplets@dev line", ], }, ]; @@ -550,20 +555,22 @@ describe("dev snapshot release helpers", () => { expect(publish).toContain("id-token: write"); expect(promote).not.toContain("id-token: write"); - expect(reconcilePromotedCliFailure).not.toContain("id-token: write"); + expect( + workflowStep(promote, "Promote validated versions to dev with reconciliation"), + ).toContain("id: promote_dev_tags"); expect(publish).not.toContain("NODE_AUTH_TOKEN"); for (const job of environmentProtectedJobs) { expect(job).toContain("environment: npm-release"); } - expect(snapshotWorkflow.split("environment: npm-release").length - 1).toBe(4); + expect(snapshotWorkflow.split("environment: npm-release").length - 1).toBe(3); for (const job of registryJobs) { expect(job).toMatch(npmRegistryUrl); } expect( snapshotWorkflow.match(/registry-url: "?https:\/\/registry\.npmjs\.org"?/g) ?? [], - ).toHaveLength(3); + ).toHaveLength(2); for (const { job, mutationStep, unprivilegedSteps } of tokenScopedSteps) { const jobDefinition = job.slice(0, job.indexOf(" steps:\n")); @@ -580,6 +587,8 @@ describe("dev snapshot release helpers", () => { for (const job of jobsWithoutNpmToken) { expect(job).not.toContain("NODE_AUTH_TOKEN"); } + expect(snapshotWorkflow).not.toContain("reconcile_promoted_cli_failure"); + expect(snapshotWorkflow).not.toContain("verify_promoted_cli"); for (const job of jobsWithoutNpmRegistry) { expect(job).not.toMatch(npmRegistryUrl); } From 5ec8666ef42f6d5f571318d2930f973128541b15 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 9 Jul 2026 17:34:45 -0400 Subject: [PATCH 5/5] fix: make dev snapshot recovery retry-safe --- .github/workflows/dev-snapshot-release.yml | 121 ++- .github/workflows/release.yml | 3 + .../core/test/dev-snapshot-release.test.ts | 897 +++++++++++++++++- scripts/dev-snapshot-release.d.mts | 112 ++- scripts/dev-snapshot-release.mjs | 655 +++++++++++-- 5 files changed, 1695 insertions(+), 93 deletions(-) diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml index 51c79391..2c531c6b 100644 --- a/.github/workflows/dev-snapshot-release.yml +++ b/.github/workflows/dev-snapshot-release.yml @@ -21,6 +21,7 @@ jobs: has_public_releases: ${{ steps.manifest.outputs.has_public_releases }} validation_kind: ${{ steps.manifest.outputs.validation_kind }} staging_tag: ${{ steps.manifest.outputs.staging_tag }} + recovery_action: ${{ steps.manifest.outputs.recovery_action }} steps: - name: Checkout uses: actions/checkout@v7 @@ -49,20 +50,55 @@ jobs: pnpm changeset status --output .tmp/changeset-status.json node scripts/dev-snapshot-release.mjs status \ --status-file .tmp/changeset-status.json \ + --source-commit "$GITHUB_SHA" \ + --run-identity "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ --output .tmp/dev-snapshot-manifest.json + - name: Recover stable public snapshot state + run: | + node --input-type=module <<'NODE' + import { + classifyRegistrySnapshot, + createStagingTag, + fetchPublicPackument, + readJson, + recoverSnapshotManifest, + writeJson, + } from './scripts/dev-snapshot-release.mjs'; + const manifestPath = '.tmp/dev-snapshot-manifest.json'; + const manifest = readJson(manifestPath); + const expectedStagingTag = createStagingTag(manifest.fingerprint, `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`); + if (manifest.stagingTag !== expectedStagingTag) { + throw new Error(`Snapshot manifest staging tag ${manifest.stagingTag} does not match this run.`); + } + const recovery = await recoverSnapshotManifest(manifest); + const packuments = Object.fromEntries( + await Promise.all( + recovery.manifest.releases.map(async (release) => [ + release.name, + await fetchPublicPackument(release.name), + ]), + ), + ); + const result = classifyRegistrySnapshot(recovery.manifest, packuments); + if (result.action !== recovery.action) { + throw new Error( + `Registry snapshot state changed during final observation: ${recovery.action} to ${result.action}.`, + ); + } + writeJson(manifestPath, { ...result.manifest, recoveryAction: result.action }); + NODE - name: Derive workflow outputs id: manifest run: | node --input-type=module <<'NODE' - import { readFileSync, appendFileSync } from 'node:fs'; - const manifest = JSON.parse(readFileSync('.tmp/dev-snapshot-manifest.json', 'utf8')); + import { appendFileSync } from 'node:fs'; + import { readJson } from './scripts/dev-snapshot-release.mjs'; + const manifest = readJson('.tmp/dev-snapshot-manifest.json'); appendFileSync(process.env.GITHUB_OUTPUT, `has_public_releases=${manifest.hasPublicReleases}\n`); appendFileSync(process.env.GITHUB_OUTPUT, `validation_kind=${manifest.validation.kind}\n`); - appendFileSync( - process.env.GITHUB_OUTPUT, - `staging_tag=dev-staged-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}\n`, - ); + appendFileSync(process.env.GITHUB_OUTPUT, `staging_tag=${manifest.stagingTag}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `recovery_action=${manifest.recoveryAction}\n`); NODE - name: Upload manifest artifact @@ -74,7 +110,7 @@ jobs: publish: name: Publish staged snapshots needs: plan - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.recovery_action == 'fresh' runs-on: ubuntu-latest environment: npm-release permissions: @@ -123,6 +159,13 @@ jobs: - name: Rewrite public closure run: node scripts/dev-snapshot-release.mjs rewrite-closure --manifest .tmp/dev-snapshot-manifest.json + - name: Stamp snapshot registry identity + run: | + node --input-type=module <<'NODE' + import { readJson, stampSnapshotMetadata } from './scripts/dev-snapshot-release.mjs'; + stampSnapshotMetadata(readJson('.tmp/dev-snapshot-manifest.json')); + NODE + - name: Build disposable snapshot workspace artifacts run: pnpm build @@ -138,6 +181,35 @@ jobs: - name: Publish exact snapshots with run-scoped staging tag run: pnpm changeset publish --tag "$STAGING_TAG" --no-git-tag + - name: Verify published snapshot registry identity + run: | + node --input-type=module <<'NODE' + import { + classifyRegistrySnapshot, + fetchPublicPackument, + readJson, + recoverSnapshotManifest, + writeJson, + } from './scripts/dev-snapshot-release.mjs'; + const manifestPath = '.tmp/dev-snapshot-manifest.json'; + const recovery = await recoverSnapshotManifest(readJson(manifestPath), { + requiredAction: 'reuse-staged', + }); + const packuments = Object.fromEntries( + await Promise.all( + recovery.manifest.releases.map(async (release) => [ + release.name, + await fetchPublicPackument(release.name), + ]), + ), + ); + const result = classifyRegistrySnapshot(recovery.manifest, packuments); + if (result.action !== 'reuse-staged') { + throw new Error(`Published snapshot registry identity is ${result.action}, expected reuse-staged.`); + } + writeJson(manifestPath, result.manifest); + NODE + - name: Upload refreshed snapshot manifest uses: actions/upload-artifact@v4 with: @@ -148,7 +220,7 @@ jobs: validate_cli: name: Validate CLI or core snapshot line needs: [plan, publish] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && ((needs.plan.outputs.recovery_action == 'fresh' && needs.publish.result == 'success') || (needs.plan.outputs.recovery_action == 'reuse-staged' && needs.publish.result == 'skipped')) runs-on: ubuntu-latest permissions: contents: read @@ -216,7 +288,7 @@ jobs: validate_packages: name: Validate package-only snapshot line needs: [plan, publish] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'package-only' + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'package-only' && ((needs.plan.outputs.recovery_action == 'fresh' && needs.publish.result == 'success') || (needs.plan.outputs.recovery_action == 'reuse-staged' && needs.publish.result == 'skipped')) runs-on: ubuntu-latest permissions: contents: read @@ -282,8 +354,8 @@ jobs: validation_complete: name: Validation barrier - needs: [plan, validate_cli, validate_packages] - if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' + needs: [plan, publish, validate_cli, validate_packages] + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && ((needs.plan.outputs.recovery_action == 'fresh' && needs.publish.result == 'success') || (needs.plan.outputs.recovery_action == 'reuse-staged' && needs.publish.result == 'skipped')) runs-on: ubuntu-latest permissions: contents: read @@ -304,7 +376,7 @@ jobs: promote: name: Promote validated snapshots to dev needs: [plan, publish, validation_complete] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' + if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.result == 'success' && needs.validation_complete.result == 'success' && ((needs.plan.outputs.recovery_action == 'fresh' && needs.publish.result == 'success') || (needs.plan.outputs.recovery_action == 'reuse-staged' && needs.publish.result == 'skipped')) runs-on: ubuntu-latest environment: npm-release permissions: @@ -368,6 +440,10 @@ jobs: name: dev-snapshot-pre-run-tags path: .tmp/pre-run-tags.json + - name: Mark promotion transaction started + id: promotion_transaction_started + run: echo "started=true" >> "$GITHUB_OUTPUT" + - name: Promote validated versions to dev with reconciliation id: promote_dev_tags env: @@ -520,7 +596,7 @@ jobs: node scripts/check-dev-snapshot-bootstrap.mjs assert-installed --manifest .tmp/dev-snapshot-manifest.json --install-root "$INSTALL_ROOT" - name: Restore dev tags after promoted smoke failure - if: ${{ always() && needs.plan.outputs.validation_kind == 'cli-bootstrap' && steps.promote_dev_tags.outcome == 'success' && steps.smoke_promoted_cli.outcome != 'success' }} + if: ${{ always() && steps.promotion_transaction_started.outputs.started == 'true' && (steps.promote_dev_tags.outcome != 'success' || (needs.plan.outputs.validation_kind == 'cli-bootstrap' && steps.smoke_promoted_cli.outcome != 'success')) }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} run: | @@ -550,8 +626,6 @@ jobs: const failures = []; for (const release of [...manifest.releases].reverse()) { try { - const tags = readTags(release.name); - if (tags.dev !== release.newVersion) continue; const previousDev = preRunTags[release.name]?.dev; if (typeof previousDev === 'string' && previousDev.length > 0) { execFileSync('npm', ['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev'], { @@ -564,10 +638,14 @@ jobs: `restore dev=${previousDev}`, ); } else { - execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { - stdio: 'inherit', - env: process.env, - }); + try { + execFileSync('npm', ['dist-tag', 'rm', release.name, 'dev'], { + stdio: 'inherit', + env: process.env, + }); + } catch (error) { + if (readTags(release.name).dev !== undefined) throw error; + } await waitForTags( release.name, (candidate) => candidate.dev === undefined, @@ -586,7 +664,7 @@ jobs: dev_image: name: Publish best-effort dev image needs: [plan, promote] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' + if: github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true' && needs.plan.outputs.validation_kind == 'cli-bootstrap' && needs.promote.result == 'success' && (needs.plan.outputs.recovery_action == 'fresh' || needs.plan.outputs.recovery_action == 'reuse-staged') runs-on: ubuntu-latest environment: npm-release permissions: @@ -598,6 +676,9 @@ jobs: with: persist-credentials: false + - name: Setup QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + - name: Setup Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f069e0c..6230d758 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: write @@ -21,6 +22,7 @@ concurrency: jobs: release: + if: github.ref == 'refs/heads/main' name: Version or Publish runs-on: ubuntu-latest permissions: @@ -135,6 +137,7 @@ jobs: dev_snapshot: name: Release dev snapshots needs: release + if: github.ref == 'refs/heads/main' uses: ./.github/workflows/dev-snapshot-release.yml permissions: contents: read diff --git a/packages/core/test/dev-snapshot-release.test.ts b/packages/core/test/dev-snapshot-release.test.ts index e277ea9e..6257bd5a 100644 --- a/packages/core/test/dev-snapshot-release.test.ts +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -1,9 +1,13 @@ +import { Buffer } from "node:buffer"; + import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import * as snapshotRelease from "../../../scripts/dev-snapshot-release.mjs"; import { + computeRelevantFingerprint, createStagingTag, deriveChangesetManifest, discoverWorkspacePackageManifests, @@ -76,6 +80,74 @@ function workflowJobContaining(workflow: string, content: string) { return matchingJobs[0]!; } +type SnapshotRelease = { + name: string; + directory: string; + newVersion: string; +}; + +type SnapshotManifest = { + fingerprint: string; + sourceCommit: string; + stagingTag: string; + releases: SnapshotRelease[]; +}; + +type RegistryPackuments = Record>; + +function requiredSnapshotExport(name: string): T { + const candidate = (snapshotRelease as Record)[name]; + expect(candidate, `expected ${name} to be exported`).toBeTypeOf("function"); + return candidate as T; +} + +function snapshotManifest(fingerprint = "a".repeat(64)): SnapshotManifest { + return { + fingerprint, + sourceCommit: "1234567890abcdef1234567890abcdef12345678", + stagingTag: `dev-staged-${fingerprint}-run-1`, + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "1.2.3-dev-fixture" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.5.6-dev-fixture" }, + ], + }; +} + +function validRegistryPackuments(manifest: SnapshotManifest): RegistryPackuments { + const stagingTag = manifest.stagingTag; + const releases = Object.fromEntries( + manifest.releases.map((release) => [release.name, release.newVersion]), + ); + return Object.fromEntries( + manifest.releases.map((release) => [ + release.name, + { + "dist-tags": { [stagingTag]: release.newVersion }, + versions: { + [release.newVersion]: { + name: release.name, + version: release.newVersion, + capletsSnapshot: { + schema: 1, + fingerprint: manifest.fingerprint, + sourceCommit: manifest.sourceCommit, + stagingTag, + releases, + }, + dist: { + integrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}`, + tarball: `https://registry.example.invalid/${encodeURIComponent(release.name)}`, + }, + ...(release.name === "fixture-cli" + ? { dependencies: { "@fixture/core": releases["@fixture/core"] } } + : {}), + }, + }, + }, + ]), + ); +} + describe("dev snapshot release helpers", () => { it("filters public workspace packages and expands their dependent release closure", () => { const root = createWorkspaceFixture([ @@ -340,9 +412,692 @@ describe("dev snapshot release helpers", () => { expect(manifest.releases).toEqual([]); }); - it("creates a run-scoped staging tag", () => { - expect(createStagingTag("123-4")).toBe("dev-staged-123-4"); - expect(() => createStagingTag("")).toThrow(/run identifier/i); + it("derives safe staging-generation tags only from full lowercase SHA-256 fingerprints", () => { + const fingerprint = "0123456789abcdef".repeat(4); + const createFingerprintStagingTag = createStagingTag as ( + fingerprint: string, + runIdentity: string, + ) => string; + expect(createFingerprintStagingTag(fingerprint, "run-42")).toBe( + `dev-staged-${fingerprint}-run-42`, + ); + for (const [invalidFingerprint, runIdentity] of [ + ["", "run-42"], + ["a".repeat(63), "run-42"], + ["a".repeat(65), "run-42"], + ["A".repeat(64), "run-42"], + ["g".repeat(64), "run-42"], + [fingerprint, ""], + [fingerprint, "run/42"], + [fingerprint, "run 42"], + [fingerprint, "../unsafe"], + ] as const) { + expect(() => createFingerprintStagingTag(invalidFingerprint, runIdentity)).toThrow(); + } + }); + + it("fingerprints the lockfile and dashboard sources for a core-and-CLI release closure", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@caplets/core", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "caplets", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + ]); + writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + mkdirSync(join(root, "apps", "dashboard", "src"), { recursive: true }); + writeFileSync( + join(root, "apps", "dashboard", "src", "release-panel.ts"), + "export const panel = 1;\n", + ); + + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: "@caplets/core", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["core-release"], + }, + ], + }, + { repoRoot: root }, + ); + expect(manifest.releases.map((release) => release.name)).toEqual(["@caplets/core", "caplets"]); + + const original = computeRelevantFingerprint(manifest, root); + writeFileSync( + join(root, "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: false\n", + ); + const afterLockfileChange = computeRelevantFingerprint(manifest, root); + writeFileSync( + join(root, "apps", "dashboard", "src", "release-panel.ts"), + "export const panel = 2;\n", + ); + const afterDashboardChange = computeRelevantFingerprint(manifest, root); + + expect(afterLockfileChange).not.toBe(original); + expect(afterDashboardChange).not.toBe(afterLockfileChange); + }); + + it("fingerprints release artifacts and intent while ignoring non-artifact inputs", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@caplets/core", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "caplets", + version: "1.0.0", + publishConfig: { access: "public" }, + dependencies: { "@caplets/core": "workspace:^" }, + }, + }, + { + directory: "unrelated", + manifest: { + name: "@fixture/unrelated", + version: "1.0.0", + publishConfig: { access: "public" }, + }, + }, + ]); + mkdirSync(join(root, ".changeset"), { recursive: true }); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + mkdirSync(join(root, "scripts"), { recursive: true }); + mkdirSync(join(root, "packages", "cli", "src"), { recursive: true }); + mkdirSync(join(root, "packages", "cli", "test"), { recursive: true }); + writeFileSync(join(root, "README.md"), "# Caplets\n"); + writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"); + writeFileSync(join(root, ".changeset", "config.json"), "{}\n"); + writeFileSync( + join(root, ".changeset", "cli-release.md"), + '---\n"caplets": patch\n---\n\nCLI release.\n', + ); + writeFileSync(join(root, ".changeset", "unrelated.md"), "---\n---\n\nUnrelated.\n"); + writeFileSync( + join(root, ".github", "workflows", "dev-snapshot-release.yml"), + "name: release\n", + ); + writeFileSync(join(root, "scripts", "check-package-runtime.mjs"), "export default 1;\n"); + writeFileSync(join(root, "scripts", "check-dev-snapshot-bootstrap.mjs"), "export default 1;\n"); + writeFileSync(join(root, "scripts", "dev-snapshot-release.mjs"), "export default 1;\n"); + writeFileSync( + join(root, "scripts", "runtime-sentry-rolldown.ts"), + "export const sentry = 1;\n", + ); + writeFileSync( + join(root, "packages", "cli", "src", "release.ts"), + "export const release = 1;\n", + ); + writeFileSync( + join(root, "packages", "cli", "test", "release.test.ts"), + "export const testOnly = 1;\n", + ); + + const manifest = deriveChangesetManifest( + { + releases: [ + { + name: "caplets", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["cli-release"], + }, + ], + }, + { repoRoot: root }, + ); + const baseline = computeRelevantFingerprint(manifest, root); + const unrelatedManifest = deriveChangesetManifest( + { + releases: [ + { + name: "@fixture/unrelated", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["unrelated"], + }, + ], + }, + { repoRoot: root }, + ); + const unrelatedBaseline = computeRelevantFingerprint(unrelatedManifest, root); + + const assertFileMutation = ( + relativePath: string, + contents: string, + changesFingerprint: boolean, + ) => { + writeFileSync(join(root, relativePath), contents); + const fingerprint = computeRelevantFingerprint(manifest, root); + if (changesFingerprint) { + expect(fingerprint).not.toBe(baseline); + } else { + expect(fingerprint).toBe(baseline); + } + writeFileSync( + join(root, relativePath), + { + "README.md": "# Caplets\n", + "pnpm-lock.yaml": "lockfileVersion: '9.0'\n", + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + ".changeset/cli-release.md": '---\n"caplets": patch\n---\n\nCLI release.\n', + ".changeset/unrelated.md": "---\n---\n\nUnrelated.\n", + ".github/workflows/dev-snapshot-release.yml": "name: release\n", + "scripts/check-package-runtime.mjs": "export default 1;\n", + "scripts/check-dev-snapshot-bootstrap.mjs": "export default 1;\n", + "scripts/dev-snapshot-release.mjs": "export default 1;\n", + "scripts/runtime-sentry-rolldown.ts": "export const sentry = 1;\n", + "packages/cli/src/release.ts": "export const release = 1;\n", + "packages/cli/test/release.test.ts": "export const testOnly = 1;\n", + }[relativePath]!, + ); + }; + + assertFileMutation("README.md", "# Caplets v2\n", true); + writeFileSync(join(root, "README.md"), "# Caplets v2\n"); + expect(computeRelevantFingerprint(unrelatedManifest, root)).toBe(unrelatedBaseline); + writeFileSync(join(root, "README.md"), "# Caplets\n"); + assertFileMutation("pnpm-lock.yaml", "lockfileVersion: '9.1'\n", true); + assertFileMutation("pnpm-workspace.yaml", "packages:\n - packages/*\n - tools/*\n", true); + assertFileMutation( + ".changeset/cli-release.md", + '---\n"caplets": patch\n---\n\nCLI release, revised.\n', + true, + ); + assertFileMutation("scripts/runtime-sentry-rolldown.ts", "export const sentry = 2;\n", true); + assertFileMutation("packages/cli/src/release.ts", "export const release = 2;\n", true); + assertFileMutation("scripts/dev-snapshot-release.mjs", "export default 2;\n", true); + assertFileMutation(".github/workflows/dev-snapshot-release.yml", "name: release v2\n", false); + assertFileMutation("scripts/check-package-runtime.mjs", "export default 2;\n", false); + assertFileMutation("scripts/check-dev-snapshot-bootstrap.mjs", "export default 2;\n", false); + assertFileMutation(".changeset/unrelated.md", "---\n---\n\nStill unrelated.\n", false); + assertFileMutation("packages/cli/test/release.test.ts", "export const testOnly = 2;\n", false); + + for (const { name, changes } of [ + { name: "release name", changes: { name: "caplets-renamed" } }, + { name: "base version", changes: { oldVersion: "1.0.1" } }, + { name: "release type", changes: { type: "minor" } }, + { name: "direct release marker", changes: { direct: false } }, + { + name: "relevant Changeset identifiers", + changes: { changesets: ["cli-release", "intent-change"] }, + }, + ]) { + const alteredIntent = { + ...manifest, + releases: manifest.releases.map((release) => + release.name === "caplets" ? { ...release, ...changes } : release, + ), + }; + expect(computeRelevantFingerprint(alteredIntent, root), name).not.toBe(baseline); + } + }); + + it("stamps every released package with the same complete snapshot identity", () => { + const root = createWorkspaceFixture([ + { + directory: "core", + manifest: { + name: "@fixture/core", + version: "1.2.3-dev-fixture", + publishConfig: { access: "public" }, + }, + }, + { + directory: "cli", + manifest: { + name: "fixture-cli", + version: "4.5.6-dev-fixture", + publishConfig: { access: "public" }, + dependencies: { "@fixture/core": "1.2.3-dev-fixture" }, + }, + }, + ]); + const manifest = snapshotManifest(); + const stampSnapshotMetadata = + requiredSnapshotExport<(manifest: SnapshotManifest, root?: string) => unknown>( + "stampSnapshotMetadata", + ); + + stampSnapshotMetadata(manifest, root); + + const expectedMetadata = { + schema: 1, + fingerprint: manifest.fingerprint, + sourceCommit: manifest.sourceCommit, + stagingTag: manifest.stagingTag, + releases: { + "@fixture/core": "1.2.3-dev-fixture", + "fixture-cli": "4.5.6-dev-fixture", + }, + }; + for (const release of manifest.releases) { + const packageJson = JSON.parse( + readFileSync(join(root, "packages", release.directory, "package.json"), "utf8"), + ) as { capletsSnapshot?: unknown }; + expect(packageJson.capletsSnapshot).toEqual(expectedMetadata); + } + }); + + it("classifies empty, coherently staged, and fully promoted registry lines", () => { + const classifyRegistrySnapshot = requiredSnapshotExport< + ( + manifest: SnapshotManifest, + packuments: RegistryPackuments, + ) => { + action: "fresh" | "reuse-staged" | "skip-promoted"; + stagingTag: string; + manifest: { + fingerprint: string; + sourceCommit: string; + releases: Array; + }; + } + >("classifyRegistrySnapshot"); + const stagedManifest = snapshotManifest(); + const recoveryRequest = { + ...stagedManifest, + sourceCommit: "fedcba0987654321fedcba0987654321fedcba09", + stagingTag: `dev-staged-${stagedManifest.fingerprint}-run-2`, + releases: stagedManifest.releases.map((release) => ({ + ...release, + newVersion: "0.0.0-pending", + })), + }; + + const untaggedPackuments = validRegistryPackuments(stagedManifest); + for (const release of stagedManifest.releases) { + delete (untaggedPackuments[release.name]!["dist-tags"] as Record)[ + stagedManifest.stagingTag + ]; + } + const fresh = classifyRegistrySnapshot(stagedManifest, untaggedPackuments); + expect(fresh).toMatchObject({ + action: "fresh", + stagingTag: stagedManifest.stagingTag, + manifest: { + fingerprint: stagedManifest.fingerprint, + sourceCommit: stagedManifest.sourceCommit, + }, + }); + + const reused = classifyRegistrySnapshot( + recoveryRequest, + validRegistryPackuments(stagedManifest), + ); + expect(reused).toMatchObject({ + action: "reuse-staged", + stagingTag: stagedManifest.stagingTag, + manifest: { + sourceCommit: stagedManifest.sourceCommit, + releases: [ + { + name: "@fixture/core", + newVersion: "1.2.3-dev-fixture", + integrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}`, + tarball: "https://registry.example.invalid/%40fixture%2Fcore", + }, + { + name: "fixture-cli", + newVersion: "4.5.6-dev-fixture", + integrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}`, + tarball: "https://registry.example.invalid/fixture-cli", + }, + ], + }, + }); + + const promotedPackuments = validRegistryPackuments(stagedManifest); + for (const release of stagedManifest.releases) { + (promotedPackuments[release.name]!["dist-tags"] as Record).dev = + release.newVersion; + } + const promoted = classifyRegistrySnapshot(recoveryRequest, promotedPackuments); + expect(promoted).toMatchObject({ + action: "skip-promoted", + stagingTag: stagedManifest.stagingTag, + manifest: { + releases: stagedManifest.releases.map((release) => ({ + name: release.name, + newVersion: release.newVersion, + })), + }, + }); + }); + + it("waits past repeated fresh registry scans for a required staged snapshot", async () => { + const recoverSnapshotManifest = requiredSnapshotExport< + ( + manifest: SnapshotManifest, + options: { + requiredAction: "reuse-staged"; + maxAttempts: number; + pollIntervalMs: number; + fetchImplementation: (url: string) => Promise<{ + ok: boolean; + status: number; + json?: () => Promise>; + }>; + }, + ) => Promise<{ + action: "fresh" | "reuse-staged" | "skip-promoted"; + stagingTag: string; + manifest: SnapshotManifest; + }> + >("recoverSnapshotManifest"); + const manifest = snapshotManifest(); + const stagedPackuments = validRegistryPackuments(manifest); + let requests = 0; + + const recovery = await recoverSnapshotManifest(manifest, { + requiredAction: "reuse-staged", + maxAttempts: 5, + pollIntervalMs: 0, + fetchImplementation: async (url) => { + const scan = Math.floor(requests / manifest.releases.length); + requests += 1; + if (scan < 2) return { ok: false, status: 404 }; + const packageName = decodeURIComponent(new URL(url).pathname.slice(1)); + return { + ok: true, + status: 200, + json: async () => stagedPackuments[packageName]!, + }; + }, + }); + + expect(recovery).toMatchObject({ + action: "reuse-staged", + stagingTag: manifest.stagingTag, + manifest: { + fingerprint: manifest.fingerprint, + releases: manifest.releases, + }, + }); + expect(requests).toBeGreaterThanOrEqual(manifest.releases.length * 3); + expect(recovery.manifest.releases).toContainEqual( + expect.objectContaining({ + name: "@fixture/core", + integrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}`, + tarball: "https://registry.example.invalid/%40fixture%2Fcore", + }), + ); + }); + + it("treats orphaned partial staging generations as fresh work", () => { + const classifyRegistrySnapshot = requiredSnapshotExport< + ( + manifest: SnapshotManifest, + packuments: RegistryPackuments, + ) => { action: "fresh" | "reuse-staged" | "skip-promoted"; stagingTag: string } + >("classifyRegistrySnapshot"); + const manifest = { + ...snapshotManifest(), + stagingTag: `dev-staged-${"a".repeat(64)}-new-run`, + }; + const orphanedManifest = { + ...manifest, + stagingTag: `dev-staged-${manifest.fingerprint}-old-run`, + }; + const packuments = validRegistryPackuments(orphanedManifest); + delete (packuments["fixture-cli"]!["dist-tags"] as Record)[ + orphanedManifest.stagingTag + ]; + + expect(classifyRegistrySnapshot(manifest, packuments)).toMatchObject({ + action: "fresh", + stagingTag: manifest.stagingTag, + }); + }); + it("treats verified older dev targets as prior state without a current-fingerprint staging candidate", () => { + const classifyRegistrySnapshot = requiredSnapshotExport< + ( + manifest: SnapshotManifest, + packuments: RegistryPackuments, + ) => { action: "fresh" | "reuse-staged" | "skip-promoted"; stagingTag: string } + >("classifyRegistrySnapshot"); + const manifest = snapshotManifest(); + const priorManifest: SnapshotManifest = { + ...snapshotManifest("b".repeat(64)), + stagingTag: `dev-staged-${"b".repeat(64)}-prior-run`, + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "1.1.0-dev-prior" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.4.0-dev-prior" }, + ], + }; + const packuments = validRegistryPackuments(priorManifest); + for (const release of priorManifest.releases) { + (packuments[release.name]!["dist-tags"] as Record).dev = release.newVersion; + } + + expect(classifyRegistrySnapshot(manifest, packuments)).toMatchObject({ + action: "fresh", + stagingTag: manifest.stagingTag, + }); + }); + + it("treats mixed verified older dev fingerprints across an expanded closure as prior state", () => { + const classifyRegistrySnapshot = requiredSnapshotExport< + ( + manifest: SnapshotManifest, + packuments: RegistryPackuments, + ) => { action: "fresh" | "reuse-staged" | "skip-promoted"; stagingTag: string } + >("classifyRegistrySnapshot"); + const manifest: SnapshotManifest = { + ...snapshotManifest(), + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "1.2.3-dev-current" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.5.6-dev-current" }, + { + name: "@fixture/closure-only", + directory: "closure-only", + newVersion: "7.8.9-dev-current", + }, + ], + }; + const priorGenerations: SnapshotManifest[] = [ + { + ...manifest, + fingerprint: "b".repeat(64), + stagingTag: `dev-staged-${"b".repeat(64)}-prior-core`, + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "1.1.0-dev-prior-core" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.4.0-dev-prior-core" }, + { + name: "@fixture/closure-only", + directory: "closure-only", + newVersion: "7.7.0-dev-prior-core", + }, + ], + }, + { + ...manifest, + fingerprint: "c".repeat(64), + stagingTag: `dev-staged-${"c".repeat(64)}-prior-cli`, + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "1.0.0-dev-prior-cli" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.3.0-dev-prior-cli" }, + { + name: "@fixture/closure-only", + directory: "closure-only", + newVersion: "7.6.0-dev-prior-cli", + }, + ], + }, + { + ...manifest, + fingerprint: "d".repeat(64), + stagingTag: `dev-staged-${"d".repeat(64)}-prior-closure`, + releases: [ + { name: "@fixture/core", directory: "core", newVersion: "0.9.0-dev-prior-closure" }, + { name: "fixture-cli", directory: "cli", newVersion: "4.2.0-dev-prior-closure" }, + { + name: "@fixture/closure-only", + directory: "closure-only", + newVersion: "7.5.0-dev-prior-closure", + }, + ], + }, + ]; + const packuments = validRegistryPackuments(priorGenerations[0]!); + for (const priorManifest of priorGenerations.slice(1)) { + const priorPackuments = validRegistryPackuments(priorManifest); + for (const release of manifest.releases) { + Object.assign( + packuments[release.name]!.versions as Record, + priorPackuments[release.name]!.versions as Record, + ); + Object.assign( + packuments[release.name]!["dist-tags"] as Record, + priorPackuments[release.name]!["dist-tags"] as Record, + ); + } + } + for (const [index, release] of manifest.releases.entries()) { + (packuments[release.name]!["dist-tags"] as Record).dev = + priorGenerations[index]!.releases[index]!.newVersion; + } + + expect(classifyRegistrySnapshot(manifest, packuments)).toMatchObject({ + action: "fresh", + stagingTag: manifest.stagingTag, + }); + }); + + it("rejects registry recovery with a partial promotion or incoherent common generation", () => { + const classifyRegistrySnapshot = requiredSnapshotExport< + (manifest: SnapshotManifest, packuments: RegistryPackuments) => unknown + >("classifyRegistrySnapshot"); + const manifest = snapshotManifest(); + const versionRecord = (packuments: RegistryPackuments, packageName: string) => + (packuments[packageName]!.versions as Record>)[ + manifest.releases.find((release) => release.name === packageName)!.newVersion + ]!; + const corruptionCases: Array<{ + name: string; + corrupt: (packuments: RegistryPackuments) => void; + }> = [ + { + name: "partial dev tags for a complete current-fingerprint staging candidate", + corrupt: (packuments) => { + (packuments["@fixture/core"]!["dist-tags"] as Record).dev = + "1.2.3-dev-fixture"; + }, + }, + { + name: "wrong metadata schema", + corrupt: (packuments) => { + ( + versionRecord(packuments, "@fixture/core").capletsSnapshot as Record + ).schema = 2; + }, + }, + { + name: "wrong metadata staging tag", + corrupt: (packuments) => { + ( + versionRecord(packuments, "@fixture/core").capletsSnapshot as Record + ).stagingTag = `dev-staged-${manifest.fingerprint}-other-run`; + }, + }, + { + name: "wrong metadata fingerprint", + corrupt: (packuments) => { + ( + versionRecord(packuments, "@fixture/core").capletsSnapshot as Record + ).fingerprint = "b".repeat(64); + }, + }, + { + name: "wrong metadata release map", + corrupt: (packuments) => { + ( + (versionRecord(packuments, "fixture-cli").capletsSnapshot as Record) + .releases as Record + )["@fixture/core"] = "9.9.9-dev-mismatch"; + }, + }, + { + name: "mismatched package identity", + corrupt: (packuments) => { + versionRecord(packuments, "fixture-cli").name = "not-fixture-cli"; + }, + }, + { + name: "mismatched version identity", + corrupt: (packuments) => { + versionRecord(packuments, "fixture-cli").version = "9.9.9-dev-mismatch"; + }, + }, + { + name: "missing integrity", + corrupt: (packuments) => { + delete (versionRecord(packuments, "@fixture/core").dist as Record) + .integrity; + }, + }, + { + name: "invalid integrity", + corrupt: (packuments) => { + (versionRecord(packuments, "@fixture/core").dist as Record).integrity = + "sha512-not-a-digest"; + }, + }, + { + name: "missing tarball", + corrupt: (packuments) => { + delete (versionRecord(packuments, "@fixture/core").dist as Record) + .tarball; + }, + }, + { + name: "invalid tarball", + corrupt: (packuments) => { + (versionRecord(packuments, "@fixture/core").dist as Record).tarball = + "not a URL"; + }, + }, + { + name: "mismatched internal closure dependency", + corrupt: (packuments) => { + (versionRecord(packuments, "fixture-cli").dependencies as Record)[ + "@fixture/core" + ] = "9.9.9-dev-mismatch"; + }, + }, + ]; + + for (const { name, corrupt } of corruptionCases) { + const packuments = validRegistryPackuments(manifest); + corrupt(packuments); + expect(() => classifyRegistrySnapshot(manifest, packuments), name).toThrow(); + } }); it("patches Changesets config and composes a dev snapshot prerelease", () => { @@ -449,9 +1204,8 @@ describe("dev snapshot release helpers", () => { expect(workflow).toContain("name: Validation barrier"); expect(workflow).not.toMatch(/^ reconcile_promoted_cli_failure:$/m); expect(workflow).not.toMatch(/^ verify_promoted_cli:$/m); - expect(workflow).toContain( - "staging_tag=dev-staged-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}", - ); + expect(workflow).toContain("createStagingTag(manifest.fingerprint"); + expect(workflow).toContain("recovery_action"); expect(workflow.split('export PATH="$INSTALL_ROOT/bin:$PATH"').length - 1).toBe(2); expect(workflow.split("persist-credentials: false").length - 1).toBe(6); expect(workflow).not.toContain('eval "$command"'); @@ -460,7 +1214,27 @@ describe("dev snapshot release helpers", () => { "if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'", ); expect(publish).toContain(`if: ${mainOnlyCondition}`); - expect(promote).toContain(`if: ${mainOnlyCondition}`); + const promoteJobCondition = + promote.match(/^ if: (?.+)$/m)?.groups?.condition ?? ""; + expect(promoteJobCondition).toContain("always()"); + expect(promoteJobCondition).toContain(mainOnlyCondition); + expect(promoteJobCondition).toContain("needs.plan.result == 'success'"); + expect(promoteJobCondition).toContain("needs.publish.result == 'success'"); + expect(promoteJobCondition).toContain("needs.publish.result == 'skipped'"); + expect(promoteJobCondition).toContain("needs.validation_complete.result == 'success'"); + const promotionTransactionMarkerName = "Mark promotion transaction started"; + const promoteMutationStepName = "Promote validated versions to dev with reconciliation"; + const promotionTransactionMarker = workflowStep(promote, promotionTransactionMarkerName); + const promoteStepNames = [...promote.matchAll(/^ - name: (?.+)$/gm)].map( + (match) => match.groups?.name ?? "", + ); + const postPublishRecovery = workflowStep( + publish, + "Verify published snapshot registry identity", + ); + expect(postPublishRecovery).toMatch( + /recoverSnapshotManifest\(\s*readJson\(manifestPath\),\s*\{\s*requiredAction:\s*["']reuse-staged["']\s*,?\s*\}\s*\)/, + ); const promotedCliSmoke = workflowStep(promote, "Smoke promoted caplets@dev line"); const restorePromotedTags = workflowStep( promote, @@ -470,10 +1244,37 @@ describe("dev snapshot release helpers", () => { promote.indexOf("Restore dev tags after promoted smoke failure"), ); expect(promotedCliSmoke).toContain("id: smoke_promoted_cli"); + expect(promotionTransactionMarker).toContain("id: promotion_transaction_started"); + expect(promotionTransactionMarker).toContain('echo "started=true" >> "$GITHUB_OUTPUT"'); + expect(promoteStepNames[promoteStepNames.indexOf(promotionTransactionMarkerName) + 1]).toBe( + promoteMutationStepName, + ); expect(restorePromotedTags).toContain("if: ${{ always()"); - expect(restorePromotedTags).toContain("needs.plan.outputs.validation_kind == 'cli-bootstrap'"); - expect(restorePromotedTags).toContain("steps.promote_dev_tags.outcome == 'success'"); - expect(restorePromotedTags).toContain("steps.smoke_promoted_cli.outcome != 'success'"); + expect(restorePromotedTags).toContain( + "steps.promotion_transaction_started.outputs.started == 'true'", + ); + expect(restorePromotedTags).toMatch( + /steps\.promotion_transaction_started\.outputs\.started == 'true'\s*&&\s*\(\s*(?:steps\.promote_dev_tags\.outcome != 'success'\s*\|\|\s*\(\s*needs\.plan\.outputs\.validation_kind == 'cli-bootstrap'\s*&&\s*steps\.smoke_promoted_cli\.outcome != 'success'\s*\)|\(\s*needs\.plan\.outputs\.validation_kind == 'cli-bootstrap'\s*&&\s*steps\.smoke_promoted_cli\.outcome != 'success'\s*\)\s*\|\|\s*steps\.promote_dev_tags\.outcome != 'success')\s*\)/, + ); + expect(restorePromotedTags).not.toContain( + "if: ${{ always() && needs.plan.outputs.validation_kind == 'cli-bootstrap'", + ); + expect(restorePromotedTags).toContain( + "for (const release of [...manifest.releases].reverse()) {", + ); + expect(restorePromotedTags).not.toMatch( + /if\s*\(\s*tags\.dev\s*!==\s*release\.newVersion\s*\)\s*(?:\{\s*)?continue\b/, + ); + expect(restorePromotedTags).toContain("const previousDev = preRunTags[release.name]?.dev;"); + expect(restorePromotedTags).toContain( + "['dist-tag', 'add', `${release.name}@${previousDev}`, 'dev']", + ); + expect(restorePromotedTags).toContain("['dist-tag', 'rm', release.name, 'dev']"); + expect(restorePromotedTags).toContain( + "if (readTags(release.name).dev !== undefined) throw error;", + ); + expect(restorePromotedTags).toContain("(candidate) => candidate.dev === previousDev"); + expect(restorePromotedTags).toContain("(candidate) => candidate.dev === undefined"); expect(promotedCliSmoke).not.toContain("NODE_AUTH_TOKEN"); for (const job of [publish, promote]) { expect(job).toMatch( @@ -488,12 +1289,84 @@ describe("dev snapshot release helpers", () => { ); }); + it("routes fresh, reusable, and promoted snapshot states without publishing an unverified line", () => { + const workflow = readFileSync( + join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), + "utf8", + ); + const plan = workflowJob(workflow, "plan"); + const publish = workflowJob(workflow, "publish"); + const validationJobs = [ + workflowJob(workflow, "validate_cli"), + workflowJob(workflow, "validate_packages"), + ]; + const promote = workflowJob(workflow, "promote"); + const devImage = workflowJob(workflow, "dev_image"); + const artifactUpload = workflowStep(plan, "Upload manifest artifact"); + + expect(plan).toContain("recovery_action"); + expect(plan).toContain("staging_tag"); + expect(plan).not.toContain("NODE_AUTH_TOKEN"); + expect(plan.indexOf("classifyRegistrySnapshot")).toBeLessThan(plan.indexOf(artifactUpload)); + expect(publish).toContain("needs.plan.outputs.recovery_action == 'fresh'"); + expect(publish).toContain("stampSnapshotMetadata"); + expect(publish.indexOf("stampSnapshotMetadata")).toBeLessThan(publish.indexOf("pnpm build")); + expect(publish.indexOf("stampSnapshotMetadata")).toBeLessThan( + publish.indexOf("pnpm changeset publish"), + ); + expect(publish.lastIndexOf("classifyRegistrySnapshot")).toBeGreaterThan( + publish.indexOf("pnpm changeset publish"), + ); + expect(publish.lastIndexOf("classifyRegistrySnapshot")).toBeLessThan( + publish.lastIndexOf("actions/upload-artifact@v4"), + ); + expect(publish).toMatch( + /if\s*\(\s*\w+\.action\s*!==\s*["']reuse-staged["']\s*\)\s*(?:\{\s*)?throw/, + ); + expect(publish).toMatch(/writeJson\([^,]+,\s*\w+\.manifest\)/); + expect(publish.lastIndexOf("writeJson(")).toBeGreaterThan( + publish.lastIndexOf("classifyRegistrySnapshot"), + ); + expect(publish.lastIndexOf("writeJson(")).toBeLessThan( + publish.lastIndexOf("actions/upload-artifact@v4"), + ); + expect(publish).toContain("overwrite: true"); + + for (const validation of validationJobs) { + expect(validation).toContain("needs.plan.outputs.recovery_action == 'fresh'"); + expect(validation).toContain("needs.plan.outputs.recovery_action == 'reuse-staged'"); + expect(validation).not.toContain("needs.plan.outputs.recovery_action == 'skip-promoted'"); + } + expect(promote).toContain("needs.plan.outputs.recovery_action == 'fresh'"); + expect(promote).toContain("needs.plan.outputs.recovery_action == 'reuse-staged'"); + expect(promote).not.toContain("needs.plan.outputs.recovery_action == 'skip-promoted'"); + expect(devImage).toContain("needs.promote.result == 'success'"); + }); + + it("prepares pinned QEMU before Buildx for multi-platform dev image publishing", () => { + const snapshotWorkflow = readFileSync( + join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), + "utf8", + ); + const devImage = workflowJob(snapshotWorkflow, "dev_image"); + const qemu = workflowStep(devImage, "Setup QEMU"); + const buildx = workflowStep(devImage, "Setup Docker Buildx"); + const publishDevImage = workflowStep(devImage, "Publish dev image tags"); + + expect(qemu).toContain( + "uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3", + ); + expect(devImage.indexOf(qemu)).toBeLessThan(devImage.indexOf(buildx)); + expect(publishDevImage).toContain("platforms: linux/amd64,linux/arm64"); + }); + it("limits snapshot release authority to the reusable protected jobs", () => { const snapshotWorkflow = readFileSync( join(repoRoot, ".github/workflows/dev-snapshot-release.yml"), "utf8", ); const releaseWorkflow = readFileSync(join(repoRoot, ".github/workflows/release.yml"), "utf8"); + const stableRelease = workflowJob(releaseWorkflow, "release"); const publish = workflowJob(snapshotWorkflow, "publish"); const promote = workflowJob(snapshotWorkflow, "promote"); const devImage = workflowJob(snapshotWorkflow, "dev_image"); @@ -541,7 +1414,8 @@ describe("dev snapshot release helpers", () => { expect(snapshotWorkflow).toContain(" workflow_call:"); expect(snapshotWorkflow).not.toMatch(/^ {2}push:$/m); expect(snapshotWorkflow).not.toContain("workflow_dispatch:"); - expect(releaseWorkflow).not.toContain("workflow_dispatch:"); + expect(releaseWorkflow).toContain("workflow_dispatch:"); + expect(stableRelease).toContain("if: github.ref == 'refs/heads/main'"); expect(releaseWorkflow).toContain("uses: ./.github/workflows/dev-snapshot-release.yml"); const snapshotCall = workflowJobContaining( @@ -549,6 +1423,7 @@ describe("dev snapshot release helpers", () => { "uses: ./.github/workflows/dev-snapshot-release.yml", ); expect(snapshotCall).toContain("needs: release"); + expect(snapshotCall).toContain("if: github.ref == 'refs/heads/main'"); expect(snapshotCall).toContain("contents: read"); expect(snapshotCall).toContain("id-token: write"); expect(snapshotCall).toContain("packages: write"); diff --git a/scripts/dev-snapshot-release.d.mts b/scripts/dev-snapshot-release.d.mts index 10c4064c..21cc5384 100644 --- a/scripts/dev-snapshot-release.d.mts +++ b/scripts/dev-snapshot-release.d.mts @@ -19,11 +19,13 @@ export type WorkspacePackageEntry = { export type ReleaseEntry = { name: string; directory: string; - oldVersion: string | undefined; - newVersion: string | undefined; - type: string; - changesets: string[]; - direct: boolean; + oldVersion?: string; + newVersion: string; + type?: string; + changesets?: string[]; + direct?: boolean; + integrity?: string; + tarball?: string; }; export type ValidationMode = @@ -37,19 +39,85 @@ export type ValidationMode = }; export type SnapshotManifest = { - hasPublicReleases: boolean; + hasPublicReleases?: boolean; releases: ReleaseEntry[]; - validation: ValidationMode; + validation?: ValidationMode; fingerprint?: string; + sourceCommit?: string; + stagingTag?: string; + recoveryAction?: "fresh" | "reuse-staged" | "skip-promoted"; changesetFiles?: string[]; }; +export type SnapshotIdentityManifest = SnapshotManifest & { + fingerprint: string; + sourceCommit: string; + stagingTag: string; +}; + +export type RegistryVersion = { + name?: unknown; + version?: unknown; + capletsSnapshot?: unknown; + dist?: unknown; + dependencies?: unknown; + devDependencies?: unknown; + optionalDependencies?: unknown; + peerDependencies?: unknown; + [key: string]: unknown; +}; + +export type RegistryPackument = { + "dist-tags"?: Record; + versions?: Record; + [key: string]: unknown; +}; + +export type RegistryPackuments = Record; + +export type RecoveredSnapshotManifest = SnapshotIdentityManifest & { + releases: Array; +}; + +export type RegistrySnapshotClassification = + | { + action: "fresh"; + stagingTag: string; + manifest: SnapshotIdentityManifest; + } + | { + action: "reuse-staged"; + stagingTag: string; + manifest: RecoveredSnapshotManifest; + } + | { + action: "skip-promoted"; + stagingTag: string; + manifest: RecoveredSnapshotManifest; + }; + +export type PublicRegistryResponse = { + ok: boolean; + status?: number; + json(): Promise; +}; + +export type RecoverSnapshotOptions = { + maxAttempts?: number; + requiredAction?: RegistrySnapshotClassification["action"]; + pollIntervalMs?: number; + fetchImplementation?: ( + url: string, + init: { headers: { Accept: string; "Cache-Control": string } }, + ) => Promise; +}; + export const CLI_PACKAGE_NAME: string; export const CORE_PACKAGE_NAME: string; export const OPENCODE_PACKAGE_NAME: string; export const PI_PACKAGE_NAME: string; -export function createStagingTag(runIdentifier: string): string; +export function createStagingTag(fingerprint: string, runIdentity: string): string; export function readJson(path: string): unknown; export function writeJson(path: string, value: unknown): void; export function discoverWorkspacePackageManifests(root?: string): WorkspacePackageEntry[]; @@ -74,7 +142,10 @@ export function collectRelevantFingerprintFiles( root?: string, ): string[]; export function computeRelevantFingerprint(manifest: SnapshotManifest, root?: string): string; -export function withFingerprint(manifest: SnapshotManifest, root?: string): SnapshotManifest; +export function withFingerprint( + manifest: SnapshotManifest, + root?: string, +): SnapshotManifest & { fingerprint: string; changesetFiles: string[] }; export function patchSnapshotConfig(root?: string): Record; export function writePatchedSnapshotConfig(root?: string): Record; export function refreshSnapshotManifestVersions( @@ -84,3 +155,26 @@ export function refreshSnapshotManifestVersions( export function rewriteClosureManifests(snapshotManifest: SnapshotManifest, root?: string): void; export function assertRewrittenClosure(snapshotManifest: SnapshotManifest, root?: string): string[]; export function parseArgs(argv: string[]): { subcommand: string; options: Map }; +export function stampSnapshotMetadata( + snapshotManifest: SnapshotIdentityManifest, + root?: string, +): { + schema: 1; + fingerprint: string; + sourceCommit: string; + stagingTag: string; + releases: Record; +}; +export function classifyRegistrySnapshot( + snapshotManifest: SnapshotIdentityManifest, + packuments: RegistryPackuments, +): RegistrySnapshotClassification; +export function fetchPublicPackument( + packageName: string, + fetchImplementation?: RecoverSnapshotOptions["fetchImplementation"], +): Promise; +export function recoverSnapshotManifest( + snapshotManifest: SnapshotIdentityManifest, + options?: RecoverSnapshotOptions, +): Promise; +export function runCli(argv?: string[]): Promise; diff --git a/scripts/dev-snapshot-release.mjs b/scripts/dev-snapshot-release.mjs index 456f477d..ec7cc6fb 100644 --- a/scripts/dev-snapshot-release.mjs +++ b/scripts/dev-snapshot-release.mjs @@ -4,28 +4,46 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const stableWorkflowPath = ".github/workflows/release.yml"; -const devWorkflowPath = ".github/workflows/dev-snapshot-release.yml"; export const CLI_PACKAGE_NAME = "caplets"; export const CORE_PACKAGE_NAME = "@caplets/core"; export const OPENCODE_PACKAGE_NAME = "@caplets/opencode"; export const PI_PACKAGE_NAME = "@caplets/pi"; -export function createStagingTag(runIdentifier) { - if (!runIdentifier || typeof runIdentifier !== "string") { - throw new Error("A run identifier is required to build a staging tag."); +const fingerprintPattern = /^[0-9a-f]{64}$/; +const safeRunIdentityPattern = /^[a-z0-9][a-z0-9._-]*$/; +const ignoredFingerprintDirectories = new Set([ + ".astro", + ".git", + ".next", + ".turbo", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); + +export function createStagingTag(fingerprint, runIdentity) { + if (typeof fingerprint !== "string" || !fingerprintPattern.test(fingerprint)) { + throw new Error( + "A lowercase 64-character SHA-256 fingerprint is required to build a staging tag.", + ); + } + if (typeof runIdentity !== "string" || !safeRunIdentityPattern.test(runIdentity)) { + throw new Error("A nonempty npm-safe run identity is required to build a staging tag."); } - return `dev-staged-${runIdentifier}`; + return `dev-staged-${fingerprint}-${runIdentity}`; } const rootFingerprintFiles = [ "package.json", ".changeset/config.json", - stableWorkflowPath, - devWorkflowPath, - "scripts/check-package-runtime.mjs", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "turbo.json", + "tsconfig.json", "scripts/dev-snapshot-release.mjs", - "scripts/check-dev-snapshot-bootstrap.mjs", + "scripts/runtime-sentry-rolldown.ts", ]; export function readJson(path) { @@ -171,26 +189,70 @@ export function listChangesetFiles(root = repoRoot) { .map((name) => `.changeset/${name}`) .sort(); } +function releaseChangesetIds(release) { + const changesets = Array.isArray(release.changesets) ? release.changesets : []; + return [...new Set(changesets.filter((id) => typeof id === "string"))].sort(); +} + +function relevantChangesetIds(manifest) { + return [...new Set(manifest.releases.flatMap(releaseChangesetIds))].sort(); +} + +function collectRelevantChangesetFiles(manifest, root) { + const relevantChangesets = new Set(relevantChangesetIds(manifest)); + return listChangesetFiles(root).filter((file) => + relevantChangesets.has(file.slice(".changeset/".length, -".md".length)), + ); +} + +function canonicalReleaseIntent(manifest) { + return manifest.releases + .map((release) => ({ + name: release.name, + oldVersion: release.oldVersion ?? null, + newVersion: release.newVersion ?? null, + type: release.type ?? null, + direct: release.direct === true, + changesets: releaseChangesetIds(release), + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} function hashFile(path, hash) { hash.update(readFileSync(path)); } +function isTestFile(name) { + return /(?:^|\.)(?:test|spec)\.[cm]?[jt]sx?$/.test(name); +} + function walkFiles(path, files) { if (!existsSync(path)) return; const stats = statSync(path); if (stats.isFile()) { - files.push(path); + if (!isTestFile(path.split(sep).at(-1))) files.push(path); return; } for (const entry of readdirSync(path, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === "dist") continue; + if ( + entry.isDirectory() && + (ignoredFingerprintDirectories.has(entry.name) || + entry.name === "test" || + entry.name === "tests" || + entry.name === "__tests__") + ) { + continue; + } + if (entry.isFile() && isTestFile(entry.name)) continue; walkFiles(join(path, entry.name), files); } } export function collectRelevantFingerprintFiles(manifest, root = repoRoot) { const files = []; + for (const relativePath of collectRelevantChangesetFiles(manifest, root)) { + files.push(join(root, relativePath)); + } for (const relativePath of rootFingerprintFiles) { const absolutePath = join(root, relativePath); if (existsSync(absolutePath)) files.push(absolutePath); @@ -198,8 +260,12 @@ export function collectRelevantFingerprintFiles(manifest, root = repoRoot) { for (const release of manifest.releases) { walkFiles(join(root, release.directory), files); } - for (const changesetFile of listChangesetFiles(root)) { - files.push(join(root, changesetFile)); + if (manifest.releases.some((release) => release.name === CORE_PACKAGE_NAME)) { + walkFiles(join(root, "apps", "dashboard"), files); + } + if (manifest.releases.some((release) => release.name === CLI_PACKAGE_NAME)) { + const readme = join(root, "README.md"); + if (existsSync(readme)) files.push(readme); } return [...new Set(files)].sort(); } @@ -207,6 +273,8 @@ export function collectRelevantFingerprintFiles(manifest, root = repoRoot) { export function computeRelevantFingerprint(manifest, root = repoRoot) { const hash = createHash("sha256"); const files = collectRelevantFingerprintFiles(manifest, root); + hash.update(JSON.stringify(canonicalReleaseIntent(manifest))); + hash.update("\0"); for (const file of files) { hash.update(relative(root, file)); hash.update("\0"); @@ -220,7 +288,7 @@ export function withFingerprint(manifest, root = repoRoot) { return { ...manifest, fingerprint: computeRelevantFingerprint(manifest, root), - changesetFiles: listChangesetFiles(root), + changesetFiles: collectRelevantChangesetFiles(manifest, root), }; } @@ -341,15 +409,436 @@ export function assertRewrittenClosure(snapshotManifest, root = repoRoot) { return failures; } +const dependencySectionNames = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function snapshotReleaseMap(snapshotManifest) { + if (!Array.isArray(snapshotManifest?.releases)) { + throw new Error("Snapshot manifest releases must be an array."); + } + const versions = new Map(); + for (const release of snapshotManifest.releases) { + if ( + !isRecord(release) || + typeof release.name !== "string" || + release.name.length === 0 || + typeof release.newVersion !== "string" || + release.newVersion.length === 0 || + versions.has(release.name) + ) { + throw new Error("Snapshot manifest contains an invalid release entry."); + } + versions.set(release.name, release.newVersion); + } + return versions; +} + +function validateSnapshotIdentity(snapshotManifest) { + if (!isRecord(snapshotManifest) || !fingerprintPattern.test(snapshotManifest.fingerprint)) { + throw new Error("Snapshot manifest requires a lowercase 64-character fingerprint."); + } + if ( + typeof snapshotManifest.sourceCommit !== "string" || + snapshotManifest.sourceCommit.length === 0 + ) { + throw new Error("Snapshot manifest requires a source commit."); + } + if (typeof snapshotManifest.stagingTag !== "string") { + throw new Error("Snapshot manifest requires a staging tag."); + } + const prefix = `dev-staged-${snapshotManifest.fingerprint}-`; + const runIdentity = snapshotManifest.stagingTag.slice(prefix.length); + if ( + !snapshotManifest.stagingTag.startsWith(prefix) || + createStagingTag(snapshotManifest.fingerprint, runIdentity) !== snapshotManifest.stagingTag + ) { + throw new Error("Snapshot manifest has an invalid staging tag."); + } +} + +export function stampSnapshotMetadata(snapshotManifest, root = repoRoot) { + validateSnapshotIdentity(snapshotManifest); + const releaseVersions = snapshotReleaseMap(snapshotManifest); + const metadata = { + schema: 1, + fingerprint: snapshotManifest.fingerprint, + sourceCommit: snapshotManifest.sourceCommit, + stagingTag: snapshotManifest.stagingTag, + releases: Object.fromEntries(releaseVersions), + }; + const publicPackages = new Map( + listPublicPublishablePackages(root).map((entry) => [entry.name, entry]), + ); + for (const release of snapshotManifest.releases) { + const entry = publicPackages.get(release.name); + if (!entry) { + throw new Error(`Missing public package metadata for ${release.name}.`); + } + writeJson(entry.path, { + ...readJson(entry.path), + capletsSnapshot: metadata, + }); + } + return metadata; +} + +function readPackument(packuments, packageName) { + const packument = packuments?.[packageName]; + if (!isRecord(packument)) { + throw new Error(`Missing registry packument for ${packageName}.`); + } + return packument; +} + +function readDistTags(packument, packageName) { + const tags = packument["dist-tags"]; + if (!isRecord(tags)) { + throw new Error(`Registry packument for ${packageName} has invalid dist-tags.`); + } + return tags; +} + +function isSha512Integrity(integrity) { + if (typeof integrity !== "string" || !integrity.startsWith("sha512-")) return false; + const digest = integrity.slice("sha512-".length); + if (!/^[A-Za-z0-9+/]{86}==$/.test(digest)) return false; + try { + const bytes = Buffer.from(digest, "base64"); + return bytes.length === 64 && bytes.toString("base64") === digest; + } catch { + return false; + } +} + +function isTarballUrl(tarball) { + if (typeof tarball !== "string") return false; + try { + const url = new URL(tarball); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function assertExactReleaseMap(releases, expectedVersions, packageName) { + if (!isRecord(releases)) { + throw new Error(`Registry metadata for ${packageName} has an invalid release map.`); + } + const expectedNames = [...expectedVersions.keys()].sort(); + const actualNames = Object.keys(releases).sort(); + if ( + actualNames.length !== expectedNames.length || + actualNames.some((name, index) => name !== expectedNames[index]) + ) { + throw new Error(`Registry metadata for ${packageName} has a mismatched release map.`); + } + for (const [name, version] of expectedVersions) { + if (releases[name] !== version) { + throw new Error(`Registry metadata for ${packageName} has a mismatched release map.`); + } + } +} + +function readStagingTags(packument, packageName, fingerprint) { + const prefix = `dev-staged-${fingerprint}-`; + return new Set( + Object.keys(readDistTags(packument, packageName)).filter((tag) => tag.startsWith(prefix)), + ); +} + +function validateRegistryGeneration(snapshotManifest, packuments, stagingTag) { + const releases = snapshotManifest.releases; + const versionByName = new Map(); + const recordsByName = new Map(); + const artifactsByName = new Map(); + let sourceCommit; + + for (const release of releases) { + const packument = readPackument(packuments, release.name); + const tags = readDistTags(packument, release.name); + const version = tags[stagingTag]; + if (typeof version !== "string" || version.length === 0) { + throw new Error(`Staging tag ${stagingTag} has no exact version for ${release.name}.`); + } + const versions = packument.versions; + const record = isRecord(versions) ? versions[version] : undefined; + if (!isRecord(record) || record.name !== release.name || record.version !== version) { + throw new Error(`Registry version identity does not match ${release.name}@${version}.`); + } + const metadata = record.capletsSnapshot; + if ( + !isRecord(metadata) || + metadata.schema !== 1 || + metadata.fingerprint !== snapshotManifest.fingerprint || + metadata.stagingTag !== stagingTag || + typeof metadata.sourceCommit !== "string" || + metadata.sourceCommit.length === 0 + ) { + throw new Error(`Registry snapshot metadata does not match ${release.name}@${version}.`); + } + if (sourceCommit === undefined) { + sourceCommit = metadata.sourceCommit; + } else if (sourceCommit !== metadata.sourceCommit) { + throw new Error( + `Registry snapshot metadata has inconsistent source commits for ${stagingTag}.`, + ); + } + const dist = record.dist; + if (!isRecord(dist) || !isSha512Integrity(dist.integrity) || !isTarballUrl(dist.tarball)) { + throw new Error(`Registry artifact metadata is invalid for ${release.name}@${version}.`); + } + versionByName.set(release.name, version); + recordsByName.set(release.name, record); + artifactsByName.set(release.name, { + integrity: dist.integrity, + tarball: dist.tarball, + metadata, + }); + } + + for (const release of releases) { + const { metadata } = artifactsByName.get(release.name); + assertExactReleaseMap(metadata.releases, versionByName, release.name); + const record = recordsByName.get(release.name); + for (const sectionName of dependencySectionNames) { + const dependencies = record[sectionName]; + if (dependencies === undefined) continue; + if (!isRecord(dependencies)) { + throw new Error(`Registry dependency metadata is invalid for ${release.name}.`); + } + for (const [dependencyName, expectedVersion] of versionByName) { + if (dependencyName === release.name || !Object.hasOwn(dependencies, dependencyName)) + continue; + if (dependencies[dependencyName] !== expectedVersion) { + throw new Error( + `Registry dependency metadata does not match ${release.name} ${sectionName}.${dependencyName}.`, + ); + } + } + } + } + + return { + stagingTag, + sourceCommit, + versionByName, + artifactsByName, + }; +} + +function hydrateRegistryManifest(snapshotManifest, generation) { + return { + ...snapshotManifest, + sourceCommit: generation.sourceCommit, + stagingTag: generation.stagingTag, + releases: snapshotManifest.releases.map((release) => ({ + ...release, + newVersion: generation.versionByName.get(release.name), + integrity: generation.artifactsByName.get(release.name).integrity, + tarball: generation.artifactsByName.get(release.name).tarball, + })), + }; +} + +export function classifyRegistrySnapshot(snapshotManifest, packuments) { + validateSnapshotIdentity(snapshotManifest); + const releaseVersions = snapshotReleaseMap(snapshotManifest); + if (releaseVersions.size === 0) { + return { + action: "fresh", + stagingTag: snapshotManifest.stagingTag, + manifest: snapshotManifest, + }; + } + + const tagsByName = new Map(); + for (const release of snapshotManifest.releases) { + tagsByName.set( + release.name, + readStagingTags( + readPackument(packuments, release.name), + release.name, + snapshotManifest.fingerprint, + ), + ); + } + const allTags = new Set([...tagsByName.values()].flatMap((tags) => [...tags])); + const completeTags = [...allTags] + .filter((tag) => [...tagsByName.values()].every((tags) => tags.has(tag))) + .sort(); + const generations = completeTags.map((tag) => { + const prefix = `dev-staged-${snapshotManifest.fingerprint}-`; + const runIdentity = tag.slice(prefix.length); + createStagingTag(snapshotManifest.fingerprint, runIdentity); + return validateRegistryGeneration(snapshotManifest, packuments, tag); + }); + + if (generations.length === 0) { + return { + action: "fresh", + stagingTag: snapshotManifest.stagingTag, + manifest: snapshotManifest, + }; + } + + const matchingDevGenerations = generations.filter((generation) => + snapshotManifest.releases.every( + (release) => + readDistTags(readPackument(packuments, release.name), release.name).dev === + generation.versionByName.get(release.name), + ), + ); + if (matchingDevGenerations.length === 1) { + const generation = matchingDevGenerations[0]; + return { + action: "skip-promoted", + stagingTag: generation.stagingTag, + manifest: hydrateRegistryManifest(snapshotManifest, generation), + }; + } + if (generations.length > 1) { + throw new Error( + `Multiple complete registry staging generations match fingerprint ${snapshotManifest.fingerprint}.`, + ); + } + + const generation = generations[0]; + const devMatches = snapshotManifest.releases.map( + (release) => + readDistTags(readPackument(packuments, release.name), release.name).dev === + generation.versionByName.get(release.name), + ); + if (devMatches.some(Boolean)) { + throw new Error( + `Registry dev tags partially promote staging generation ${generation.stagingTag}.`, + ); + } + return { + action: "reuse-staged", + stagingTag: generation.stagingTag, + manifest: hydrateRegistryManifest(snapshotManifest, generation), + }; +} + +export async function fetchPublicPackument(packageName, fetchImplementation = globalThis.fetch) { + if (typeof fetchImplementation !== "function") { + throw new Error("A fetch implementation is required to read the public npm registry."); + } + const response = await fetchImplementation( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, + { headers: { Accept: "application/json", "Cache-Control": "no-cache" } }, + ); + if (!response?.ok) { + if (response?.status === 404) { + return { "dist-tags": {}, versions: {} }; + } + throw new Error( + `Could not read public registry packument for ${packageName}: ${response?.status}.`, + ); + } + const packument = await response.json(); + if (!isRecord(packument)) { + throw new Error(`Public registry packument for ${packageName} is invalid.`); + } + return packument; +} + +function recoverySignature(recovery) { + return JSON.stringify({ + action: recovery.action, + stagingTag: recovery.stagingTag, + fingerprint: recovery.manifest.fingerprint, + sourceCommit: recovery.manifest.sourceCommit, + releases: recovery.manifest.releases.map((release) => ({ + name: release.name, + newVersion: release.newVersion, + integrity: release.integrity, + tarball: release.tarball, + })), + }); +} + +export async function recoverSnapshotManifest(snapshotManifest, options = {}) { + const maxAttempts = Number.isInteger(options.maxAttempts) + ? Math.min(Math.max(options.maxAttempts, 2), 24) + : 24; + const pollIntervalMs = + typeof options.pollIntervalMs === "number" && Number.isFinite(options.pollIntervalMs) + ? Math.min(Math.max(options.pollIntervalMs, 0), 5_000) + : 5_000; + const fetchImplementation = options.fetchImplementation ?? globalThis.fetch; + const requiredAction = options.requiredAction; + if ( + requiredAction !== undefined && + requiredAction !== "fresh" && + requiredAction !== "reuse-staged" && + requiredAction !== "skip-promoted" + ) { + throw new Error(`Unsupported required registry snapshot action: ${String(requiredAction)}.`); + } + let previousSignature; + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const packuments = Object.fromEntries( + await Promise.all( + snapshotManifest.releases.map(async (release) => [ + release.name, + await fetchPublicPackument(release.name, fetchImplementation), + ]), + ), + ); + const recovery = classifyRegistrySnapshot(snapshotManifest, packuments); + if (requiredAction && recovery.action !== requiredAction) { + previousSignature = undefined; + lastError = new Error( + `Public registry snapshot action is ${recovery.action}, expected ${requiredAction}.`, + ); + } else { + const signature = recoverySignature(recovery); + if (signature === previousSignature) return recovery; + previousSignature = signature; + lastError = undefined; + } + } catch (error) { + previousSignature = undefined; + lastError = error; + } + if (attempt + 1 < maxAttempts && pollIntervalMs > 0) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + if (lastError) throw lastError; + throw new Error("Public registry snapshot state did not stabilize."); +} + export function parseArgs(argv) { const [subcommand = "status", ...rest] = argv; const options = new Map(); for (let index = 0; index < rest.length; index += 1) { - const key = rest[index]; - const value = rest[index + 1]; - if (!key.startsWith("--")) continue; - options.set(key.slice(2), value ?? "true"); - index += 1; + const option = rest[index]; + if (typeof option !== "string" || !option.startsWith("--")) { + throw new Error(`Unexpected argument: ${String(option)}.`); + } + const equalsIndex = option.indexOf("="); + const key = option.slice(2, equalsIndex === -1 ? undefined : equalsIndex); + const value = equalsIndex === -1 ? rest[index + 1] : option.slice(equalsIndex + 1); + if (!key || typeof value !== "string" || value.length === 0 || value.startsWith("--")) { + throw new Error(`Option ${option} requires a value.`); + } + if (options.has(key)) { + throw new Error(`Option --${key} was provided more than once.`); + } + options.set(key, value); + if (equalsIndex === -1) index += 1; } return { subcommand, options }; } @@ -362,46 +851,106 @@ function ensureOption(options, key) { return value; } +function assertAllowedOptions(subcommand, options, allowed) { + const unknownOptions = [...options.keys()].filter((option) => !allowed.has(option)); + if (unknownOptions.length > 0) { + throw new Error( + `Unknown option${unknownOptions.length === 1 ? "" : "s"} for ${subcommand}: ${unknownOptions + .map((option) => `--${option}`) + .join(", ")}.`, + ); + } +} + function printJson(result) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } +export async function runCli(argv = process.argv.slice(2)) { + const { subcommand, options } = parseArgs(argv); + if (subcommand === "status") { + assertAllowedOptions( + subcommand, + options, + new Set(["status-file", "output", "root", "source-commit", "run-identity"]), + ); + const statusFile = ensureOption(options, "status-file"); + const output = options.get("output"); + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const sourceCommit = options.get("source-commit") ?? process.env.GITHUB_SHA; + const runIdentity = + options.get("run-identity") ?? + (process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}` + : undefined); + if (!sourceCommit) throw new Error("Missing required --source-commit option."); + if (!runIdentity) throw new Error("Missing required --run-identity option."); + const fingerprinted = withFingerprint( + deriveChangesetManifest(readJson(statusFile), { repoRoot: root }), + root, + ); + const manifest = { + ...fingerprinted, + sourceCommit, + stagingTag: createStagingTag(fingerprinted.fingerprint, runIdentity), + }; + if (output) writeJson(output, manifest); + return manifest; + } + if (subcommand === "stamp-metadata") { + assertAllowedOptions(subcommand, options, new Set(["manifest", "root"])); + const manifestFile = ensureOption(options, "manifest"); + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const metadata = stampSnapshotMetadata(readJson(manifestFile), root); + return { ok: true, metadata }; + } + if (subcommand === "recover-manifest") { + assertAllowedOptions(subcommand, options, new Set(["manifest", "output"])); + const manifestFile = ensureOption(options, "manifest"); + const output = ensureOption(options, "output"); + const recovery = await recoverSnapshotManifest(readJson(manifestFile)); + const manifest = { + ...recovery.manifest, + recoveryAction: recovery.action, + }; + writeJson(output, manifest); + return manifest; + } + if (subcommand === "patch-snapshot-config") { + assertAllowedOptions(subcommand, options, new Set(["root"])); + return writePatchedSnapshotConfig( + options.get("root") ? resolve(options.get("root")) : repoRoot, + ); + } + if (subcommand === "refresh-manifest") { + assertAllowedOptions(subcommand, options, new Set(["manifest", "output", "root"])); + const manifestFile = ensureOption(options, "manifest"); + const output = options.get("output") ?? manifestFile; + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const refreshed = refreshSnapshotManifestVersions(readJson(manifestFile), root); + writeJson(output, refreshed); + return refreshed; + } + if (subcommand === "rewrite-closure") { + assertAllowedOptions(subcommand, options, new Set(["manifest", "root"])); + const manifestFile = ensureOption(options, "manifest"); + const root = options.get("root") ? resolve(options.get("root")) : repoRoot; + const manifest = readJson(manifestFile); + rewriteClosureManifests(manifest, root); + const failures = assertRewrittenClosure(manifest, root); + if (failures.length > 0) { + throw new Error(`Closure rewrite failed:\n- ${failures.join("\n- ")}`); + } + return { ok: true, rewritten: manifest.releases.map((release) => release.name) }; + } + throw new Error(`Unknown subcommand: ${subcommand}`); +} + if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) { - const { subcommand, options } = parseArgs(process.argv.slice(2)); try { - if (subcommand === "status") { - const statusFile = ensureOption(options, "status-file"); - const output = options.get("output"); - const manifest = withFingerprint(deriveChangesetManifest(readJson(statusFile))); - if (output) writeJson(output, manifest); - printJson(manifest); - } else if (subcommand === "patch-snapshot-config") { - const result = writePatchedSnapshotConfig( - options.get("root") ? resolve(options.get("root")) : repoRoot, - ); - printJson(result); - } else if (subcommand === "refresh-manifest") { - const manifestFile = ensureOption(options, "manifest"); - const output = options.get("output") ?? manifestFile; - const root = options.get("root") ? resolve(options.get("root")) : repoRoot; - const refreshed = refreshSnapshotManifestVersions(readJson(manifestFile), root); - writeJson(output, refreshed); - printJson(refreshed); - } else if (subcommand === "rewrite-closure") { - const manifestFile = ensureOption(options, "manifest"); - const root = options.get("root") ? resolve(options.get("root")) : repoRoot; - const manifest = readJson(manifestFile); - rewriteClosureManifests(manifest, root); - const failures = assertRewrittenClosure(manifest, root); - if (failures.length > 0) { - throw new Error(`Closure rewrite failed:\n- ${failures.join("\n- ")}`); - } - printJson({ ok: true, rewritten: manifest.releases.map((release) => release.name) }); - } else { - throw new Error(`Unknown subcommand: ${subcommand}`); - } + printJson(await runCli()); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); + process.exitCode = 1; } }