diff --git a/.github/workflows/dev-snapshot-release.yml b/.github/workflows/dev-snapshot-release.yml new file mode 100644 index 00000000..2c531c6b --- /dev/null +++ b/.github/workflows/dev-snapshot-release.yml @@ -0,0 +1,704 @@ +name: Dev Snapshot Release + +on: + workflow_call: + +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 }} + recovery_action: ${{ steps.manifest.outputs.recovery_action }} + 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 \ + --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 { 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=${manifest.stagingTag}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `recovery_action=${manifest.recoveryAction}\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: 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: + 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 + registry-url: "https://registry.npmjs.org" + + - 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: 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 + + - 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 + + - 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: + 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: 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 + 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 --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 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 + 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: 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 + 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'); + 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 + 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, 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 + 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: 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: + contents: read + 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 + registry-url: https://registry.npmjs.org + + - name: Download manifest artifact + uses: actions/download-artifact@v4 + with: + 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' + 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: 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: + NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} + 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 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, + }); + 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) { + 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 + + - 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: | + 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 --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" + + - name: Restore dev tags after promoted smoke failure + 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: | + 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 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; + 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 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 { + 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, + 'remove dev tag', + ); + } + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Failed to reconcile all promoted dev tags.'); + } + NODE + + 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' && (needs.plan.outputs.recovery_action == 'fresh' || needs.plan.outputs.recovery_action == 'reuse-staged') + runs-on: ubuntu-latest + environment: npm-release + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + + - 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/.github/workflows/release.yml b/.github/workflows/release.yml index f7af98e3..6230d758 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ concurrency: jobs: release: + if: github.ref == 'refs/heads/main' name: Version or Publish runs-on: ubuntu-latest permissions: @@ -133,3 +134,12 @@ 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 + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/dev-snapshot-release.yml + permissions: + contents: read + id-token: write + packages: write 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..53a9c8cd --- /dev/null +++ b/packages/core/test/dev-snapshot-bootstrap.test.ts @@ -0,0 +1,243 @@ +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 { + assertInstalledSnapshotLine, + buildValidationCommands, + derivePackageOnlyTargets, + createIsolatedValidationEnv, + 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 }); + } +}); + +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("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", "@caplets/pi", "@caplets/opencode"], + }); + expect(buildValidationCommands(manifest)).toEqual([ + "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("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: ["@fixture/peer-host"] }, + releases: [{ name: "@fixture/peer-host", newVersion: "1.0.1-dev-fixture" }], + }; + + expect(derivePackageOnlyTargets(manifest, root)).toEqual([ + { + name: "@fixture/peer-host", + peerDependencies: ["@fixture/agent-api"], + validationKind: "install-only", + }, + ]); + expect(buildValidationCommands(manifest)).toEqual([ + "npm install -g @fixture/peer-host@1.0.1-dev-fixture", + ]); + }); + + 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(assertInstalledSnapshotLine(manifest, installRoot)).toEqual([]); + }); + + 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: "@caplets/core", + version: "2.0.0-dev-fixture-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", + ), + ); + } + }); + + 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 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")}`, + ), + ]), + ); + }); + + 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: [packageName] }, + releases: [{ name: packageName, newVersion: "3.0.0-dev-fixture-20260708120000" }], + }; + writeInstalledPackage(installRoot, packageName, { + name: packageName, + version: "3.0.0-dev-fixture-20260708120000", + }); + + expect(assertInstalledSnapshotLine(manifest, installRoot)).toEqual( + expectedPeers.map( + (peerDependency) => + `${packageName} is missing peer dependency declaration for ${peerDependency}.`, + ), + ); + }); +}); 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..6257bd5a --- /dev/null +++ b/packages/core/test/dev-snapshot-release.test.ts @@ -0,0 +1,1471 @@ +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, + expandPublicReleaseClosure, + listPublicPublishablePackages, + patchSnapshotConfig, + refreshSnapshotManifestVersions, + writePatchedSnapshotConfig, +} 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 }); + } +}); + +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]!; +} + +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([ + { + 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(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", + "@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("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("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: "@fixture/core", + type: "minor", + oldVersion: "1.0.0", + newVersion: "1.1.0", + changesets: ["core-snapshot"], + }, + ], + }, + { repoRoot: root }, + ); + const refreshed = refreshSnapshotManifestVersions(snapshotManifest, root); + + 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", + }); + }); + + 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: "@fixture/unknown", + type: "patch", + oldVersion: "0.1.0", + newVersion: "0.1.1", + changesets: ["unrelated"], + }, + ], + }, + { repoRoot: root }, + ); + + expect(manifest.hasPublicReleases).toBe(false); + expect(manifest.releases).toEqual([]); + }); + + 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", () => { + 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(changesetDirectory, "snapshot.md"), + '---\n"@fixture/snapshot-target": patch\n---\n\nSnapshot fixture.\n', + ); + + expect(patchSnapshotConfig(root).snapshot).toMatchObject({ + useCalculatedVersion: true, + 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("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"); + expect(workflow).not.toContain("cancel-in-progress: true"); + expect(workflow).toContain("needs: [plan, publish, validation_complete]"); + 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("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"'); + expect(workflow).not.toContain("Stop when dry run is requested"); + expect(workflow).toContain( + "if: always() && github.ref == 'refs/heads/main' && needs.plan.outputs.has_public_releases == 'true'", + ); + expect(publish).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, + "Restore dev tags after promoted smoke 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(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( + "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( + /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("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"); + 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"), + 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", + "Create install root for promoted smoke", + "Smoke promoted caplets@dev line", + ], + }, + { + job: promote, + mutationStep: workflowStep(promote, "Restore dev tags after promoted smoke failure"), + unprivilegedSteps: [ + "Checkout", + "Setup Node", + "Download manifest artifact", + "Create install root for promoted smoke", + "Smoke promoted caplets@dev line", + ], + }, + ]; + + expect(snapshotWorkflow).toContain(" workflow_call:"); + expect(snapshotWorkflow).not.toMatch(/^ {2}push:$/m); + expect(snapshotWorkflow).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( + releaseWorkflow, + "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"); + + expect(publish).toContain("id-token: write"); + expect(promote).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(3); + + for (const job of registryJobs) { + expect(job).toMatch(npmRegistryUrl); + } + expect( + snapshotWorkflow.match(/registry-url: "?https:\/\/registry\.npmjs\.org"?/g) ?? [], + ).toHaveLength(2); + + 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"); + } + 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); + } + }); +}); diff --git a/scripts/check-dev-snapshot-bootstrap.d.mts b/scripts/check-dev-snapshot-bootstrap.d.mts new file mode 100644 index 00000000..e7c89612 --- /dev/null +++ b/scripts/check-dev-snapshot-bootstrap.d.mts @@ -0,0 +1,71 @@ +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, + parentPackageName?: string, +): string; +export function readInstalledPackageManifest( + installRoot: string, + packageName: string, + parentPackageName?: string, +): Record; +export function readInstalledPackageVersion( + installRoot: string, + packageName: string, + parentPackageName?: 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..6c11f08c --- /dev/null +++ b/scripts/check-dev-snapshot-bootstrap.mjs @@ -0,0 +1,241 @@ +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, 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, parentPackageName) { + const packageJsonPath = findInstalledPackageJson(installRoot, packageName, parentPackageName); + return JSON.parse(readFileSync(packageJsonPath, "utf8")); +} + +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( + 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]) { + 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; +} + +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") { + if (!versionByName.get(CORE_PACKAGE_NAME)) { + throw new Error(`Missing snapshot version for ${CORE_PACKAGE_NAME}.`); + } + return [ + 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) => installCommand(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..21cc5384 --- /dev/null +++ b/scripts/dev-snapshot-release.d.mts @@ -0,0 +1,180 @@ +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; + newVersion: string; + type?: string; + changesets?: string[]; + direct?: boolean; + integrity?: string; + tarball?: string; +}; + +export type ValidationMode = + | { + kind: "cli-bootstrap"; + packages: string[]; + } + | { + kind: "package-only"; + packages: string[]; + }; + +export type SnapshotManifest = { + hasPublicReleases?: boolean; + releases: ReleaseEntry[]; + 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(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[]; +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 & { fingerprint: string; changesetFiles: string[] }; +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 }; +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 new file mode 100644 index 00000000..ec7cc6fb --- /dev/null +++ b/scripts/dev-snapshot-release.mjs @@ -0,0 +1,956 @@ +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)), ".."); + +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"; +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-${fingerprint}-${runIdentity}`; +} + +const rootFingerprintFiles = [ + "package.json", + ".changeset/config.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "turbo.json", + "tsconfig.json", + "scripts/dev-snapshot-release.mjs", + "scripts/runtime-sentry-rolldown.ts", +]; + +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: [...names].sort(), + }; + } + 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 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); + 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 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()) { + if (!isTestFile(path.split(sep).at(-1))) files.push(path); + return; + } + for (const entry of readdirSync(path, { withFileTypes: true })) { + 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); + } + for (const release of manifest.releases) { + walkFiles(join(root, release.directory), files); + } + 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(); +} + +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"); + hashFile(file, hash); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function withFingerprint(manifest, root = repoRoot) { + return { + ...manifest, + fingerprint: computeRelevantFingerprint(manifest, root), + changesetFiles: collectRelevantChangesetFiles(manifest, root), + }; +} + +export function patchSnapshotConfig(root = repoRoot) { + const config = readJson(join(root, ".changeset", "config.json")); + return { + ...config, + snapshot: { + ...config.snapshot, + useCalculatedVersion: true, + prereleaseTemplate: "{tag}-{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)]), + ); + 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 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}` }; + }), + }; +} + +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; +} + +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 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 }; +} + +function ensureOption(options, key) { + const value = options.get(key); + if (!value) { + throw new Error(`Missing required --${key} option.`); + } + 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) { + try { + printJson(await runCli()); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +}