diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dab527..164dfe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: run: node scripts/release-publish.mjs packed-consumer publish: - if: github.ref_type == 'tag' + if: github.event.deleted != true && github.ref_type == 'tag' runs-on: ubuntu-latest needs: [quality, packed-consumer] timeout-minutes: 30 @@ -176,7 +176,7 @@ jobs: run: node scripts/release-publish.mjs public-consumer github-release: - if: github.ref_type == 'tag' + if: github.event.deleted != true && github.ref_type == 'tag' runs-on: ubuntu-latest needs: [publish] timeout-minutes: 15 diff --git a/docs/launch/v1.0.0-next.0.md b/docs/launch/v1.0.0-next.0.md index 8fb9bb2..40f6adc 100644 --- a/docs/launch/v1.0.0-next.0.md +++ b/docs/launch/v1.0.0-next.0.md @@ -1,6 +1,6 @@ # Primitree 1.0.0-next.0 -Status: Unreleased. +Status: Released 2026-08-17. Primitree checks a local DTCG source against project layer and owner rules. The first public prerelease also reserves the unscoped `primitree` package name, so diff --git a/docs/plans/2026-08-10-configured-dtcg-adoption.md b/docs/plans/2026-08-10-configured-dtcg-adoption.md index 3fbfc77..6505145 100644 --- a/docs/plans/2026-08-10-configured-dtcg-adoption.md +++ b/docs/plans/2026-08-10-configured-dtcg-adoption.md @@ -130,9 +130,9 @@ Node test runner, Fumadocs, Prettier, and Biome. - [ ] **Step 7: Review and commit the release set** - Run the code scanner on both files, inspect every signal, complete - OMP-CODE-000 through OMP-CODE-021, and scan their prose. Run the root release - tests, formatter check, Biome, and `git diff --check` in a disposable copy. + Run the code and prose review checks on both files and inspect every signal. + Run the root release tests, formatter check, Biome, and `git diff --check` in + a disposable copy. Commit subject: diff --git a/docs/releasing.md b/docs/releasing.md index 496626d..1dce723 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -38,12 +38,46 @@ empty temporary npm configuration, and calls npm with blocks credential reads, OIDC discovery, and registry requests. A dry-run cannot prove npm access or provenance. The publish job provides that proof. -Bind the release attempt to the saved artifact and validate the intended tag: +Before running the tag-mode metadata check, bind the shared version and release +notes path to the verified artifact: ```bash ARTIFACT_DIR=artifacts/npm pnpm run verify:release-artifacts -VERSION=$(node -p "require('./artifacts/npm/manifest.json').version") +VERSION=$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR") +[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-next\.(0|[1-9][0-9]*))?$ ]] +RELEASE_NOTES_PATH="docs/launch/v$VERSION.md" +test -f "$RELEASE_NOTES_PATH" +``` + +The initial candidate resolves to `1.0.0-next.0`; a reviewed recovery candidate +resolves to its later `-next.N` version. Finalize the release copy in the +reviewed branch. In all six package changelogs, change the heading to +`$VERSION (YYYY-MM-DD)`. In `$RELEASE_NOTES_PATH`, replace the draft status +with `Status: Released YYYY-MM-DD.` Use the same UTC date in all seven files. + +Confirm that the committed release date is still the current UTC date: + +```bash +RELEASE_DATE=$( + sed -n \ + 's/^Status: Released \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)\.$/\1/p' \ + "$RELEASE_NOTES_PATH" +) +[[ "$RELEASE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] +CURRENT_UTC_DATE=$(date -u +%F) +test "$RELEASE_DATE" = "$CURRENT_UTC_DATE" +``` + +Stop when the UTC-date guard fails. Update all seven files through a reviewed +date-correction pull request, merge it, rebind `FINAL_COMMIT`, and rerun the +tag-mode metadata and deployment verification before tagging. A working-tree +edit after binding is not a release candidate. + +Bind the release attempt to the saved artifact and validate the intended tag: + +```bash +test "$VERSION" = "$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR")" GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata (cd "$ARTIFACT_DIR" && shasum -a 256 -c SHA256SUMS) ``` @@ -191,14 +225,20 @@ test "$( )" = true ``` -Configure the repository ruleset to block release tag updates and deletions -before you push the tag. GitHub applies immutable-release protection after -publication, so the ruleset covers the draft and asset-upload window. +Configure the active repository ruleset `Protect release tags` with no bypass +actors to block release tag updates and deletions before you push the tag. +GitHub applies immutable-release protection after publication, so the ruleset +covers the draft and asset-upload window. ### 2. Merge and bind exact main - [ ] Merge the reviewed branch and bind the release to the exact `main` commit. +Before merging, confirm that the reviewed branch contains the finalized release +copy described in **Local preflight**. Commit these release markers with the +release-finalization changes, include them in review, and merge them before +binding `FINAL_COMMIT`. Do not edit the release copy after binding the commit. + Before merging, prove that Vercel links the docs project to the intended GitHub repository and production branch. `autoAssignCustomDomains` must be `true`. Record the current production deployment before merging so the launch has one @@ -295,7 +335,8 @@ printf 'Release alias-event baseline: %s\n' "$RELEASE_EVENT_BASELINE_ID" After every required review and check passes, merge, switch to `main`, and record the exact commit. The initial release may proceed without a fabricated -version pull request because the public manifests are already at 1.0.0. +version pull request because the public manifests are already at +1.0.0-next.0. ```bash set -euo pipefail @@ -936,23 +977,35 @@ test "$(jq '[.[] | select(.name == "NPM_TOKEN")] | length' <<<"$GH_ENV_SECRETS") ### 5. Tag, publish, and create the GitHub prerelease -- [ ] Create `v1.0.0-next.0` at the final verified commit and no other commit, push the single intended tag, and approve publication. +- [ ] Create `v$VERSION` at the final verified commit and no other commit, push the single intended tag, and approve publication. The initial candidate is `v1.0.0-next.0`. -For the initial release, create one annotated prerelease tag at the recorded -commit. Never use a blanket tag push. +Create one annotated prerelease tag at the recorded commit. The verified +artifact supplies the initial or recovery version. Never use a blanket tag +push. -Update the release copy before creating the tag. In all six package changelogs, -change `1.0.0-next.0` to `1.0.0-next.0 (YYYY-MM-DD)`. In -`docs/launch/v1.0.0-next.0.md`, replace the draft status with -`Status: Released YYYY-MM-DD.` Use the same UTC date in all seven files. The -tag-mode metadata check rejects a missing date, a mismatched date, or an -`Unreleased` marker. +The committed release copy must already contain the matching UTC date in all +seven files before this step starts. Do not edit it after `FINAL_COMMIT` is +bound. The tag-mode metadata check rejects a missing date, a mismatched date, +or an `Unreleased` marker. Run `git tag -d` to clear a local tag from an earlier failed launch attempt before creating the release tag. ```bash -VERSION=1.0.0-next.0 +ARTIFACT_DIR=artifacts/npm +pnpm run verify:release-artifacts +VERSION=$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR") +[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-next\.(0|[1-9][0-9]*))?$ ]] +RELEASE_NOTES_PATH="docs/launch/v$VERSION.md" +test -f "$RELEASE_NOTES_PATH" +RELEASE_DATE=$( + sed -n \ + 's/^Status: Released \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)\.$/\1/p' \ + "$RELEASE_NOTES_PATH" +) +[[ "$RELEASE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] +CURRENT_UTC_DATE=$(date -u +%F) +test "$RELEASE_DATE" = "$CURRENT_UTC_DATE" GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata git tag -d "v$VERSION" 2>/dev/null || true git tag -a "v$VERSION" "$FINAL_COMMIT" -m "Primitree $VERSION" @@ -1018,20 +1071,21 @@ security while GitHub retains the secret. - [ ] Revoke the exact one-day bootstrap token by ID and verify its removal. -List tokens as JSON so npm returns full token IDs without exposing full token -values. Match the description and expiry recorded at creation, copy that exact -token ID into `BOOTSTRAP_TOKEN_ID`, revoke it, and list again: +List tokens as JSON so npm returns a revocation identifier without exposing the +token value. Match the description and expiry recorded at creation, copy that +identifier into `BOOTSTRAP_TOKEN_ID`, revoke it, and list again. The selector +uses `id` when present and falls back to `key` for npm JSON that omits `id`: ```bash set -euo pipefail TOKENS_BEFORE=$(npm token list --json) || return 1 2>/dev/null || exit 1 BOOTSTRAP_TOKEN_ID='' -test "$(jq --arg id "$BOOTSTRAP_TOKEN_ID" '[.[] | select(.key == $id)] | length' <<<"$TOKENS_BEFORE")" = 1 +test "$(jq --arg id "$BOOTSTRAP_TOKEN_ID" '[.[] | select((.id // .key) == $id)] | length' <<<"$TOKENS_BEFORE")" = 1 npm token revoke "$BOOTSTRAP_TOKEN_ID" --registry=https://registry.npmjs.org/ TOKENS_AFTER=$(npm token list --json) || return 1 2>/dev/null || exit 1 -test "$(jq --arg id "$BOOTSTRAP_TOKEN_ID" '[.[] | select(.key == $id)] | length' <<<"$TOKENS_AFTER")" = 0 +test "$(jq --arg id "$BOOTSTRAP_TOKEN_ID" '[.[] | select((.id // .key) == $id)] | length' <<<"$TOKENS_AFTER")" = 0 ``` Never substitute a token value for the ID. @@ -1063,12 +1117,15 @@ workflow, and environment relationship. - [ ] Verify every replacement package, the production documentation site, and the migration page. ```bash -npm view "@primitree/core@1.0.0-next.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/dtcg@1.0.0-next.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/cli@1.0.0-next.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/hooks@1.0.0-next.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/mcp@1.0.0-next.0" version --registry=https://registry.npmjs.org/ -npm view "primitree@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +ARTIFACT_DIR=artifacts/npm +pnpm run verify:release-artifacts +VERSION=$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR") +npm view "@primitree/core@$VERSION" version --registry=https://registry.npmjs.org/ +npm view "@primitree/dtcg@$VERSION" version --registry=https://registry.npmjs.org/ +npm view "@primitree/cli@$VERSION" version --registry=https://registry.npmjs.org/ +npm view "@primitree/hooks@$VERSION" version --registry=https://registry.npmjs.org/ +npm view "@primitree/mcp@$VERSION" version --registry=https://registry.npmjs.org/ +npm view "primitree@$VERSION" version --registry=https://registry.npmjs.org/ curl --fail --silent --show-error https://primitree.com/ >/dev/null curl --fail --silent --show-error https://primitree.com/docs/hooks/migration >/dev/null ``` @@ -1110,9 +1167,14 @@ Audit the workflow's registry decisions with these commands. Run recovery through GitHub Actions: ```bash -VERSION=1.0.0-next.0 -RELEASE_CHANNEL=next ARTIFACT_DIR=artifacts/npm +pnpm run verify:release-artifacts +VERSION=$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR") +RELEASE_CHANNEL=$( + node --input-type=module \ + --eval='import { releaseChannelForVersion } from "./scripts/release-config.mjs"; process.stdout.write(releaseChannelForVersion(process.argv[1]))' \ + "$VERSION" +) (cd "$ARTIFACT_DIR" && shasum -a 256 -c SHA256SUMS) npm view "@primitree/core@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/dtcg@$VERSION" version --registry=https://registry.npmjs.org @@ -1192,7 +1254,7 @@ verifyReleaseArtifacts({ artifactDirectory: path.resolve(process.env.ARTIFACT_DIR), }) NODE -VERSION=$(node -p "require('$ARTIFACT_DIR/manifest.json').version") +VERSION=$(node -p 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' "$ARTIFACT_DIR") GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata (cd "$ARTIFACT_DIR" && shasum -a 256 -c SHA256SUMS) ``` @@ -1213,47 +1275,207 @@ The helper does not delete draft assets. Inspect and remove a known failed `starter` upload by hand before rerunning the failed job. Do not replace an asset that passed verification. -### Replace a pushed wrong tag +### Recover from a pushed wrong tag + +A pushed release tag is immutable, including one that points to the wrong +commit. Never disable `Protect release tags` for recovery. Never move or delete +a pushed `v*` tag. -Deleting a tag does not stop its existing workflow run. Identify the exact old -run ID and confirm its head SHA belongs to the wrong tag before cancellation. -Cancel an active old run and wait until all jobs reach a terminal state. -Inspect a terminal run without sending another cancellation request: +Bind `OLD_RUN_ID` to the exact tag and require its exact head SHA to match the +peeled remote commit before sending a cancellation request. Reject missing, +duplicate, or malformed remote tag rows. Cancel the bound run when it remains +active and wait for every job to reach a terminal state. Re-fetch the terminal +run. Require GitHub to report a single `publish` job, a skipped or cancelled +conclusion, and no steps: ```bash +VERSION=${VERSION:-1.0.0-next.0} +[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-next\.(0|[1-9][0-9]*)$ ]] OLD_RUN_ID=123456789 -gh run view "$OLD_RUN_ID" --json headSha,headBranch,event,status,jobs -gh run cancel "$OLD_RUN_ID" -gh run watch "$OLD_RUN_ID" -gh run view "$OLD_RUN_ID" --json status,conclusion,jobs -``` +set -euo pipefail -Before continuing, require the output to prove the publish job never started. -Re-query all six versions against the release registry and require all six -commands to return npm `E404`: +OLD_RUN_JSON=$( + gh run view "$OLD_RUN_ID" \ + --json headSha,headBranch,event,status,conclusion,jobs +) +test "$(jq -r '.event' <<<"$OLD_RUN_JSON")" = push +test "$(jq -r '.headBranch' <<<"$OLD_RUN_JSON")" = "v$VERSION" +OLD_RUN_STATUS=$( + jq -er '.status | strings | select(length > 0)' <<<"$OLD_RUN_JSON" +) +OLD_RUN_HEAD_SHA=$( + jq -er \ + '.headSha | strings | select(test("^[0-9a-f]{40}$"))' \ + <<<"$OLD_RUN_JSON" +) -```bash -VERSION=1.0.0-next.0 -npm view "@primitree/core@$VERSION" version --registry=https://registry.npmjs.org -npm view "@primitree/dtcg@$VERSION" version --registry=https://registry.npmjs.org -npm view "@primitree/cli@$VERSION" version --registry=https://registry.npmjs.org -npm view "@primitree/hooks@$VERSION" version --registry=https://registry.npmjs.org -npm view "@primitree/mcp@$VERSION" version --registry=https://registry.npmjs.org -npm view "primitree@$VERSION" version --registry=https://registry.npmjs.org +TAG_REF="refs/tags/v$VERSION" +PEELED_TAG_REF="$TAG_REF^{}" +REMOTE_TAG_REFS=$( + git ls-remote --tags origin "$TAG_REF" "$PEELED_TAG_REF" +) +REMOTE_TAG_LINE_COUNT=$( + awk 'NF { count += 1 } END { print count + 0 }' <<<"$REMOTE_TAG_REFS" +) +VALID_REMOTE_TAG_LINE_COUNT=$( + awk -F '\t' \ + -v tag_ref="$TAG_REF" \ + -v peeled_ref="$PEELED_TAG_REF" ' + NF == 2 && + $1 ~ /^[0-9a-f]{40}$/ && + ($2 == tag_ref || $2 == peeled_ref) { + count += 1 + } + END { print count + 0 } + ' <<<"$REMOTE_TAG_REFS" +) +TAG_OBJECT_COUNT=$( + awk -F '\t' \ + -v ref="$TAG_REF" \ + '$2 == ref { count += 1 } END { print count + 0 }' \ + <<<"$REMOTE_TAG_REFS" +) +PEELED_TAG_COUNT=$( + awk -F '\t' \ + -v ref="$PEELED_TAG_REF" \ + '$2 == ref { count += 1 } END { print count + 0 }' \ + <<<"$REMOTE_TAG_REFS" +) +test "$REMOTE_TAG_LINE_COUNT" = 2 +test "$VALID_REMOTE_TAG_LINE_COUNT" = 2 +test "$TAG_OBJECT_COUNT" = 1 +test "$PEELED_TAG_COUNT" = 1 +REMOTE_TAG_COMMIT=$( + awk -F '\t' \ + -v ref="$PEELED_TAG_REF" \ + '$2 == ref { print $1 }' \ + <<<"$REMOTE_TAG_REFS" +) +test "$REMOTE_TAG_COMMIT" = "$OLD_RUN_HEAD_SHA" + +if [ "$OLD_RUN_STATUS" != completed ]; then + gh run cancel "$OLD_RUN_ID" + gh run watch "$OLD_RUN_ID" +fi + +TERMINAL_RUN_JSON=$( + gh run view "$OLD_RUN_ID" \ + --json headSha,headBranch,event,status,conclusion,jobs +) +test "$(jq -r '.event' <<<"$TERMINAL_RUN_JSON")" = push +test "$(jq -r '.headBranch' <<<"$TERMINAL_RUN_JSON")" = "v$VERSION" +test "$(jq -r '.headSha' <<<"$TERMINAL_RUN_JSON")" = "$OLD_RUN_HEAD_SHA" +test "$(jq -r '.status' <<<"$TERMINAL_RUN_JSON")" = completed + +PUBLISH_JOB_JSON=$( + jq -cer \ + '[.jobs[] | select(.name == "publish")] | select(length == 1) | .[0]' \ + <<<"$TERMINAL_RUN_JSON" +) +PUBLISH_CONCLUSION=$( + jq -er '.conclusion | strings | select(length > 0)' <<<"$PUBLISH_JOB_JSON" +) +case "$PUBLISH_CONCLUSION" in + skipped | cancelled) ;; + *) exit 1 ;; +esac +PUBLISH_STEP_COUNT=$(jq -er '.steps | arrays | length' <<<"$PUBLISH_JOB_JSON") +test "$PUBLISH_STEP_COUNT" = 0 ``` -After cancellation is terminal, the publish job never started, and all six -versions are absent, delete and recreate the tag at the verified commit and -push that single ref: +These machine checks prove that the publish job never started. Run this bounded +checker against all six exact versions. Each query uses the public registry, +disables retries, and has a 20-second fetch timeout. A zero exit status, stdout, +or an npm error code other than a single `E404` stops recovery. Registry output +stays in a mode-700 temporary directory and the exit trap removes it without +printing it: ```bash -git tag -d "v$VERSION" -git push origin ":refs/tags/v$VERSION" -FINAL_COMMIT=0123456789abcdef0123456789abcdef01234567 -git tag "v$VERSION" "$FINAL_COMMIT" -git push origin "refs/tags/v$VERSION" +set -euo pipefail +: "${VERSION:?Set VERSION to the wrong pushed tag version}" +NPM_REGISTRY=https://registry.npmjs.org +NPM_E404_CHECK_DIR= + +cleanup_npm_e404_check() { + test -z "$NPM_E404_CHECK_DIR" || + rm -rf -- "$NPM_E404_CHECK_DIR" +} + +trap 'exit 1' HUP INT TERM +trap cleanup_npm_e404_check EXIT +NPM_E404_CHECK_DIR=$(mktemp -d) +chmod 700 "$NPM_E404_CHECK_DIR" + +require_version_e404() { + local package_name="$1" + local package_index="$2" + local stdout_path="$NPM_E404_CHECK_DIR/$package_index.stdout" + local stderr_path="$NPM_E404_CHECK_DIR/$package_index.stderr" + local npm_status + local npm_error_codes + + : >"$stdout_path" + : >"$stderr_path" + set +e + npm view "$package_name@$VERSION" version \ + --registry="$NPM_REGISTRY" \ + --fetch-retries=0 \ + --fetch-timeout=20000 \ + --loglevel=error \ + >"$stdout_path" 2>"$stderr_path" + npm_status=$? + set -e + + test "$npm_status" -ne 0 || return 1 + test ! -s "$stdout_path" || return 1 + npm_error_codes=$( + awk ' + $1 == "npm" && ($2 == "error" || $2 == "ERR!") && $3 == "code" { + print $4 + } + ' "$stderr_path" + ) + test "$npm_error_codes" = E404 +} + +package_index=0 +for package_name in \ + "@primitree/core" \ + "@primitree/dtcg" \ + "@primitree/cli" \ + "@primitree/hooks" \ + "@primitree/mcp" \ + "primitree" +do + package_index=$((package_index + 1)) + require_version_e404 "$package_name" "$package_index" +done + +trap - HUP INT TERM +cleanup_npm_e404_check +trap - EXIT ``` -A started publish job or published package blocks tag movement. Treat uncertain -state the same way. Preserve provenance, deprecate an invalid package when -needed, and issue a new patch release. +After all six versions return npm `E404`, keep the wrong tag as an immutable +failed attempt and prepare a new candidate: + +1. In the reviewed follow-up that adds the correction changeset, change the old + launch note to + `Status: Failed release attempt YYYY-MM-DD. npm publication did not start.` + Preserve its original UTC date. In all six package changelogs, add + `The immutable Git tag exists, but npm has no matching package version.` + under the old version heading. +2. Let the Version Packages workflow create or update its version pull request. + Require the review and CI process in **Version pull requests**. Advance the + fixed package group to the next `-next.N` version. +3. Correct the release copy for the new version and UTC date in reviewed + changes. Merge them before binding the release commit. +4. Switch to the merged `main`, rebind `FINAL_COMMIT`, and repeat the + tag-mode metadata and exact deployment verification from steps 2 and 3. +5. Create and push the new version tag through step 5. Do not reuse the wrong + version or its tag. + +A started publish job or any published package blocks the unpublished-version +path. Treat uncertain registry or workflow state the same way. Preserve +provenance and use the same-byte partial-publication recovery or a later +corrective release, as applicable. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ed4d81c..8960b3f 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index fa376d1..dad2c2d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/packages/dtcg/CHANGELOG.md b/packages/dtcg/CHANGELOG.md index bfe849f..ee8e6c1 100644 --- a/packages/dtcg/CHANGELOG.md +++ b/packages/dtcg/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/packages/dtcg/tests/emitter-bounds.test.ts b/packages/dtcg/tests/emitter-bounds.test.ts index 03d24b1..6e2a764 100644 --- a/packages/dtcg/tests/emitter-bounds.test.ts +++ b/packages/dtcg/tests/emitter-bounds.test.ts @@ -148,37 +148,41 @@ describe('emitTypescript work bounds', () => { ).toThrow(TYPESCRIPT_WORK_LIMIT_MESSAGE) }) - it('caps generated TypeScript output at 20 MiB of UTF-8', () => { - const value = '界'.repeat(50_000) - const aliases = Object.fromEntries( - Array.from({ length: 140 }, (_, index) => [ - `alias-${index}`, - { $type: 'string' as const, $value: '{tokens.base}' }, - ]) - ) + it( + 'caps generated TypeScript output at 20 MiB of UTF-8', + { timeout: 10_000 }, + () => { + const value = '界'.repeat(50_000) + const aliases = Object.fromEntries( + Array.from({ length: 140 }, (_, index) => [ + `alias-${index}`, + { $type: 'string' as const, $value: '{tokens.base}' }, + ]) + ) - expect(() => - emitTypescript( - {}, - { - version: '2025.10', - sets: { - source: { - sources: [ - { - tokens: { - base: { $type: 'string', $value: value }, - ...aliases, + expect(() => + emitTypescript( + {}, + { + version: '2025.10', + sets: { + source: { + sources: [ + { + tokens: { + base: { $type: 'string', $value: value }, + ...aliases, + }, }, - }, - ], + ], + }, }, - }, - resolutionOrder: [{ $ref: '#/sets/source' }], - } - ) - ).toThrow('TypeScript output can contain at most 20 MiB.') - }) + resolutionOrder: [{ $ref: '#/sets/source' }], + } + ) + ).toThrow('TypeScript output can contain at most 20 MiB.') + } + ) }) function tailwindCollisionTree(depth: number): DTCGDocument { diff --git a/packages/dtcg/tests/output-byte-limits.test.ts b/packages/dtcg/tests/output-byte-limits.test.ts index 195d6e7..a603750 100644 --- a/packages/dtcg/tests/output-byte-limits.test.ts +++ b/packages/dtcg/tests/output-byte-limits.test.ts @@ -17,47 +17,55 @@ const outputOptions = { } as const describe('buildDTCGOutputs JSON byte limits', () => { - it('counts UTF-8 bytes across token names and text values', () => { - const value = '😀'.repeat(2_625_000) - const files = { - 'brand.tokens.json': { - first: { $type: 'fontFamily', $value: value }, - }, - 'second.tokens.json': { - second: { $type: 'fontFamily', $value: value }, - }, - } satisfies Record - - expect(() => - buildDTCGOutputs( - { - files, - resolver, - resolverFileName: 'tokens.resolver.json', + it( + 'counts UTF-8 bytes across token names and text values', + { timeout: 10_000 }, + () => { + const value = '😀'.repeat(2_625_000) + const files = { + 'brand.tokens.json': { + first: { $type: 'fontFamily', $value: value }, + }, + 'second.tokens.json': { + second: { $type: 'fontFamily', $value: value }, }, - outputOptions - ) - ).toThrow('DTCG output text can contain at most 20 MiB.') - }) + } satisfies Record - it('counts UTF-8 bytes in each serialized token file', () => { - const value = '界'.repeat(75) - const document = { - family: { - $type: 'fontFamily', - $value: Array.from({ length: 90_000 }, () => value), - }, - } satisfies DTCGDocument + expect(() => + buildDTCGOutputs( + { + files, + resolver, + resolverFileName: 'tokens.resolver.json', + }, + outputOptions + ) + ).toThrow('DTCG output text can contain at most 20 MiB.') + } + ) - expect(() => - buildDTCGOutputs( - { - files: { 'brand.tokens.json': document }, - resolver, - resolverFileName: 'tokens.resolver.json', + it( + 'counts UTF-8 bytes in each serialized token file', + { timeout: 10_000 }, + () => { + const value = '界'.repeat(75) + const document = { + family: { + $type: 'fontFamily', + $value: Array.from({ length: 90_000 }, () => value), }, - outputOptions - ) - ).toThrow('A DTCG output file can contain at most 20 MiB.') - }) + } satisfies DTCGDocument + + expect(() => + buildDTCGOutputs( + { + files: { 'brand.tokens.json': document }, + resolver, + resolverFileName: 'tokens.resolver.json', + }, + outputOptions + ) + ).toThrow('A DTCG output file can contain at most 20 MiB.') + } + ) }) diff --git a/packages/dtcg/tests/pipeline.test.ts b/packages/dtcg/tests/pipeline.test.ts index 1fde4e2..1955167 100644 --- a/packages/dtcg/tests/pipeline.test.ts +++ b/packages/dtcg/tests/pipeline.test.ts @@ -1147,7 +1147,7 @@ describe('emitCss', () => { ).not.toThrow() }) - it('stops before CSS output exceeds 20 MiB', () => { + it('stops before CSS output exceeds 20 MiB', { timeout: 10_000 }, () => { const banner = 'é'.repeat(10 * 1024 * 1024 + 1) expect(() => diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 14c4847..4716fb2 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index d30be83..2af8f8c 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/packages/primitree/CHANGELOG.md b/packages/primitree/CHANGELOG.md index c44730b..4d4e979 100644 --- a/packages/primitree/CHANGELOG.md +++ b/packages/primitree/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0-next.0 +## 1.0.0-next.0 (2026-08-17) ### Major Changes diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index f986f55..ea7b9f6 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -1898,8 +1899,15 @@ test('publishes through one pinned dual-mode helper and then verifies the public const publish = jobs.get('publish') assert.ok(publish) assert.equal(workflowDocument.jobs.publish.environment, 'npm') + assert.equal( + workflowDocument.jobs.publish.if, + "github.event.deleted != true && github.ref_type == 'tag'" + ) assert.match(publish, /needs: \[quality, packed-consumer\]/) - assert.match(publish, /if: github\.ref_type == 'tag'/) + assert.match( + publish, + /if: github\.event\.deleted != true && github\.ref_type == 'tag'/ + ) assert.match(publish, /timeout-minutes: 30/) assert.match(publish, /node-version: 24\.18\.0/) assert.match(publish, /npm install --global npm@11\.18\.0/) @@ -1946,7 +1954,10 @@ test('creates an immutable-safe GitHub Release in a separate least-privilege job const document = assertWorkflowTrustPolicy(workflow) const releaseJob = document.jobs['github-release'] assert.deepEqual(releaseJob.needs, ['publish']) - assert.equal(releaseJob.if, "github.ref_type == 'tag'") + assert.equal( + releaseJob.if, + "github.event.deleted != true && github.ref_type == 'tag'" + ) assert.equal(releaseJob['timeout-minutes'], 15) assert.deepEqual(releaseJob.permissions, { contents: 'write' }) assert.equal(Object.hasOwn(releaseJob, 'environment'), false) @@ -2005,32 +2016,143 @@ test('documents the unscoped launcher and next channel in prerelease notes', () assert.doesNotMatch(nextReleaseNotes, /—/) }) -test('dates public release copy before creating a tag', () => { +test('finalizes dated public release copy before tag validation and final binding', () => { + const localPreflight = extractMarkdownSection( + releaseRunbook, + '## Local preflight' + ) const external = extractMarkdownSection( releaseRunbook, '## External npm and GitHub steps' ) + const mergePhase = extractMarkdownSection( + external, + '### 2. Merge and bind exact main' + ) const tagPhase = extractMarkdownSection( external, '### 5. Tag, publish, and create the GitHub prerelease' ) + const normalizedPreflight = localPreflight.replace(/\s+/g, ' ') + const normalizedMergePhase = mergePhase.replace(/\s+/g, ' ') + + assert.match(normalizedPreflight, /all six package changelogs/i) + assert.match(normalizedPreflight, /`\$VERSION \(YYYY-MM-DD\)`/) + assert.match(normalizedPreflight, /`Status: Released YYYY-MM-DD\.`/) + assert.match(normalizedPreflight, /same UTC date in all seven files/i) + assertInOrder( + normalizedPreflight, + [ + 'Before running the tag-mode metadata check', + 'ARTIFACT_DIR=artifacts/npm', + 'pnpm run verify:release-artifacts', + 'VERSION=$(node -p', + 'RELEASE_NOTES_PATH="docs/launch/v$VERSION.md"', + 'all six package changelogs', + '`Status: Released YYYY-MM-DD.`', + 'same UTC date in all seven files', + 'RELEASE_DATE=$(', + 'CURRENT_UTC_DATE=$(date -u +%F)', + 'test "$RELEASE_DATE" = "$CURRENT_UTC_DATE"', + 'GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata', + ], + 'release copy and first tag-mode validation order' + ) + assert.match( + normalizedPreflight, + /Stop when the UTC-date guard fails[\s\S]*reviewed date-correction pull request[\s\S]*rebind `FINAL_COMMIT`[\s\S]*tag-mode metadata and deployment verification/i + ) + assert.doesNotMatch(normalizedPreflight, /tag is not pushed on 2026-08-17/i) - assert.match(tagPhase, /all six package changelogs/i) - assert.match(tagPhase, /`1\.0\.0-next\.0`/) - assert.match(tagPhase, /`Status: Released YYYY-MM-DD\.`/) + assert.match( + normalizedMergePhase, + /public manifests are already at 1\.0\.0-next\.0\./ + ) + assert.doesNotMatch( + normalizedMergePhase, + /public manifests are already at 1\.0\.0\./ + ) + assertInOrder( + normalizedMergePhase, + [ + 'confirm that the reviewed branch contains the finalized release copy', + 'Commit these release markers', + 'git switch main', + "FINAL_COMMIT=$(git rev-parse 'origin/main^{commit}')", + ], + 'release copy commit and final main binding order' + ) + + assert.match(tagPhase, /committed release copy/i) assert.match(tagPhase, /tag-mode metadata check rejects[\s\S]*Unreleased/i) + assert.doesNotMatch( + tagPhase, + /Update the release copy|change `1\.0\.0-next\.0`|replace the draft status/i + ) assert.match( tagPhase, /git tag -a "v\$VERSION" "\$FINAL_COMMIT" -m "Primitree \$VERSION"/ ) + assert.doesNotMatch(tagPhase, /VERSION=1\.0\.0-next\.0/) + assert.doesNotMatch(tagPhase, /docs\/launch\/v1\.0\.0-next\.0\.md/) assertInOrder( tagPhase, [ - 'Update the release copy before creating the tag.', + 'The committed release copy', + 'ARTIFACT_DIR=artifacts/npm', + 'pnpm run verify:release-artifacts', + 'VERSION=$(node -p', + 'RELEASE_NOTES_PATH="docs/launch/v$VERSION.md"', + 'RELEASE_DATE=$(', + 'CURRENT_UTC_DATE=$(date -u +%F)', + 'test "$RELEASE_DATE" = "$CURRENT_UTC_DATE"', 'GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata', 'git tag -a "v$VERSION"', ], - 'release copy and tag order' + 'committed release copy and tag order' + ) +}) + +test('derives the recovery tag and release notes from the verified artifact version', () => { + const external = extractMarkdownSection( + releaseRunbook, + '## External npm and GitHub steps' + ) + const tagPhase = extractMarkdownSubsection( + external, + '### 5. Tag, publish, and create the GitHub prerelease' + ) + const recovery = extractMarkdownSection( + releaseRunbook, + '### Recover from a pushed wrong tag' + ) + + assert.match(recovery, /next `-next\.N` version[\s\S]*through step 5/i) + assertInOrder( + tagPhase, + [ + 'pnpm run verify:release-artifacts', + 'VERSION=$(node -p', + 'RELEASE_NOTES_PATH="docs/launch/v$VERSION.md"', + 'GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata', + 'git tag -a "v$VERSION" "$FINAL_COMMIT"', + 'git push origin "refs/tags/v$VERSION"', + ], + 'recovery version and tag binding' + ) +}) + +test('reads every release version through ARTIFACT_DIR', () => { + assert.doesNotMatch( + releaseRunbook, + /require\('\.\/artifacts\/npm\/manifest\.json'\)\.version/ + ) + assert.equal( + occurrences( + releaseRunbook, + 'require(require("node:path").resolve(process.argv[1], "manifest.json")).version' + ), + 6 ) }) @@ -2148,6 +2270,7 @@ test('freezes the launch administration order and credential boundaries', () => ) assert.match(external, /BOOTSTRAP_TOKEN_ID/) assert.match(external, /exact token ID/i) + assert.equal(occurrences(external, 'select((.id // .key) == $id)'), 2) assert.match( external, /bootstrap granular access token[\s\S]*cannot authorize `npm trust`/i @@ -3097,7 +3220,7 @@ npm() { if [[ -e "$TOKEN_STATE" && "$KEEP_REVOKED_TOKEN" != true ]]; then printf '%s\n' '[]' else - printf '%s\n' '[{"key":"token-id-123","token":"npm_example"}]' + printf '[{"%s":"token-id-123","token":"npm_example"}]\n' "${'$'}{TOKEN_FIELD:-id}" fi if [[ ! -e "$TOKEN_STATE" && "$TOKEN_LIST_FAIL_WHEN" == before ]] || [[ -e "$TOKEN_STATE" && "$TOKEN_LIST_FAIL_WHEN" == after ]]; then @@ -3125,6 +3248,11 @@ npm() { `${tokenMock}\nTOKEN_STATE=${JSON.stringify(tokenState)}\nKEEP_REVOKED_TOKEN=false\nTOKEN_LIST_FAIL_WHEN=''\n${tokenBlock}` ) assert.equal(removedToken.status, 0, removedToken.stderr) + rmSync(tokenState, { force: true }) + const removedKeyToken = runBash( + `${tokenMock}\nTOKEN_STATE=${JSON.stringify(tokenState)}\nTOKEN_FIELD=key\nKEEP_REVOKED_TOKEN=false\nTOKEN_LIST_FAIL_WHEN=''\n${tokenBlock}` + ) + assert.equal(removedKeyToken.status, 0, removedKeyToken.stderr) for (const failure of ['before', 'after']) { rmSync(tokenState, { force: true }) const failedReadback = runBash( @@ -3229,12 +3357,16 @@ test('requires exact automatic main verification before legacy deprecation', () 'PRODUCTION_DEPLOYMENT_ID="$OBSERVED_DEPLOYMENT_ID"', `test "$(jq -r '.meta.githubCommitSha' <<<"$PRODUCTION_JSON")" =`, 'wait_for_verified_public_site', - 'npm view "@primitree/core@1.0.0-next.0"', + 'npm view "@primitree/core@$VERSION"', LEGACY_DEPRECATION_COMMAND, ], 'automatic production and deprecation' ) assert.doesNotMatch(external, /--prod --skip-domain|protection-bypass/) + assert.doesNotMatch( + external, + /npm view "(?:@primitree\/(?:core|dtcg|cli|hooks|mcp)|primitree)@1\.0\.0-next\.0"/ + ) assert.doesNotMatch(external, /\.meta\.gitCommitSha/) assert.match( external, @@ -3432,7 +3564,7 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = ) assert.match( tagPhase, - /^- \[ \] Create `v1\.0\.0-next\.0` at the final verified commit and no other commit, push the single intended tag, and approve publication\.$/m + /^- \[ \] Create `v\$VERSION` at the final verified commit and no other commit, push the single intended tag, and approve publication\. The initial candidate is `v1\.0\.0-next\.0`\.$/m ) assert.doesNotMatch(tagPhase, /^- \[ \] Recreate `v1\.0\.0`/m) for (const phrase of [ @@ -3441,7 +3573,7 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = 'trusted publishing for all six packages', 'protected npm and GitHub environments and rulesets', 'GitHub environment `npm`', - '`v1.0.0-next.0` at the final verified commit and no other commit', + 'initial candidate is `v1.0.0-next.0`', 'single intended tag', 'immutable releases', 'release tag updates and deletions', @@ -3471,6 +3603,10 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = assert.match(external, /administrator[\s\S]*before a tag run/i) assert.match(external, /Administration[\s\S]*read/) assert.match(external, /job-scoped `GITHUB_TOKEN`[\s\S]*cannot perform/i) + assert.match( + branchPreflight, + /active repository ruleset `Protect release tags`[\s\S]*no bypass\s+actors[\s\S]*release tag updates and deletions/i + ) assert.match( external, /ruleset[\s\S]*release tag updates and deletions[\s\S]*immutable-release protection[\s\S]*after\s+publication/i @@ -3498,7 +3634,7 @@ test('freezes same-byte recovery queries and retries in dependency order', () => assertInOrder(recovery, viewCommands, 'partial publication queries') assertInOrder(recovery, publishCommands, 'same-byte publication retries') for (const command of viewCommands) { - assert.equal(occurrences(releaseRunbook, command), 2) + assert.equal(occurrences(recovery, command), 1) } for (const command of publishCommands) { assert.equal(occurrences(releaseRunbook, command), 1) @@ -3514,6 +3650,21 @@ test('freezes same-byte recovery queries and retries in dependency order', () => assert.match(recovery, /Linux[\s\S]*sha256sum --check SHA256SUMS/i) assert.match(recovery, /Re-run failed jobs/) assert.match(recovery, /sole\s+supported selective recovery path/i) + assert.match( + recovery, + /import \{ releaseChannelForVersion \} from ['"]\.\/scripts\/release-config\.mjs['"][\s\S]*releaseChannelForVersion\(process\.argv\[1\]\)/ + ) + assert.doesNotMatch(recovery, /^RELEASE_CHANNEL=next$/m) + assertInOrder( + recovery, + [ + 'VERSION=$(node -p', + 'RELEASE_CHANNEL=$(', + 'releaseChannelForVersion(process.argv[1])', + '(cd "$ARTIFACT_DIR" && shasum -a 256 -c SHA256SUMS)', + ], + 'artifact-derived release channel' + ) assert.match( recovery, /`npm publish` commands[\s\S]*do not execute[\s\S]*from a local machine/i @@ -3555,19 +3706,26 @@ test('documents dist-tag, invalid-content, and immutable artifact recovery', () ) }) -test('preserves tag and provenance boundaries during recovery', () => { +test('preserves immutable tags and advances wrong-tag recovery to a new version', () => { const recovery = extractMarkdownSection( releaseRunbook, '## Partial publication recovery' ) - assert.match(recovery, /git tag -d "v\$VERSION"/) - assert.match(recovery, /git push origin ":refs\/tags\/v\$VERSION"/) + const wrongTag = extractMarkdownSection( + recovery, + '### Recover from a pushed wrong tag' + ) + const registryPackages = RELEASE_PUBLISH_PACKAGES.map(({ name }) => name) + assert.match( recovery, /pushed wrong tag[\s\S]*publish job never started[\s\S]*all six/i ) - assert.match(recovery, /started publish job[\s\S]*blocks tag movement/i) - assert.match(recovery, /preserve provenance/i) + assert.match( + recovery, + /started publish job[\s\S]*blocks the unpublished-version[\s\S]*path/i + ) + assert.match(recovery, /preserve[\s\S]*provenance/i) assert.match( recovery, /GitHub Release[\s\S]*re-run failed jobs[\s\S]*without moving the tag/i @@ -3576,24 +3734,158 @@ test('preserves tag and provenance boundaries during recovery', () => { recovery, /credential[\s\S]*preserve the artifact and checksums[\s\S]*retries packages[\s\S]*missing[\s\S]*verified tarballs/i ) - const registryQueries = RELEASE_PUBLISH_PACKAGES.map( - ({ name }) => - `npm view "${name}@$VERSION" version --registry=https://registry.npmjs.org` + + assert.match(wrongTag, /exact tag[\s\S]*exact head SHA/i) + assert.match( + wrongTag, + /jq -r '\.event'[\s\S]*= push[\s\S]*jq -r '\.headBranch'[\s\S]*"v\$VERSION"/ + ) + assert.match( + wrongTag, + /PUBLISH_JOB_JSON[\s\S]*select\(length == 1\)[\s\S]*skipped[\s\S]*cancelled[\s\S]*\.steps \| arrays \| length[\s\S]*PUBLISH_STEP_COUNT" = 0/i + ) + assert.match(wrongTag, /all six[\s\S]*npm `E404`/i) + assert.match( + wrongTag, + /reviewed follow-up[\s\S]*correction changeset[\s\S]*version pull request[\s\S]*next `-next\.N` version/i + ) + assert.match( + wrongTag, + /launch note[\s\S]*`Status: Failed release attempt YYYY-MM-DD\. npm publication did not start\.`/i + ) + assert.match( + wrongTag, + /all six package changelogs[\s\S]*`The immutable Git tag exists, but npm has no matching package version\.`/i + ) + assert.match( + wrongTag, + /correct the release copy[\s\S]*merge[\s\S]*rebind `FINAL_COMMIT`[\s\S]*deployment verification[\s\S]*new version tag/i + ) + assert.match( + wrongTag, + /Never disable `Protect release tags`[\s\S]*Never move or delete[\s\S]*a pushed `v\*` tag/i ) + assert.doesNotMatch(wrongTag, /enforcement=disabled/) + assert.doesNotMatch(wrongTag, /git tag -d|git push[^\n]*:refs\/tags/) + assertInOrder( - recovery, + wrongTag, [ + 'OLD_RUN_JSON=$(', + 'REMOTE_TAG_REFS=$(', + 'test "$REMOTE_TAG_COMMIT" = "$OLD_RUN_HEAD_SHA"', + 'if [ "$OLD_RUN_STATUS" != completed ]; then', 'gh run cancel "$OLD_RUN_ID"', 'gh run watch "$OLD_RUN_ID"', - 'gh run view "$OLD_RUN_ID" --json status,conclusion,jobs', + 'TERMINAL_RUN_JSON=$(', + 'test "$(jq -r \'.status\' <<<"$TERMINAL_RUN_JSON")" = completed', + 'PUBLISH_JOB_JSON=$(', + 'test "$PUBLISH_STEP_COUNT" = 0', 'publish job never started', - ...registryQueries, - 'git tag -d "v$VERSION"', - 'git push origin ":refs/tags/v$VERSION"', + 'NPM_REGISTRY=https://registry.npmjs.org', + ...registryPackages.map(name => `"${name}"`), + 'all six versions return npm `E404`', + 'correction changeset', + 'Status: Failed release attempt YYYY-MM-DD. npm publication did not start.', + 'The immutable Git tag exists, but npm has no matching package version.', + 'version pull request', + 'next `-next.N` version', + 'Correct the release copy', + 'rebind `FINAL_COMMIT`', + 'new version tag', ], - 'safe tag replacement' + 'immutable wrong-tag recovery' + ) +}) + +test('requires an array before counting publish steps', () => { + const wrongTag = extractMarkdownSection( + releaseRunbook, + '### Recover from a pushed wrong tag' + ) + const filterMatch = wrongTag.match( + /PUBLISH_STEP_COUNT=\$\(jq -er '([^']+)' <<<"\$PUBLISH_JOB_JSON"\)/ + ) + assert.ok(filterMatch) + assert.equal(filterMatch[1], '.steps | arrays | length') +}) + +test('accepts only six bounded exact-E404 registry responses', t => { + const wrongTag = extractMarkdownSection( + releaseRunbook, + '### Recover from a pushed wrong tag' + ) + const checker = extractBashBlockContaining( + wrongTag, + 'NPM_E404_CHECK_DIR=$(mktemp -d)', + 'wrong-tag exact E404 checker' + ) + const testRoot = mkdtempSync(join(tmpdir(), 'primitree-e404-check-')) + const temporaryDirectory = join(testRoot, 'tmp') + const queryLog = join(testRoot, 'queries.log') + mkdirSync(temporaryDirectory) + t.after(() => rmSync(testRoot, { recursive: true, force: true })) + + const mockNpm = String.raw` +npm() { + printf '%s\n' "$*" >>"$NPM_QUERY_LOG" + if [[ "$NPM_SCENARIO" == success && "$2" == "$NPM_TARGET" ]]; then + printf '%s\n' "$VERSION" + return 0 + fi + if [[ "$NPM_SCENARIO" == non-e404 && "$2" == "$NPM_TARGET" ]]; then + printf '%s\n' 'npm error code E401' >&2 + return 1 + fi + printf '%s\n' 'npm error code E404' >&2 + return 1 +} +` + const runScenario = (scenario, target = '') => { + writeFileSync(queryLog, '') + const result = runBash(`${mockNpm}\n${checker}`, { + env: { + ...process.env, + TMPDIR: temporaryDirectory, + VERSION: '1.0.0-next.0', + NPM_QUERY_LOG: queryLog, + NPM_SCENARIO: scenario, + NPM_TARGET: target, + }, + }) + assert.equal(result.stdout, '', `${scenario} must not expose npm stdout`) + assert.equal(result.stderr, '', `${scenario} must not expose npm stderr`) + assert.deepEqual( + readdirSync(temporaryDirectory), + [], + `${scenario} must remove registry output` + ) + return { + result, + queries: readFileSync(queryLog, 'utf8').trim().split('\n'), + } + } + + const allMissing = runScenario('e404') + assert.equal(allMissing.result.status, 0, 'six E404 responses must pass') + assert.deepEqual( + allMissing.queries, + RELEASE_PUBLISH_PACKAGES.map( + ({ name }) => + `view ${name}@1.0.0-next.0 version --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=20000 --loglevel=error` + ), + 'the checker must query all six exact package versions' + ) + + const published = runScenario('success', 'primitree@1.0.0-next.0') + assert.notEqual(published.result.status, 0, 'a published version must fail') + + const registryFailure = runScenario('non-e404', 'primitree@1.0.0-next.0') + assert.notEqual( + registryFailure.result.status, + 0, + 'a non-E404 registry failure must fail' ) - assert.match(recovery, /Cancel an active old run/i) }) test('rejects blanket tag pushes with or without an explicit remote', () => {