diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 02a3f078..0a2d1dfc 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -15,7 +15,7 @@ paths: # prior step, and the runnable refs are the release build tags, so # the static existence check is a false positive here. - 'file "lib/index\.js" does not exist' - # The npm publish jobs in post-green.yml and update-release.yml share a + # The npm publish jobs in update-release-pr.yml and update-release.yml share a # lane with `queue: max`, which keeps every queued publish instead of # the one pending job a lane holds by default; actionlint 1.7.12, the # newest release, does not know the key. Drop this once an actionlint diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 327d8370..b0d3c052 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -18,13 +18,17 @@ * per workflow step: * * package-commit post-green.yml GITHUB_SHA, RUN_URL (optional) - * prerelease-version post-green.yml GITHUB_SHA - * npm-verdict next post-green.yml GITHUB_SHA, NPM_REGISTRY_URL (optional) - * npm-confirm next post-green.yml GITHUB_SHA, NPM_REGISTRY_URL (optional), NPM_CONFIRM_PAUSE_MS (optional) + * anchor update-release-pr.yml GITHUB_SHA + * npm-verdict next update-release-pr.yml GITHUB_SHA, NPM_REGISTRY_URL (optional) + * npm-confirm next update-release-pr.yml GITHUB_SHA, NPM_REGISTRY_URL (optional), NPM_CONFIRM_PAUSE_MS (optional) * npm-verdict stable update-release.yml TAG, GITHUB_SHA, NPM_REGISTRY_URL (optional) * package, retag-major update-release.yml TAG, GITHUB_SHA, RUN_URL (optional, package only) - * anchor update-release-pr.yml GITHUB_SHA * boundary-check, anchor-check checks.yml (the checkout alone) + * prerelease-version by hand GITHUB_SHA (the version a commit's next publish carries) + * + * The `next` pre-release publishes when release-please creates or refreshes the release PR, which it does only when a + * releasable commit lands (release-please-config.json leaves always-update off); npm-verdict next is the guard on + * npm state that follows, never the decision to publish. * * Node builtins only: bun runs this before `bun install`. Tests: test/scripts/release-pipeline*.test.ts over release-pipeline-fixture.ts. */ @@ -42,34 +46,6 @@ const PACKAGED_PATHS = ["lib/index.js", "lib/pkg/"] as const; /** What every packaged commit must carry as non-empty regular files. */ const REQUIRED_BUILT_FILES = ["lib/index.js", "lib/pkg/index.js"] as const; const PACKAGED = "lib/index.js and lib/pkg/"; -/** - * What a `next` publish ships beyond package.json and the entries of its `files` list (read at the source: lib/pkg/, - * lib/settings.schema.json, LICENSE.md, README.md today): lib/pkg/ is tsdown's build of src/ under tsdown.config.ts - * and tsconfig.json with the versions bun.lock pins; the version is minted from the release manifest by this script; - * the publish job rewrites the manifest (npm version, npm pkg delete) before npm reads it; and .gitattributes shapes - * the bytes the checkout writes for every packed file. lib/index.js and action.yml ride the packaged commit, never - * the tarball, and src/ covers the bundle's inputs anyway. A commit that changes none of these since the source of - * the pre-release `next` names publishes nothing. - */ -export const NEXT_BUILD_INPUTS = [ - "src/", - "tsdown.config.ts", - "tsconfig.json", - "bun.lock", - MANIFEST_FILE, - ".gitattributes", - ".github/scripts/release-pipeline.ts", - ".github/workflows/post-green.yml", -] as const; -/** What under src/ the build never packs, so a change to it alone publishes nothing: tests, e2e scenarios and their - * generators, mock handlers, and the docs prose (a trailing "/" names a directory, a leading "*" a file-name suffix). */ -export const NEXT_BUILD_UNPACKED = [ - "*.test.ts", - "scenarios/", - "*.docs.yml", - "mock.ts", - "generators.ts", -] as const; const LATEST_REF = "refs/tags/latest"; const BUILD_TAG_PREFIX = "refs/tags/build/"; const BUILD_TAG = /^refs\/tags\/build\/([1-9]\d*)\.[0-9a-f]{7}$/; @@ -857,7 +833,7 @@ export function mainPosition(cwd: string, sourceSha: string): MainPosition { } /** - * The npm version a green main commit's library build publishes under the `next` dist-tag: the manifest version's + * The npm version a main commit's library build publishes under the `next` dist-tag: the manifest version's * next patch, then `main`, the source's position on main, and its short sha. That sorts above the last release, * below the next one whatever its bump, and along main: npm compares the count first, and it grows by one with * each merge (the date is for the reader; two merges on one day share it). The sha carries a `g` prefix, as git @@ -892,7 +868,7 @@ export function prereleaseVersion( export interface PrereleaseVersionOptions { cwd: string; - /** The green main commit this run judged; the checkout must be at it. */ + /** The commit whose build is published; the checkout must be at it. */ sourceSha: string; } @@ -963,7 +939,7 @@ export interface Packument { export type PublishVerdict = | { publish: true; version: string } | { publish: false; version: string; reason: string }; -/** The next channel's verdict also carries, one line each, the published pre-releases it set aside: a source the checkout cannot place. */ +/** The next channel's guard also carries, one line each, the published pre-releases it set aside: a source the checkout cannot place. */ export type NextVerdict = PublishVerdict & { notices: string[] }; /** A published pre-release whose source is a strict descendant of this run's: newer on main, whatever its numbers say. */ @@ -974,20 +950,14 @@ interface Descendant { } /** - * Where a pre-release's source stands to this run's, once its sha resolves: the run's own commit, an ancestor on the - * source's first-parent chain (a main commit), an ancestor off that chain (inside a merged branch), a strict + * Where a pre-release's source stands to this run's, once its sha resolves: the run's own commit, an ancestor, a strict * descendant, or one on neither side of the source (off its line of main). */ -type Placement = "own" | "ancestor" | "merged" | "descendant" | "unrelated"; +type Placement = "own" | "ancestor" | "descendant" | "unrelated"; /** A source placed, or one the checkout lacks (a sha it never fetched, or a short one naming several objects). */ type Placed = { sha: string; placement: Placement } | { sha: null; placement: "unresolved" }; -function placeSource( - cwd: string, - sourceSha: string, - sha7: string, - onMain: (sha: string) => boolean, -): Placed { +function placeSource(cwd: string, sourceSha: string, sha7: string): Placed { const sha = resolveCommit(cwd, sha7); if (sha === null) { return { sha, placement: "unresolved" }; @@ -1000,74 +970,36 @@ function placeSource( if (descends(sourceSha, sha)) { return { sha, placement: "descendant" }; } - if (!descends(sha, sourceSha)) { - return { sha, placement: "unrelated" }; - } - return { sha, placement: onMain(sha) ? "ancestor" : "merged" }; -} - -/** Whether a sha is on the source's first-parent chain, main's own history: an ancestor off it came in with a merged - * branch. The chain is listed once, on the first ancestor asked about. */ -function mainChainOf(cwd: string, sourceSha: string): (sha: string) => boolean { - let chain: Set | null = null; - return (sha) => { - chain ??= new Set(git(cwd, "rev-list", "--first-parent", sourceSha).split("\n")); - return chain.has(sha); - }; -} - -/** The build `next` names, placed: the base the shipped-surface comparison starts from. */ -interface NextBase { - version: string; - sha: string; - placement: Placement; + return { sha, placement: descends(sha, sourceSha) ? "ancestor" : "unrelated" }; } /** * The published pre-releases placed against this run's source by ancestry: the descendants, the one furthest along * main, and a notice for each the checkout cannot place. The version `next` names is placed with them, whether or not - * the record lists it; null when it names none, carries no sha, or the checkout lacks its commit. + * the record lists it. */ function placePublished( cwd: string, sourceSha: string, packument: Packument, -): { - descendants: Descendant[]; - newest: Descendant | null; - notices: string[]; - next: NextBase | null; -} { +): { descendants: Descendant[]; newest: Descendant | null; notices: string[] } { const descendants: Descendant[] = []; const notices: string[] = []; let newest: Descendant | null = null; const nextVersion = packument["dist-tags"].next; - let next: NextBase | null = null; const versions = new Set(Object.keys(packument.versions)); if (nextVersion !== undefined) { versions.add(nextVersion); } - const onMain = mainChainOf(cwd, sourceSha); for (const version of versions) { const sha7 = sha7Of(version); if (sha7 === null) { continue; } - const placed = placeSource(cwd, sourceSha, sha7, onMain); - const isNext = version === nextVersion; - const noBase = isNext - ? ", and next names it, so there is no build to compare the shipped surface against: every change counts as shipped" - : ""; + const placed = placeSource(cwd, sourceSha, sha7); if (placed.sha === null) { - notices.push( - `${version} names ${sha7}, which is no commit in this checkout; ignored${noBase}`, - ); - continue; - } - if (isNext) { - next = { version, sha: placed.sha, placement: placed.placement }; - } - if (placed.placement === "descendant") { + notices.push(`${version} names ${sha7}, which is no commit in this checkout; ignored`); + } else if (placed.placement === "descendant") { descendants.push({ version, sha: placed.sha }); if ( newest === null || @@ -1077,110 +1009,19 @@ function placePublished( } } else if (placed.placement === "unrelated") { notices.push( - `${version} names ${sha7}, which is neither an ancestor nor a descendant of ${sourceSha.slice(0, 7)} on main; ignored${noBase}`, + `${version} names ${sha7}, which is neither an ancestor nor a descendant of ${sourceSha.slice(0, 7)} on main; ignored`, ); } } - return { descendants, newest, notices, next }; + return { descendants, newest, notices }; } -/** A `files` entry names a file, or a directory with or without the trailing slash; a glob would need npm's packlist. */ -const FILES_GLOB = /[*?[\]{}!]/; - /** - * The tree paths a `next` publish ships, read at the source: package.json itself (npm always packs the manifest), the - * entries of its `files` list, and the build's inputs. Null, with the reason, when the list is not plain paths: a - * manifest without one packs the whole tree, and a glob is not matched here; either way every change counts as shipped. - */ -function shippedPaths( - cwd: string, - sourceSha: string, -): { paths: string[]; unreadable: null } | { paths: null; unreadable: string } { - const pkg = JSON.parse(git(cwd, "show", `${sourceSha}:${MANIFEST}`)) as { files?: unknown }; - const files = pkg.files; - if (!Array.isArray(files) || !files.every((entry) => typeof entry === "string")) { - return { - paths: null, - unreadable: `${MANIFEST} at ${sourceSha.slice(0, 7)} has no files list, so npm packs the whole tree`, - }; - } - const glob = files.find((entry) => FILES_GLOB.test(entry)); - if (glob !== undefined) { - return { - paths: null, - unreadable: `${MANIFEST} at ${sourceSha.slice(0, 7)} lists ${JSON.stringify(glob)} in files, a pattern this comparison does not match`, - }; - } - return { paths: [MANIFEST, ...files, ...NEXT_BUILD_INPUTS], unreadable: null }; -} - -/** The root files npm packs whatever `files` says, as npm-packlist spells them: `/readme{,.*[^~$]}`, `/copying{,.*[^~$]}`, - * `/licen[cs]e{,.*[^~$]}`, any case; a glob star crosses no separator, and an editor backup suffix is left out. */ -const ALWAYS_PACKED = /^(?:readme|copying|licen[cs]e)(?:\.[^/]*[^~$/])?$/i; -const ALWAYS_PACKED_TEXT = "or a root README, COPYING, or LICENSE"; -const UNPACKED_TEXT = `(under src/, ${NEXT_BUILD_UNPACKED.join(", ")} are never packed and do not count)`; - -/** Whether a path under src/ is one the build never packs. */ -function unpacked(path: string): boolean { - const segments = path.split("/"); - if (segments[0] !== "src") { - return false; - } - const name = segments[segments.length - 1] ?? ""; - return NEXT_BUILD_UNPACKED.some((entry) => - entry.endsWith("/") - ? segments.slice(1, -1).includes(entry.slice(0, -1)) - : entry.startsWith("*") - ? name.endsWith(entry.slice(1)) - : name === entry, - ); -} - -/** Whether `path` is one of `shipped`, lies under one of its directories, or is a root file npm always packs; a path - * under src/ the build never packs is not, whatever it lies under. */ -function ships(shipped: string[], path: string): boolean { - if (unpacked(path)) { - return false; - } - return ( - ALWAYS_PACKED.test(path) || - shipped.some((entry) => { - const dir = entry.replace(/\/$/, ""); - return path === dir || path.startsWith(`${dir}/`); - }) - ); -} - -/** - * The shipped paths some merge to main after `base`, up to the source, touches: each first-parent step's own diff, not - * the two trees'. A change one merge makes and the next reverts leaves the trees equal, and the runs for the two can - * take the lane in either order: judged by trees, the revert's run would skip and the change's run would then publish - * what main no longer holds. `--no-renames` lists a moved file under both names, so one moved out of the surface still - * counts as a change to it. - */ -function shippedChanges(cwd: string, base: string, sourceSha: string, shipped: string[]): string[] { - const touched = git( - cwd, - "log", - "--first-parent", - "--diff-merges=first-parent", - "--format=", - "--name-only", - "--no-renames", - `${base}..${sourceSha}`, - ) - .split("\n") - .filter((path) => path !== "" && ships(shipped, path)); - return [...new Set(touched)].sort(); -} - -/** - * Every published pre-release is placed by its source's ancestry, so a run for an older commit publishes nothing - * once a newer commit's pre-release is on the registry, whatever order the two runs finished in (`npm publish --tag - * next` moves next to whatever it publishes). Then the build `next` names is the base: when no merge to main after it, - * up to this source, touched a shipped path, the run publishes nothing, so a docs-only merge mints no version. A - * base the checkout cannot place, or a files list it cannot read as paths, is no reason to hold a build back: the run - * publishes, saying why in a notice. Null is a package the registry has never seen: the first publish goes. + * The pre-publish guard on npm state, not a decision about whether to publish (the release-PR refresh that runs the + * publish job made that): every published pre-release is placed by its source's ancestry, so a rerun of a run that + * already published, or a stale retry once a newer commit's pre-release is on the registry, publishes nothing, + * whatever order the runs finished in (`npm publish --tag next` moves next to whatever it publishes). A source the + * checkout cannot place is set aside with a notice. Null is a package the registry has never seen: the first publish goes. */ export function nextPublishVerdict( cwd: string, @@ -1189,15 +1030,10 @@ export function nextPublishVerdict( packument: Packument | null, ): NextVerdict { if (packument === null) { - return { - publish: true, - version, - notices: [ - "the registry holds no record of this package yet, so there is no build to compare the shipped surface against; publishing", - ], - }; + return { publish: true, version, notices: [] }; } - if (version in packument.versions) { + // A dist-tag names a version the registry holds, so next naming this one is the same rerun as the record listing it. + if (version in packument.versions || packument["dist-tags"].next === version) { return { publish: false, version, @@ -1205,57 +1041,22 @@ export function nextPublishVerdict( notices: [], }; } - const { newest: newer, notices, next } = placePublished(cwd, sourceSha, packument); - if (newer !== null) { + const { newest, notices } = placePublished(cwd, sourceSha, packument); + if (newest !== null) { return { publish: false, version, - reason: `the registry already holds ${newer.version}, whose source ${newer.sha.slice(0, 7)} is a descendant of ${sourceSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`, + reason: `the registry already holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} is a descendant of ${sourceSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`, notices, }; } - const nextVersion = packument["dist-tags"].next; - if (nextVersion === undefined) { - notices.push( - "the registry's next names no version, so there is no build to compare the shipped surface against; publishing", - ); - } else if (sha7Of(nextVersion) === null) { - notices.push( - `the registry's next is ${nextVersion}, which names no source sha, so there is no build to compare the shipped surface against; publishing`, - ); - } else if (next !== null && next.placement === "merged") { - notices.push( - `the registry's next is ${next.version}, whose source ${next.sha.slice(0, 7)} is inside a branch merged to main, not a main commit, so the merges since it cannot be walked; publishing`, - ); - } else if (next !== null && (next.placement === "ancestor" || next.placement === "own")) { - const shipped = shippedPaths(cwd, sourceSha); - if (shipped.paths === null) { - notices.push( - `${shipped.unreadable}; every change since ${next.version} counts as shipped, publishing`, - ); - } else { - const changed = shippedChanges(cwd, next.sha, sourceSha, shipped.paths); - if (changed.length === 0) { - return { - publish: false, - version, - reason: `no shipped file changed since ${next.version} (source ${next.sha.slice(0, 7)}): no merge to main in ${next.sha.slice(0, 7)}..${sourceSha.slice(0, 7)} touches ${shipped.paths.join(", ")}, ${ALWAYS_PACKED_TEXT} ${UNPACKED_TEXT}`, - notices, - }; - } - notices.push( - `${changed.length} shipped ${changed.length === 1 ? "file" : "files"} changed since ${next.version} (source ${next.sha.slice(0, 7)}): ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}`, - ); - } - } return { publish: true, version, notices }; } /** * Only `latest` is consulted: a plain `npm publish` moves latest and leaves - * next alone, and a release is meant to sort below the pre-releases that - * followed its merge (the merge commit's own run publishes the next patch's - * pre-release before this job runs). + * next alone. A release sorts above the pre-releases published before its + * merge; the next release PR's first refresh publishes the one above it. */ export function stablePublishVerdict(version: string, packument: Packument | null): PublishVerdict { if (packument === null) { @@ -1389,9 +1190,8 @@ export interface NpmConfirmOptions { * lane until the next holder's verdict can see this publish (npm makes a publish readable asynchronously; a verdict * read in that gap would move next back). Once it shows, and a descendant's pre-release is on the record, next * must name a descendant's, or this stale run moved it back. A drift is reported, not repaired: trusted publishing (OIDC) - * authenticates `npm publish` alone, not `npm dist-tag add` (npm/cli#8547); the next green push that changes the - * shipped surface publishes and moves next forward, and a rerun of the reporting run publishes nothing and passes, so - * a blocked release can go on. + * authenticates `npm publish` alone, not `npm dist-tag add` (npm/cli#8547); the next release-PR refresh publishes and + * moves next forward, and a rerun of the reporting run publishes nothing and passes. */ export async function npmConfirm(options: NpmConfirmOptions): Promise { const { cwd, sourceSha, registry, attempts, delayMs } = options; @@ -1422,8 +1222,8 @@ export async function npmConfirm(options: NpmConfirmOptions): Promise the caller granted none (a fork, a ceiling change); step one skips the rest - # shared lane -> update-release.yml's publish takes the same lane; no caller holds it - # queue: max -> a lane keeps ONE pending job by default; a dropped job fails the post-green call - # verdict compares the record -> the lane serializes but does not order; GitHub promises no queue order - # confirm holds the lane -> a job ends once the registry shows its publish, so the next holder's verdict sees it - publish-next: - if: github.repository == 'Vivswan/github-settings-as-code' - concurrency: - group: npm-publish - queue: max - cancel-in-progress: false - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check the caller grants an OIDC token - id: oidc - run: | - if [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then - echo "proceed=true" >> "$GITHUB_OUTPUT" - else - echo "::warning::this run has no OIDC token (the post-green call in the managed ci.yml grants no id-token: write);" \ - "the library pre-release was not published to npm." \ - "Add id-token: write to that call's permissions in Vivswan/repo-platform to publish every green push to @next." - echo "proceed=false" >> "$GITHUB_OUTPUT" - fi - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: steps.oidc.outputs.proceed == 'true' - with: - ref: ${{ inputs.sha }} - # The version counts main's commits under this one and the verdict places published sources by ancestry; a shallow checkout can do neither. - fetch-depth: 0 - persist-credentials: false - - uses: ./.github/actions/setup - if: steps.oidc.outputs.proceed == 'true' - # registry-url writes the .npmrc npm publishes through; with no - # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's - # OIDC exchange replaces. - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.oidc.outputs.proceed == 'true' - with: - node-version: 24 - registry-url: https://registry.npmjs.org - # Trusted publishing (and its provenance) exists from npm 11.5.1 on; an - # older bundled npm is upgraded once, then held to the floor. - - name: Require an npm that publishes through OIDC - if: steps.oidc.outputs.proceed == 'true' - run: | - floor=11.5.1 - below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; } - if below_floor; then - npm install -g npm@latest - fi - if below_floor; then - echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer." - exit 1 - fi - - name: Build the library - if: steps.oidc.outputs.proceed == 'true' - run: bun run build:lib - # scripts.prepare is dropped from the published manifest: it installs - # lefthook, a devDependency the tarball does not carry, and npm blocks - # install scripts from a provenance-attested package anyway. - # The verdict reads the registry's origin record (the CDN's copy lags a - # publish by up to 300 s) and places every published pre-release by the - # ancestry of the source sha it names: a version already there (a rerun - # of this run) or a pre-release of a descendant of this commit (a stale - # retry after newer runs published) publishes nothing, so next never - # moves back on what the run can see. It then walks the merges to main - # after the source of the build next names, up to this commit, over what - # the tarball ships and what builds it (package.json's files list beside - # the script's NEXT_BUILD_INPUTS): when none touched any of it, docs - # alone, this run publishes nothing either; a base the checkout cannot - # place publishes with a notice saying why. A registry error stops the job instead of - # publishing blind. npm's provenance names the commit in GITHUB_SHA, so - # the source is passed in. - - name: Publish the pre-release under the next dist-tag - id: publish - if: steps.oidc.outputs.proceed == 'true' - env: - SOURCE_SHA: ${{ inputs.sha }} - run: | - verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict next)" - case "$verdict" in - publish\ *) - npm version "${verdict#publish }" --no-git-tag-version - npm pkg delete scripts.prepare - GITHUB_SHA="$SOURCE_SHA" npm publish --tag next - echo "published=true" >> "$GITHUB_OUTPUT" ;; - skip\ *) echo "::notice::${verdict#skip }" ;; - *) - echo "unexpected npm-verdict output: $verdict" - echo "::error::npm-verdict printed neither publish nor skip; see the line above." - exit 1 ;; - esac - # npm makes a publish readable asynchronously. This step holds the lane - # until the record shows the version (15 reads, 20 s apart), so the next - # holder's verdict sees it; a record that never shows it warns. It then - # fails the job if the record holds a descendant's pre-release and next - # names none: OIDC authenticates npm publish alone, not npm dist-tag add, - # so no tag is moved here and the next green push that changes the - # shipped surface moves next forward instead. A rerun of the failed run publishes nothing, skips this step, - # and passes. - - name: Confirm the registry shows the publish and next moved forward - if: steps.publish.outputs.published == 'true' - env: - SOURCE_SHA: ${{ inputs.sha }} - run: | - confirmed="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-confirm next)" - case "$confirmed" in - settled\ *) echo "::notice::${confirmed#settled }" ;; - unsettled\ *) echo "::warning::${confirmed#unsettled }" ;; - behind\ *) - echo "::error::${confirmed#behind }" - exit 1 ;; - *) - echo "unexpected npm-confirm output: $confirmed" - echo "::error::npm-confirm printed neither settled, unsettled, nor behind; see the line above." - exit 1 ;; - esac diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 5dd36652..76ac3baf 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -1,7 +1,15 @@ # The release-PR hook of the release pipeline: repo-owned (a generated-once starter, never overwritten by sync), # called by the managed ci.yml whenever release-please creates or refreshes the release PR, independently of any -# release cut. Its one job is the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline.ts): called -# in the run that refreshed the PR, GITHUB_SHA is exactly the main head the refresh was built on. +# release cut. Inside a called workflow github.sha is the caller's: the push to main that release-please judged, the +# head the refresh was built on. Two independent jobs share that sha and nothing else: +# +# anchor -> the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline.ts) inside the PR branch +# publish-next -> the library pre-release to npm under the `next` dist-tag +# +# The publish lives here because release-please's refresh IS the releasable signal: release-please-config.json leaves +# always-update off, so the PR is refreshed only when the release notes change, i.e. when a commit of a type its +# changelog shows lands (feat, fix, perf, revert, or a breaking marker). A push of hidden types alone (test, docs, chore, +# build, ci, refactor) refreshes nothing and publishes nothing; no check of this repository's own decides a publish. # # The anchor is pushed with the default github.token, which starts no workflows, so the PR's checks do not re-run # on the anchored head; anchor-check's failure message says to close and reopen the PR. @@ -33,3 +41,126 @@ jobs: install: "false" - name: Anchor the boundary inside the release PR branch run: bun .github/scripts/release-pipeline.ts anchor + + # The npm-publish lane exists because the registry has no compare-and-set: the pre-publish guard's read still holds + # when its publish lands, whichever publisher (this job, update-release.yml's) took the lane before it. + # no ACTIONS_ID_TOKEN_REQUEST_URL -> the caller granted none (a fork, a ceiling change); step one skips the rest + # shared lane -> update-release.yml's publish takes the same lane; no caller holds it + # queue: max -> a lane keeps ONE pending job by default; a dropped job fails the call + # guard reads the record -> the lane serializes but does not order; GitHub promises no queue order + # confirm holds the lane -> a job ends once the registry shows its publish, so the next holder's guard sees it + # No permissions key: a job asking above the caller's ceiling fails the whole call, anchor included, so the OIDC + # grant arrives by inheritance from the id-token: write the managed ci.yml gives this call, and a ceiling without + # it reaches step one's warn-and-skip instead of a validation failure. + publish-next: + if: github.repository == 'Vivswan/github-settings-as-code' + concurrency: + group: npm-publish + queue: max + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check the caller grants an OIDC token + id: oidc + run: | + if [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then + echo "proceed=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::this run has no OIDC token (the update-release-pr call in the managed ci.yml grants no id-token: write);" \ + "the library pre-release was not published to npm." \ + "Add id-token: write to that call's permissions in Vivswan/repo-platform to publish every release-PR refresh to @next." + echo "proceed=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.oidc.outputs.proceed == 'true' + with: + ref: ${{ github.sha }} + # The version counts main's commits under this one and the guard places published sources by ancestry; a shallow checkout can do neither. + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/setup + if: steps.oidc.outputs.proceed == 'true' + # registry-url writes the .npmrc npm publishes through; with no + # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's + # OIDC exchange replaces. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.oidc.outputs.proceed == 'true' + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # Trusted publishing (and its provenance) exists from npm 11.5.1 on; an + # older bundled npm is upgraded once, then held to the floor. + - name: Require an npm that publishes through OIDC + if: steps.oidc.outputs.proceed == 'true' + run: | + floor=11.5.1 + below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; } + if below_floor; then + npm install -g npm@latest + fi + if below_floor; then + echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer." + exit 1 + fi + - name: Build the library + if: steps.oidc.outputs.proceed == 'true' + run: bun run build:lib + # scripts.prepare is dropped from the published manifest: it installs + # lefthook, a devDependency the tarball does not carry, and npm blocks + # install scripts from a provenance-attested package anyway. + # The guard is a safety on npm state, not a decision about whether to + # publish (the refresh that called this workflow decided that): it reads + # the registry's origin record (the CDN's copy lags a publish by up to + # 300 s) and places every published pre-release by the ancestry of the + # source sha it names. A version already there (a rerun of this run) or + # a pre-release of a descendant of this commit (a stale retry after + # newer runs published) publishes nothing, so next never moves back on + # what the run can see; a source the checkout cannot place is ignored + # with a notice. A registry error stops the job instead of publishing + # blind. npm's provenance names the commit in GITHUB_SHA, so the source + # is passed in. + - name: Publish the pre-release under the next dist-tag + id: publish + if: steps.oidc.outputs.proceed == 'true' + env: + SOURCE_SHA: ${{ github.sha }} + run: | + verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict next)" + case "$verdict" in + publish\ *) + npm version "${verdict#publish }" --no-git-tag-version + npm pkg delete scripts.prepare + GITHUB_SHA="$SOURCE_SHA" npm publish --tag next + echo "published=true" >> "$GITHUB_OUTPUT" ;; + skip\ *) echo "::notice::${verdict#skip }" ;; + *) + echo "unexpected npm-verdict output: $verdict" + echo "::error::npm-verdict printed neither publish nor skip; see the line above." + exit 1 ;; + esac + # npm makes a publish readable asynchronously. This step holds the lane + # until the record shows the version (15 reads, 20 s apart), so the next + # holder's guard sees it; a record that never shows it warns. It then + # fails the job if the record holds a descendant's pre-release and next + # names none: OIDC authenticates npm publish alone, not npm dist-tag add, + # so no tag is moved here and the next release-PR refresh moves next + # forward instead. A rerun of the failed run publishes nothing, skips this step, + # and passes. + - name: Confirm the registry shows the publish and next moved forward + if: steps.publish.outputs.published == 'true' + env: + SOURCE_SHA: ${{ github.sha }} + run: | + confirmed="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-confirm next)" + case "$confirmed" in + settled\ *) echo "::notice::${confirmed#settled }" ;; + unsettled\ *) echo "::warning::${confirmed#unsettled }" ;; + behind\ *) + echo "::error::${confirmed#behind }" + exit 1 ;; + *) + echo "unexpected npm-confirm output: $confirmed" + echo "::error::npm-confirm printed neither settled, unsettled, nor behind; see the line above." + exit 1 ;; + esac diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index a20cbf06..dc216cca 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -123,7 +123,7 @@ jobs: # token, provenance attached by npm. Behind verify-release, so nothing reaches the registry before the draft's # assets check out. Until the owner's one-time npm setup exists this job fails and holds the draft; a rerun converges. # package-release sits in needs for its output alone (a job reads outputs only from its direct dependencies). - # The lane is the one post-green.yml's publish-next takes: the registry has no compare-and-set, so the two + # The lane is the one update-release-pr.yml's publish-next takes: the registry has no compare-and-set, so the two # publishers never overlap and a verdict still holds when its publish lands; queue: max keeps every queued # publish (a lane drops all but one pending job by default). publish-npm: @@ -167,7 +167,7 @@ jobs: - name: Build the library run: bun run build:lib # scripts.prepare is dropped from the published manifest, as in - # post-green.yml's publish-next. + # update-release-pr.yml's publish-next. # The verdict holds the built package.json to the tag and reads the # registry: a version already there (a rerun) or a latest that is a # newer release (an older release's job rerun after a newer release) diff --git a/README.md b/README.md index 0d135803..7afc47c0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node 22.14 or newer): validate, merge, check, and apply from your own code. -- `npm install @vivswan/github-settings-as-code` installs the released version; `@next` installs the newest green `main` commit as a pre-release. The [library reference](docs/reference/library.md) has the API by group and the versioning rules. +- `npm install @vivswan/github-settings-as-code` installs the released version; `@next` installs the pre-release of the `main` commit release-please last refreshed the release PR on ([Versioning](docs/reference/library.md#versioning)). The [library reference](docs/reference/library.md) has the API by group and the versioning rules. - `npx @vivswan/github-settings-as-code@next check --repository o/r --settings-file .github/settings.yml` runs the action's check from a terminal. The [command line guide](docs/start/cli.md) has every command. ## Docs diff --git a/docs/reference/library.md b/docs/reference/library.md index 0911890d..bbe466ce 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -12,7 +12,7 @@ Three ways in, one package: ```bash npm install @vivswan/github-settings-as-code # the released version (npm dist-tag latest) -npm install @vivswan/github-settings-as-code@next # the newest green main commit that changed the library, a pre-release +npm install @vivswan/github-settings-as-code@next # the pre-release of the main commit the release PR was last refreshed on npm install github:Vivswan/github-settings-as-code# # one packaged commit: a build tag's or a release tag's ``` @@ -385,7 +385,7 @@ The package and the action share one version, the one in `.release-please-manife | npm dist-tag | Publishes on | Version | Install | |---|---|---|---| -| `next` | Every green push to `main` that changes what the tarball ships or builds it | The manifest's next patch, then `-main...g`: `2.0.1-main.446.20260913.g95d081d` | `npm install @vivswan/github-settings-as-code@next` | +| `next` | Every refresh of the release PR: release-please creates or refreshes it when a releasable commit (feat, fix, perf, revert, or a breaking marker) lands on `main` | The manifest's next patch, then `-main...g`: `2.0.1-main.446.20260913.g95d081d` | `npm install @vivswan/github-settings-as-code@next` | | `latest` | Every release cut | The released version: `2.1.0`. Until the first release it names a `next` pre-release: a packument always carries `latest` (npm/registry REGISTRY-API.md, "dist-tags: an object with at least one key, latest"), so the first publish took it whatever `--tag` asked for, and the first stable release moves it | `npm install @vivswan/github-settings-as-code` | | none | Every green push to `main` (`build/.`, the ten newest kept) and every release tag | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | @@ -397,23 +397,21 @@ The npm dist-tag `latest` is not the git tag `latest`: the git tag names the pac - `g95d081d`: its short sha; the `g` marks a git object id, as `git describe` writes it (a bare all-digit sha such as `0123456` would be read by npm as the number 123456). - Two runs for one commit mint the same string, whenever they run. - A pre-release sorts above the last release and below the next one whatever its bump, and later commits on main sort later: npm compares the count first, and it grows by one with each merge (the date is for the reader; two merges on one day share it). - - The one pre-release that sorts above a release is the release merge commit's own: its manifest already carries the new version. - - So `next` moves to `2.1.1-main...g` in the same run that publishes `2.1.0`, the order the two channels are meant to keep (next above latest). -- `next` moves only to a descendant. The publish verdict resolves the sha in every published `-main.` version in its full checkout and places it against this run's commit: + - A release merge refreshes no release PR, so it publishes no pre-release: after `2.1.0` publishes, `next` names the last pre-release below it (`2.0.1-main...`) until the first releasable commit of the next cycle opens the next release PR, whose refresh publishes `2.1.1-main...g`. `npm install ...@next` resolves the dist-tag whatever the ordering. +- `next` moves only to a descendant. The pre-publish guard resolves the sha in every published `-main.` version in its full checkout and places it against this run's commit: - a pre-release whose source is a strict descendant: this run is stale and publishes nothing, whatever order the two runs finished in; - a sha the checkout cannot resolve, or one that is neither ancestor nor descendant (off main): ignored, with a notice in the log; - the run's own version already on the registry (a rerun of that commit): publishes nothing. -- A `next` build appears when the shipped surface changed, not on every merge: the verdict walks the merges to main after the source of the build `next` names, up to this commit, and skips when none touched a shipped path. A docs-only merge publishes nothing, with a notice naming the base version and its source. - - The shipped surface: `package.json`, its `files` list, `src/`, the build config, `bun.lock`, the release manifest, and the publish recipe (`NEXT_BUILD_INPUTS` in the pipeline script derives it). - - Under `src/`, what the build never packs does not count: `*.test.ts`, `scenarios/`, `*.docs.yml`, `mock.ts`, and `generators.ts` (`NEXT_BUILD_UNPACKED` lists them). A merge that touches only those publishes nothing. - - A base the checkout cannot place (no `next` yet, a release under it, a sha the checkout lacks, off main, or inside a merged branch), or a `files` list that is not plain paths, publishes and says why: nothing to compare against is no reason to hold a build back. +- A `next` build appears when release-please creates or refreshes the release PR, and nowhere else: the release hook `update-release-pr.yml` publishes in the run that refreshed it, from the commit the refresh was built on. + - release-please refreshes the PR only when the release notes change (`always-update` is off in `release-please-config.json`), which a releasable commit does: feat, fix, perf, revert, or a breaking marker. A merge of hidden types alone (chore, build, ci, test, docs, refactor) refreshes nothing and publishes nothing, a dependency bump under `build(deps)` included. + - No check of the repository's own decides a publish. The guard above is a safety on registry state: a rerun of the run, or a stale retry after a newer commit published, publishes nothing, so `next` never moves backward; a published source the checkout cannot place is ignored with a notice. - `latest` publishes nothing when the version is already there or when the dist-tag `latest` names a newer release (a rerun of an older release's job); it does not look at `next`. - Every registry read misses the CDN cache (a cached packument lags a publish by up to 300 s), and a `next` publish job holds the npm-publish lane until the registry's record shows its version (up to 15 reads, 20 s apart: three of the first five publishes were still unreadable after 80 s, so the hold covers that lag with margin). - So the run after it judges against a record that carries it. Neither dist-tag moves backward on what its run could see. - The residual window: a publish the registry has not made readable within that bound is invisible to the run after it, which then moves `next` back to its older version. - That run fails with an error naming the drift once the record shows both versions; it warns if its own never shows within the bound. - It passes without naming the drift if its own shows while the overtaken one still does not. - - A rerun of it publishes nothing and passes, and the next green push that changes the shipped surface moves `next` forward again (its commit descends from every published one). + - A rerun of it publishes nothing and passes, and the next release-PR refresh moves `next` forward again (its commit descends from every published one). - No run moves a dist-tag by hand: trusted publishing authenticates `npm publish` alone, not `npm dist-tag add`. - Both channels publish through npm trusted publishing (OIDC) from this repository's CI workflow: no registry token exists anywhere. - npm attaches a provenance attestation to every version CI publishes, which `npm audit signatures` checks in a project that installs it. diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 78fcff25..8e9631e4 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -4,7 +4,9 @@ order: 20 # Upgrading from v2 to v3 -Fifty-eight breaks. Nine are for library consumers (sections 9, 23, 24, 27, 34, 35, 54, 56, and 58), one is for anyone pinning a sha (section 21), and eighteen are parse-time refusals (sections 36 to 52 and 55): a declaration GitHub would reject, or that could never converge, now fails before any request. Section 53 is silent: YAML merge keys resolve. Section 57 respells one validation message. +Fifty-nine breaks. Nine are for library consumers (sections 9, 23, 24, 27, 34, 35, 54, 56, and 58), and two are for anyone pinning a sha or installing `@next` (sections 21 and 59). + +Eighteen are parse-time refusals (sections 36 to 52 and 55): a declaration GitHub would reject, or that could never converge, now fails before any request. Sections 53 and 57 are silent: YAML merge keys resolve, and one validation message is respelled. The library sections describe the public entry, `@vivswan/github-settings-as-code`. The internal entry, `@vivswan/github-settings-as-code/internal`, carries no contract ([the two entries](../reference/library.md#the-two-entries)), so a change to a name that lives only there is not a break and this guide does not list it as a break. A name leaving the public entry for the internal one is a break, listed in [section 9](#9-library-the-public-entry-and-the-v3-names). @@ -72,6 +74,7 @@ The changelog entry for 3.0.0 will carry the release-please footers in the [CHAN | Library: `plan()` and `snapshot()` resolve to a `Result` | `await labels.plan(ctx, declared)` resolved to the plan and rejected on a denied read, a duplicated live pair, or a live body the section could not reconcile | Both resolve to a neverthrow `Result`: the plan or snapshot on `Ok`, a `SectionFailure` on `Err`; a rejection is left for the wrong-context refusal, a client that throws instead of answering, and `BUG:` invariants | Reading `.ops` or `.value` off the awaited value fails to compile (`TS2339`); `rejects.toThrow` assertions on a section call pass a resolved promise through; [section 56](#56-library-plan-and-snapshot-resolve-to-a-result) | | A closed section's unrecognized key names the entry by index | `collaborators[octocat]: declares "permision", which this section does not recognize ...` | `collaborators[0] (username "octocat"): declares "permision", which this section does not recognize ...`; under a wrapper, `collaborators.entries[0] (username "octocat")` | Anything that greps the bracket for the entry's identity needs the new spelling; [section 57](#57-a-closed-sections-unrecognized-key-names-the-entry-by-index) | | Library: `GitHubClient` and `ArtifactUploader` answer, never reject | `tryRequest()` and `tryGraphql()` rejected for a request with no HTTP answer (not sent, the transport failed, a GraphQL body off the wire contract); `upload()` rejected to report a failed upload | Both port methods resolve to a `ClientAnswer` whose third arm is `{ failed }`, the whole line; `upload()` resolves to `{ uploaded: true }` or `{ failed }`. A test double that still throws is read as a broken contract: the failure is reported, never classified | A double returning `void` from `upload()`, or a caller reading `.data` off a `ClientAnswer` without narrowing `failed`, fails to compile; [section 58](#58-library-githubclient-and-artifactuploader-answer-never-reject) | +| `next` publishes on a release-PR refresh | The pre-release v3 builds published a `next` pre-release from every green push to `main` that changed the shipped surface | The pre-release publishes when release-please creates or refreshes the release PR, which a releasable commit (feat, fix, perf, revert, or a breaking marker) does; a merge of hidden types alone publishes nothing | No error: `@next` keeps resolving, to the last releasable commit's pre-release; a build of one exact commit is the packaged commit under its `build/*` tag; [section 59](#59-next-publishes-on-a-release-pr-refresh) | ## 1. The defaults-file fallback @@ -1246,6 +1249,22 @@ v3 const answer = await client.tryRequest("GET", path); // resol Fix: add the `failed` arm to every `GitHubClient` double and read it before `error`; return `{ uploaded: true }` from every `ArtifactUploader` double. +## 59. `next` publishes on a release-PR refresh + +For anyone installing `@vivswan/github-settings-as-code@next`. The pre-release v3 builds published a `next` pre-release from every green push to `main` that changed what the tarball ships or builds it; v3 publishes one when release-please creates or refreshes the release PR, and nowhere else. + +A releasable commit publishes in the run that lands it; a merge of hidden types alone (a `build(deps)` bump, a test, a doc) publishes nothing. [Versioning](../reference/library.md#versioning) owns the rule, the guard that keeps `next` from moving back, and its residual window. + +```text +pre-release merge build(deps): bump yaml -> green -> bun.lock changed -> publish 3.0.1-main.447.20260922.gabc1234 under next + merge fix(x): ... -> green -> src/ changed -> publish 3.0.1-main.448.20260922.gdef5678 under next + +v3 merge build(deps): bump yaml -> green -> no release-PR refresh -> nothing published + merge fix(x): ... -> green -> release PR refreshed -> publish 3.0.1-main.448.20260922.gdef5678 under next +``` + +Fix: nothing for a consumer of `@next`. For a build of one exact commit, install the packaged commit (`github:Vivswan/github-settings-as-code#`, from a `build/.` tag, the ten newest kept, or a release tag). + ## Order of operations 1. Rename any settings file whose path contains a comma, rename `undeclared` to `_undeclared` in every settings file, and move every other underscore key into a YAML comment; the v2 line accepts the old spellings only, so do all three together with the pin move. diff --git a/test/docs/npm-publish-workflows.test.ts b/test/docs/npm-publish-workflows.test.ts index 4167295e..08d3276c 100644 --- a/test/docs/npm-publish-workflows.test.ts +++ b/test/docs/npm-publish-workflows.test.ts @@ -1,6 +1,6 @@ /** - * The two npm publishers (post-green.yml's publish-next, update-release.yml's publish-npm) share one contract, trusted publishing - * through the runner's OIDC token and nothing else, and one was copied from the other. The relations here: both are guarded to the + * The two npm publishers (update-release-pr.yml's publish-next, update-release.yml's publish-npm) share one contract, trusted + * publishing through the runner's OIDC token and nothing else, and one was copied from the other. The relations here: both are guarded to the * repository package.json names; both take one lane; the stable one runs after every other job of its workflow; neither hands npm a * token, as the library page promises; the steps they share are the same text; both publish to one registry. The probe, the floor * guard, and the publish blocks also run under bash against stubs, since no pin shows what a branch does. @@ -66,7 +66,7 @@ const setupNode = (job: RunJob): Step => const STABLE_FILE = "update-release.yml"; const STABLE_JOB = "publish-npm"; -const NEXT_FILE = "post-green.yml"; +const NEXT_FILE = "update-release-pr.yml"; const NEXT_JOB = "publish-next"; const publishers = () => { const nextWorkflow = readWorkflow(NEXT_FILE); diff --git a/test/docs/post-green-workflow.test.ts b/test/docs/post-green-workflow.test.ts index ad273306..bc7afd10 100644 --- a/test/docs/post-green-workflow.test.ts +++ b/test/docs/post-green-workflow.test.ts @@ -2,10 +2,10 @@ * The hooks ci.yml calls after the all-green gate (post-green.yml, update-release.yml, update-release-pr.yml) push refs and publish * packages, so what they can do follows from where they are reachable and what each job is granted. The relations here: every hook is * reachable through workflow_call alone and its ci.yml caller sits downstream of all-green; a job's effective grant covers what its - * steps consume; a hook job's own condition is the fork guard or nothing; post-green's judged sha - * reaches every checkout and packaging step; every output a step writes is read by a later step, and every gate reads an output an - * earlier step writes, back to the probe. The push probe also runs under bash against a stubbed git, since no pin shows what a branch - * does. + * steps consume; a hook job's own condition is the fork guard or nothing; post-green's judged sha, and the caller's sha in the + * release-PR hook, reach every checkout and every step that names a source; every output a step writes is read by a later step, and + * every gate reads an output an earlier step writes, back to the probe. The push probe also runs under bash against a stubbed git, + * since no pin shows what a branch does. * * The static guards catch ACCIDENTAL drift: a trigger, grant, gate, or step added or dropped in plain YAML. Deliberately hiding one * behind other syntax is out of scope. @@ -198,62 +198,72 @@ describe("the hooks' grants", () => { expect(grantProblems(HOOKS)).toEqual([]); }); - test.each<[string, (hooks: LocalCall[]) => LocalCall[], RegExp]>([ - [ - "a caller ceiling below what the build job pushes with", - (hooks) => - hooks.map((hook) => - hook.file === "post-green.yml" - ? { - ...hook, - job: { ...hook.job, permissions: { contents: "read", "id-token": "write" } }, - } - : hook, - ), + test("a caller ceiling below what the build job pushes with fails the grant relation (negative control)", () => { + // The build job declares no grant of its own, so the caller's ceiling is what it runs under. + const hooks = structuredClone(HOOKS).map((hook) => + hook.file === "post-green.yml" + ? { ...hook, job: { ...hook.job, permissions: { contents: "read", "id-token": "write" } } } + : hook, + ); + expect(grantProblems(hooks).join("\n")).toMatch( /post-green\.yml#build: .* needs contents: write/, + ); + }); + + test.each<[string, string, Record, RegExp]>([ + [ + "an anchor job narrowed to read while its subcommand pushes", + "anchor", + { contents: "read" }, + /update-release-pr\.yml#anchor: .* needs contents: write/, ], [ - "a caller that grants no OIDC token", - (hooks) => - hooks.map((hook) => - hook.file === "post-green.yml" - ? { ...hook, job: { ...hook.job, permissions: { contents: "write" } } } - : hook, - ), - /post-green\.yml#publish-next: .* needs id-token: write/, + "a publish job without the OIDC grant its npm publish consumes", + "publish-next", + { contents: "read" }, + /update-release-pr\.yml#publish-next: .* needs id-token: write/, ], - ])("%s fails the grant relation (negative control)", (_case, mutate, message) => { - expect(grantProblems(mutate(structuredClone(HOOKS))).join("\n")).toMatch(message); - }); - - test("an anchor job narrowed to read while its subcommand pushes fails (negative control)", () => { + ])("%s fails the grant relation (negative control)", (_case, id, permissions, message) => { const narrowed = readWorkflow("update-release-pr.yml"); - must(narrowed.jobs.anchor, "anchor job").permissions = { contents: "read" }; + must(narrowed.jobs[id], `${id} job`).permissions = permissions; const read = (file: string) => file === "update-release-pr.yml" ? narrowed : readWorkflow(file); - expect(grantProblems(HOOKS, read).join("\n")).toMatch( - /update-release-pr\.yml#anchor: .* needs contents: write/, - ); + expect(grantProblems(HOOKS, read).join("\n")).toMatch(message); }); }); -/** The hook a library-page claim about "every green push" or "every release cut" points at, by its ci.yml caller's condition. */ +/** + * The hook a library-page claim about "every green push", "every release cut", or a refresh of "the release PR" points at, by its + * ci.yml caller's condition. + */ function hookFor(claim: string): { file: string; workflow: Workflow } { - const release = /release cut/i.test(claim); + const trigger = /release cut/i.test(claim) + ? "a release cut" + : /release PR/i.test(claim) + ? "a release-PR refresh" + : "a green push to main"; // A green push: the caller's own condition names the push event and the main ref beside the gate's success; a release cut: it - // names release-please's release_created output. One caller each, or the claim points at nothing. + // names release-please's release_created output; a release-PR refresh: its prs_created output. One caller each, or the claim + // points at nothing. const runsOn = (job: Job) => { const on = condition(job.if); - return release - ? /release_created == 'true'/.test(on) - : /needs\.all-green\.result == 'success'/.test(on) && + switch (trigger) { + case "a release cut": + return /release_created == 'true'/.test(on); + case "a release-PR refresh": + return /prs_created == 'true'/.test(on); + default: + return ( + /needs\.all-green\.result == 'success'/.test(on) && /github\.event_name == 'push'/.test(on) && - /github\.ref == 'refs\/heads\/main'/.test(on); + /github\.ref == 'refs\/heads\/main'/.test(on) + ); + } }; const matching = HOOKS.filter(({ job }) => runsOn(job)); expect( matching.map((hook) => hook.file), - `exactly one hook caller runs on ${release ? "a release cut" : "a green push to main"}`, + `exactly one hook caller runs on ${trigger}`, ).toHaveLength(1); const hook = matching[0] as LocalCall; return { file: hook.file, workflow: readWorkflow(hook.file) }; @@ -320,13 +330,22 @@ describe("the library page's publishing claims", () => { expect(widened.map((hook) => hook.file)).not.toContain("post-green.yml"); }); - test("a packaging step gone, or a channel's verdict gone, fails the claim (negative control)", () => { + test("a packaging step gone, a channel's verdict gone, or next claimed back on the green push, fails the claim (negative control)", () => { const { workflow } = hookFor("green push"); const build = must(workflow.jobs.build, "build job"); build.steps = build.steps?.filter((step) => !/package-commit/.test(step.run ?? "")); expect(runsSubcommand(workflow, "package-commit")).toBe(false); - expect(runsSubcommand(workflow, "npm-verdict next")).toBe(true); + // The pre-release guard runs in the release-PR hook alone: the green-push hook publishes nothing to npm. + expect(runsSubcommand(workflow, "npm-verdict next")).toBe(false); + expect(runsSubcommand(hookFor("the release PR").workflow, "npm-verdict next")).toBe(true); expect(runsSubcommand(workflow, "npm-verdict stable")).toBe(false); + expect( + claimProblems( + page.replace(/^\| `next` \| [^|]+ \|/m, "| `next` | Every green push to `main` |"), + ), + ).toEqual([ + 'the page says next publishes on "Every green push to `main`", but post-green.yml runs no npm-verdict next', + ]); // A publish step conditioned on the caller's event skips on the push that calls it, so the channel's claim fails. const conditioned = readWorkflow("update-release.yml"); for (const job of Object.values(conditioned.jobs)) { @@ -429,32 +448,43 @@ function wiringProblems(workflow: Workflow): string[] { }); } -describe("post-green.yml", () => { - const workflow = readWorkflow("post-green.yml"); +/** The post-green hook and the release-PR hook, whose publish job the post-green one used to hold. */ +const RELEASE_PR = "update-release-pr.yml"; +const POST_GREEN = "post-green.yml"; + +describe("the probed hooks' wiring", () => { + const workflow = readWorkflow(POST_GREEN); const caller = must( - HOOKS.find((call) => call.file === "post-green.yml"), + HOOKS.find((call) => call.file === POST_GREEN), "post-green caller", ).job; test("every step after a probe runs on its verdict, every read names a written output, every output is read, in every hook", () => { - const probes = Object.values(workflow.jobs).filter((job) => (job.steps ?? []).some(isProbe)); - // Both post-green jobs open with a probe today; the release hooks have none, and their output reads are judged the same way. - expect(probes.length).toBe(Object.keys(workflow.jobs).length); + const probed = (file: string) => + Object.values(readWorkflow(file).jobs).filter((job) => (job.steps ?? []).some(isProbe)); + // post-green's one job and the release-PR hook's publish job open with a probe today; the other release-hook jobs have none, + // and their output reads are judged the same way. + expect(probed(POST_GREEN).length).toBe(Object.keys(workflow.jobs).length); + expect(probed(RELEASE_PR)).toHaveLength(1); for (const { file } of HOOKS) { expect(wiringProblems(readWorkflow(file)), file).toEqual([]); } }); - test.each<[string, (w: Workflow) => void, RegExp]>([ + const publishJob = (w: Workflow) => must(w.jobs["publish-next"], "publish-next"); + + test.each<[string, string, (w: Workflow) => void, RegExp]>([ [ "the packaging step without its gate", + POST_GREEN, (w) => delete must(must(w.jobs.build, "build").steps?.at(-1), "step").if, /runs whatever the probe found/, ], [ "the confirmation gated on a step that is not gated itself", + RELEASE_PR, (w) => { - const steps = must(must(w.jobs["publish-next"], "publish-next").steps, "steps"); + const steps = must(publishJob(w).steps, "steps"); must(steps.at(-1), "confirm").if = "steps.oidc-copy.outputs.proceed == 'true'"; steps.splice(1, 0, { id: "oidc-copy", run: 'echo "proceed=true" >> "$GITHUB_OUTPUT"' }); }, @@ -462,6 +492,7 @@ describe("post-green.yml", () => { ], [ "a gate that is not an equality on true", + POST_GREEN, (w) => { must(must(w.jobs.build, "build").steps?.at(-1), "step").if = "always() || steps.token.outputs.proceed == 'true'"; @@ -470,6 +501,7 @@ describe("post-green.yml", () => { ], [ "a gate on an output the probe never writes", + POST_GREEN, (w) => { must(must(w.jobs.build, "build").steps?.at(-1), "step").if = "steps.token.outputs.published == 'true'"; @@ -478,11 +510,13 @@ describe("post-green.yml", () => { ], [ "the confirmation step gone, leaving the publish output unread", - (w) => must(w.jobs["publish-next"], "publish-next").steps?.pop(), + RELEASE_PR, + (w) => publishJob(w).steps?.pop(), /writes published, which no later step reads/, ], [ "the checkout ahead of the probe under a condition of its own", + POST_GREEN, (w) => { must(must(w.jobs.build, "build").steps?.[0], "checkout").if = "github.event_name == 'release'"; @@ -491,6 +525,7 @@ describe("post-green.yml", () => { ], [ "a probe under a condition of its own", + POST_GREEN, (w) => { must(must(w.jobs.build, "build").steps?.[1], "probe").if = "github.event_name == 'release'"; }, @@ -498,14 +533,15 @@ describe("post-green.yml", () => { ], [ "the publish output no longer written", + RELEASE_PR, (w) => { - const step = must(must(w.jobs["publish-next"], "publish-next").steps?.at(-2), "publish"); + const step = must(publishJob(w).steps?.at(-2), "publish"); step.run = step.run?.replace(/\n\s*echo "published=true" >> "\$GITHUB_OUTPUT"/, ""); }, /reads steps\.publish\.outputs\.published, which no earlier step writes/, ], - ])("%s fails the wiring relation (negative control)", (_case, mutate, message) => { - const drifted = structuredClone(workflow); + ])("%s fails the wiring relation (negative control)", (_case, file, mutate, message) => { + const drifted = readWorkflow(file); mutate(drifted); expect(wiringProblems(drifted).join("\n")).toMatch(message); }); @@ -535,32 +571,52 @@ describe("post-green.yml", () => { expect(wiringProblems(stable).join("\n")).toMatch(message); }); - test("the judged sha the caller passes is the ref every checkout takes and the source every packaging step names", () => { - const [input, ...rest] = Object.keys(workflow.on.workflow_call?.inputs ?? {}); - expect(rest, "post-green.yml takes more than the one judged sha").toEqual([]); - // Compared as expressions, so a sync respelling the managed caller's braces or spacing is not a behavior change here. - expect(condition(caller.with?.[input ?? ""])).toBe("github.sha"); - const judged = `inputs.${input}`; - const steps = Object.values(workflow.jobs).flatMap((job) => job.steps ?? []); - const checkouts = steps.filter((step) => (step.uses ?? "").startsWith("actions/checkout@")); - // The steps that pass a source to the pipeline or to npm, found by the script that reads the variable, not by the env that sets it. + /** + * The one sha a hook acts on must reach every checkout that names a ref and every step that passes a source to the pipeline or + * to npm (found by the script that reads the variable, not by the env that sets it). A checkout naming no ref takes the caller's + * event sha, which is the same commit in both hooks. + */ + function expectSource( + file: string, + judged: string, + atLeast: { checkouts: number; sources: number }, + ) { + const steps = Object.values(readWorkflow(file).jobs).flatMap((job) => job.steps ?? []); + const checkouts = steps.filter( + (step) => (step.uses ?? "").startsWith("actions/checkout@") && step.with?.ref !== undefined, + ); const sources = steps.filter((step) => /\$SOURCE_SHA\b/.test(step.run ?? "")); - expect(checkouts.length).toBeGreaterThan(1); - expect(sources.length).toBeGreaterThan(1); + expect(checkouts.length).toBeGreaterThanOrEqual(atLeast.checkouts); + expect(sources.length).toBeGreaterThanOrEqual(atLeast.sources); for (const step of checkouts) { - expect(condition(step.with?.ref), "a checkout of something other than the judged sha").toBe( - judged, - ); + expect( + condition(step.with?.ref), + `${file}: a checkout of something other than ${judged}`, + ).toBe(judged); } for (const step of sources) { expect( condition(step.env?.SOURCE_SHA), - `"${step.name}" packages something other than the judged sha`, + `${file}: "${step.name}" names a source other than ${judged}`, ).toBe(judged); } + } + + test("the judged sha the caller passes post-green is the ref its checkout takes and the source its packaging step names", () => { + const [input, ...rest] = Object.keys(workflow.on.workflow_call?.inputs ?? {}); + expect(rest, "post-green.yml takes more than the one judged sha").toEqual([]); + // Compared as expressions, so a sync respelling the managed caller's braces or spacing is not a behavior change here. + expect(condition(caller.with?.[input ?? ""])).toBe("github.sha"); + expectSource(POST_GREEN, `inputs.${input}`, { checkouts: 1, sources: 1 }); + }); + + test("the release-PR hook publishes the caller's sha: the push release-please judged is what its checkout takes and its publish and confirmation name", () => { + // Inside a called workflow github.sha is the caller's, the head the refresh was built on, so the version, the provenance, + // and the guard all speak of one commit. + expectSource(RELEASE_PR, "github.sha", { checkouts: 1, sources: 2 }); }); - test("the probe tells a rejected PAT from a read ceiling by the same secret the checkout falls back from", () => { + test("the push probe tells a rejected PAT from a read ceiling by the same secret the checkout falls back from", () => { const steps = must(workflow.jobs.build, "build job").steps ?? []; const checkout = must( steps.find((step) => (step.uses ?? "").startsWith("actions/checkout@")), diff --git a/test/package-json.test.ts b/test/package-json.test.ts index 8f34e700..477e6b7a 100644 --- a/test/package-json.test.ts +++ b/test/package-json.test.ts @@ -53,7 +53,7 @@ describe("package.json as the npm manifest", () => { }); test("publishes a public scoped package from the repository the schema names", () => { - // post-green.yml (publish-next) and update-release.yml (publish-npm) run a bare `npm publish` under trusted publishing: a scoped package + // update-release-pr.yml (publish-next) and update-release.yml (publish-npm) run a bare `npm publish` under trusted publishing: a scoped package // publishes restricted without publishConfig.access, and provenance verifies repository.url against the workflow's repository. expect("private" in pkg).toBe(false); expect(pkg.publishConfig).toEqual({ access: "public" }); diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 3c8f6ca2..994a4c52 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -5,7 +5,7 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { basename, join } from "node:path"; import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; import { @@ -15,8 +15,6 @@ import { FROZEN, type MainPosition, mainPosition, - NEXT_BUILD_INPUTS, - NEXT_BUILD_UNPACKED, type NextVerdict, nextPublishVerdict, npmConfirm, @@ -52,7 +50,6 @@ import { LATEST, latestTag, localIdentity, - manifestJson, moveOf, PERMANENT, PLANTED_PACKAGES, @@ -1208,16 +1205,12 @@ describe("prereleaseVersion", () => { ).toThrow(/not a version this pipeline mints/); }); - /** The surface a skip names: the manifest, the fixture manifest's files list (lib/pkg/), the build inputs, npm's - * always-packed root files, and what under src/ does not count. */ - const SHIPPED = `package.json, lib/pkg/, ${NEXT_BUILD_INPUTS.join(", ")}, or a root README, COPYING, or LICENSE (under src/, ${NEXT_BUILD_UNPACKED.join(", ")} are never packed and do not count)`; - const registry = (versions: string[], tags: Record): Packument => ({ versions: Object.fromEntries(versions.map((v) => [v, {}])), "dist-tags": tags, }); - /** The registry states a next verdict meets, around one source: the fixture's release merge, judged from the + /** The registry states the next guard meets, around one source: the fixture's release merge, judged from the * work clone after main grew two commits past it and a branch left it unmerged. */ function mainAround(fx: Fixture): { own: string; @@ -1243,72 +1236,47 @@ describe("prereleaseVersion", () => { }; } - test("next: a published pre-release is placed by its source's ancestry, and a descendant's, or next naming this run's own source, holds the run back", () => { + test("next: a published pre-release is placed by its source's ancestry; a rerun, or a descendant's pre-release, holds the run back, and nothing else does", () => { const fx = seedFixture(); const main = mainAround(fx); const source7 = fx.mergeSha.slice(0, 7); const sha7 = (version: string): string => version.slice(-7); + const staleReason = (version: string): string => + `the registry already holds ${version}, whose source ${sha7(version)} is a descendant of ${source7} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`; const stale = (version: string): NextVerdict => ({ publish: false, version: main.own, - reason: `the registry already holds ${version}, whose source ${sha7(version)} is a descendant of ${source7} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`, + reason: staleReason(version), notices: [], }); + const goes = (...notices: string[]): NextVerdict => ({ + publish: true, + version: main.own, + notices, + }); const unresolved = "2.1.1-main.9.20260901.g0000000"; - /** Why a run without a base publishes, and what one that names the version next holds says on top of "ignored". */ - const noNext = - "the registry's next names no version, so there is no build to compare the shipped surface against; publishing"; - const noBase = - ", and next names it, so there is no build to compare the shipped surface against: every change counts as shipped"; const cases: [string, Packument | null, NextVerdict][] = [ + ["a package the registry has never seen", null, goes()], + ["the first pre-release after a release", registry(["2.1.0"], { latest: "2.1.0" }), goes()], [ - "a package the registry has never seen", - null, - { - publish: true, - version: main.own, - notices: [ - "the registry holds no record of this package yet, so there is no build to compare the shipped surface against; publishing", - ], - }, - ], - [ - "the first pre-release after a release", - registry(["2.1.0"], { latest: "2.1.0" }), - { publish: true, version: main.own, notices: [noNext] }, - ], - [ - // The release merge rewrote the manifest and package.json beside the changelog: two shipped paths, named in order. - "an ancestor's pre-release on next, with shipped changes since", + "an ancestor's pre-release on next", registry(["2.1.0", main.ancestor], { latest: "2.1.0", next: main.ancestor }), - { - publish: true, - version: main.own, - notices: [ - `2 shipped files changed since ${main.ancestor} (source ${sha7(main.ancestor)}): .release-please-manifest.json, package.json`, - ], - }, + goes(), ], [ - "next naming this run's own version while the record lacks it: nothing changed since itself", + "next naming this run's own version while the record lacks it: a rerun, npm would refuse the version", registry(["2.1.0"], { latest: "2.1.0", next: main.own }), { publish: false, version: main.own, - reason: `no shipped file changed since ${main.own} (source ${source7}): no merge to main in ${source7}..${source7} touches ${SHIPPED}`, + reason: `${main.own} is already on the registry`, notices: [], }, ], [ "next naming a release, which carries no source", registry(["2.1.0"], { latest: "2.1.0", next: "2.1.0" }), - { - publish: true, - version: main.own, - notices: [ - "the registry's next is 2.1.0, which names no source sha, so there is no build to compare the shipped surface against; publishing", - ], - }, + goes(), ], [ "a descendant's pre-release with other position identifiers (the hand bootstrap's shape): the sha alone places it", @@ -1344,46 +1312,24 @@ describe("prereleaseVersion", () => { [ "a release that shipped after this commit, with no pre-release of its merge", registry(["2.1.0", "2.2.0"], { latest: "2.2.0" }), - { publish: true, version: main.own, notices: [noNext] }, + goes(), ], [ "a pre-release naming a commit this checkout lacks, on next", registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }), - { - publish: true, - version: main.own, - notices: [ - `${unresolved} names 0000000, which is no commit in this checkout; ignored${noBase}`, - ], - }, - ], - [ - "a pre-release naming a commit this checkout lacks, beside an ancestor's on next", - registry(["2.1.0", unresolved, main.ancestor], { latest: "2.1.0", next: main.ancestor }), - { - publish: true, - version: main.own, - notices: [ - `${unresolved} names 0000000, which is no commit in this checkout; ignored`, - `2 shipped files changed since ${main.ancestor} (source ${sha7(main.ancestor)}): .release-please-manifest.json, package.json`, - ], - }, + goes(`${unresolved} names 0000000, which is no commit in this checkout; ignored`), ], [ "a pre-release naming a commit off this source's line of main, on next", registry(["2.1.0", main.unrelated], { latest: "2.1.0", next: main.unrelated }), - { - publish: true, - version: main.own, - notices: [ - `${main.unrelated} names ${sha7(main.unrelated)}, which is neither an ancestor nor a descendant of ${source7} on main; ignored${noBase}`, - ], - }, + goes( + `${main.unrelated} names ${sha7(main.unrelated)}, which is neither an ancestor nor a descendant of ${source7} on main; ignored`, + ), ], [ "a hand-published version this pipeline never minted, which no dist-tag names", registry(["2.1.0", "2.1.1-beta.1"], { latest: "2.1.0" }), - { publish: true, version: main.own, notices: [noNext] }, + goes(), ], ]; for (const [name, packument, expected] of cases) { @@ -1393,7 +1339,7 @@ describe("prereleaseVersion", () => { }); } // A checkout git cannot read is not a checkout without the commit: the same record that skipped above stops - // the verdict from a directory that is no repository, instead of publishing over the descendant. + // the guard from a directory that is no repository, instead of publishing over the descendant. const nowhere = join(fx.root, "not-a-repository"); mkdirSync(nowhere); expect(() => @@ -1406,208 +1352,28 @@ describe("prereleaseVersion", () => { ).toThrow( /^git rev-parse --verify --quiet [0-9a-f]{7}\^\{commit\} failed: fatal: not a git repository/, ); - }); - - test("next: a source no merge since the build next names touched a shipped path up to publishes nothing; a shipped change, a reverted one, a moved file, or a files list not read as paths publishes", async () => { - const fx = seedFixture(); - const main = mainAround(fx); - const further = git(fx.work, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; - const lab = clone(fx.root, fx.origin, "lab"); - const skipReason = (base: string, baseSha: string, source: string): string => - `no shipped file changed since ${base} (source ${baseSha.slice(0, 7)}): no merge to main in ${baseSha.slice(0, 7)}..${source.slice(0, 7)} touches ${SHIPPED}`; - const skipped = (base: string, baseSha: string, source: string): NextVerdict => ({ - publish: false, - version: versionOf(lab, source, 5), - reason: skipReason(base, baseSha, source), - notices: [], - }); - const published = (source: string, count: number, notice: string): NextVerdict => ({ - publish: true, - version: versionOf(lab, source, count), - notices: [notice], - }); - const at = (base: string) => registry(["2.1.0", base], { latest: "2.1.0", next: base }); - // Docs, the changelog, and a workflow: nothing the tarball ships or the build reads. - write(lab, "docs/guide.md", "# Guide\n"); - write(lab, "CHANGELOG.md", `${CHANGELOG_21}\n## Unreleased\n`); - write(lab, ".github/workflows/ci.yml", "name: ci\non: [push]\njobs: {}\n"); - const docs = commitAll(lab, "docs: a guide"); - git(lab, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - expect(nextPublishVerdict(lab, docs, versionOf(lab, docs, 5), at(main.further))).toEqual( - skipped(main.further, further, docs), - ); - // The base two merges back: the merge between them changed src/, so the diff to this source carries it. - const after = git(lab, "rev-parse", `${further}^`); - expect(nextPublishVerdict(lab, docs, versionOf(lab, docs, 5), at(main.descendant))).toEqual( - published( - docs, - 5, - `1 shipped file changed since ${main.descendant} (source ${after.slice(0, 7)}): src/marker.ts`, - ), - ); - const docsVersion = versionOf(lab, docs, 5); - // A source change on top of the docs commit. - write(lab, "src/marker.ts", 'export const marker = "shipped";\n'); - const src = commitAll(lab, "feat: shipped"); - expect(nextPublishVerdict(lab, src, versionOf(lab, src, 6), at(docsVersion))).toEqual( - published( - src, - 6, - `1 shipped file changed since ${docsVersion} (source ${docs.slice(0, 7)}): src/marker.ts`, - ), - ); - // The root README, which npm packs whatever the files list says (the fixture's names lib/pkg/ alone); a backup - // copy beside it and a README under a directory are not packed, so they do not count. - git(lab, "checkout", "--quiet", docs); - write(lab, "README.md", "# Package\n"); - write(lab, "README.md~", "# Package (backup)\n"); - write(lab, "docs/README.md", "# Docs\n"); - const readme = commitAll(lab, "docs: the package readme"); - expect(nextPublishVerdict(lab, readme, versionOf(lab, readme, 6), at(docsVersion))).toEqual( - published( - readme, - 6, - `1 shipped file changed since ${docsVersion} (source ${docs.slice(0, 7)}): README.md`, - ), - ); - // A change one merge makes and the next reverts: the trees agree, so a tree diff would skip the revert's run, and - // the change's run, taking the lane after it, would publish what main no longer holds. - git(lab, "checkout", "--quiet", docs); - write(lab, "src/marker.ts", 'export const marker = "reverted";\n'); - commitAll(lab, "feat: soon reverted"); - // The helper trims git's output; the file ends in the newline the trim took. - write(lab, "src/marker.ts", `${git(lab, "show", `${docs}:src/marker.ts`)}\n`); - const reverted = commitAll(lab, "revert: the marker"); - expect(git(lab, "diff", "--name-only", docs, reverted)).toBe(""); - expect(nextPublishVerdict(lab, reverted, versionOf(lab, reverted, 7), at(docsVersion))).toEqual( - published( - reverted, - 7, - `1 shipped file changed since ${docsVersion} (source ${docs.slice(0, 7)}): src/marker.ts`, - ), - ); - // A file moved out of the surface: with rename detection the diff would list docs/marker.ts alone. - git(lab, "checkout", "--quiet", docs); - git(lab, "mv", "src/marker.ts", "docs/marker.ts"); - const moved = commitAll(lab, "refactor: move the marker"); - expect(git(lab, "diff", "-M", "--name-only", docs, moved)).toBe("docs/marker.ts"); - expect(nextPublishVerdict(lab, moved, versionOf(lab, moved, 6), at(docsVersion))).toEqual( - published( - moved, - 6, - `1 shipped file changed since ${docsVersion} (source ${docs.slice(0, 7)}): src/marker.ts`, - ), - ); - // A base inside a merged branch: the first-parent walk from the source never visits it, so the merges since it - // cannot be read. Here the branch changed the marker and reverted it before the merge; judged by the merge's own - // diff the revert would be lost, so the run publishes instead, saying why. - git(lab, "checkout", "--quiet", docs); - write(lab, "src/marker.ts", 'export const marker = "branch";\n'); - const inBranch = commitAll(lab, "feat: on a branch"); - write(lab, "src/marker.ts", `${git(lab, "show", `${docs}:src/marker.ts`)}\n`); - const branchTip = commitAll(lab, "revert: on the branch"); - git(lab, "checkout", "--quiet", docs); - git(lab, "merge", "--quiet", "--no-ff", "--no-edit", branchTip); - const merge = git(lab, "rev-parse", "HEAD"); - const inBranchVersion = versionOf(lab, inBranch, 6); - expect(nextPublishVerdict(lab, merge, versionOf(lab, merge, 6), at(inBranchVersion))).toEqual( - published( - merge, - 6, - `the registry's next is ${inBranchVersion}, whose source ${inBranch.slice(0, 7)} is inside a branch merged to main, not a main commit, so the merges since it cannot be walked; publishing`, - ), - ); - // A manifest without a files list, then a docs-only commit over it: npm would pack the whole tree, so it publishes. - const manifestWith = (files: string[] | undefined): string => { - const pkg = JSON.parse(manifestJson("2.1.0")) as { files?: string[] }; - if (files === undefined) { - delete pkg.files; - } else { - pkg.files = files; - } - return `${JSON.stringify(pkg, null, 2)}\n`; - }; - for (const [files, why] of [ - [undefined, "has no files list, so npm packs the whole tree"], - [["lib/**"], 'lists "lib/**" in files, a pattern this comparison does not match'], - ] as const) { - git(lab, "checkout", "--quiet", docs); - write(lab, "package.json", manifestWith(files === undefined ? undefined : [...files])); - const base = commitAll(lab, "chore: the files list"); - write(lab, "docs/guide.md", `# Guide (${why})\n`); - const source = commitAll(lab, "docs: the guide again"); - const baseVersion = versionOf(lab, base, 6); - expect(nextPublishVerdict(lab, source, versionOf(lab, source, 7), at(baseVersion))).toEqual( - published( - source, - 7, - `package.json at ${source.slice(0, 7)} ${why}; every change since ${baseVersion} counts as shipped, publishing`, - ), - ); - } // The subcommand prints the skip as one stdout line, the notice the workflow raises. - const judge = checkoutOf(fx, "docs-judge", docs, "packaged-bundle-bytes-4\n"); - await withRegistry({ status: 200, body: at(main.further) }, async (url) => { - expect( - await subcommand(judge, { GITHUB_SHA: docs, NPM_REGISTRY_URL: url }, "npm-verdict", "next"), - ).toEqual({ - stdout: `skip ${skipReason(main.further, further, docs)}\n`, - stderr: "", - status: 0, - }); - }); - }); - - test("next: under src/, a merge touching only tests, scenarios, docs prose, mock handlers, or generators publishes nothing; one touching packed source publishes", () => { - const fx = seedFixture(); - const main = mainAround(fx); - const further = git(fx.work, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; - const lab = clone(fx.root, fx.origin, "lab"); - const at = registry(["2.1.0", main.further], { latest: "2.1.0", next: main.further }); - const skipped = (source: string, count: number): NextVerdict => ({ - publish: false, - version: versionOf(lab, source, count), - reason: `no shipped file changed since ${main.further} (source ${further.slice(0, 7)}): no merge to main in ${further.slice(0, 7)}..${source.slice(0, 7)} touches ${SHIPPED}`, - notices: [], - }); - // A section's tests alone: the merge that published a byte-identical tarball. - write(lab, "src/sections/x/x.test.ts", 'test("x", () => {});\n'); - const tests = commitAll(lab, "test(x): a case"); - expect(nextPublishVerdict(lab, tests, versionOf(lab, tests, 5), at)).toEqual(skipped(tests, 5)); - // An e2e scenario alone, on top of it: two merges since the base, neither packed. - write(lab, "src/sections/x/scenarios/x-basic.yml", "name: x-basic\n"); - const scenario = commitAll(lab, "test(x): a scenario"); - expect(nextPublishVerdict(lab, scenario, versionOf(lab, scenario, 6), at)).toEqual( - skipped(scenario, 6), + const judge = checkoutOf(fx, "stale-judge", fx.mergeSha, "packaged-bundle-bytes-4\n"); + return withRegistry( + { + status: 200, + body: registry(["2.1.0", main.descendant], { latest: "2.1.0", next: main.descendant }), + }, + async (url) => { + expect( + await subcommand( + judge, + { GITHUB_SHA: fx.mergeSha, NPM_REGISTRY_URL: url }, + "npm-verdict", + "next", + ), + ).toEqual({ + stdout: `skip ${staleReason(main.descendant)}\n`, + stderr: "", + status: 0, + }); + }, ); - // The rest of what the build never packs, in one merge. - write(lab, "src/sections/x/x.docs.yml", "title: X\n"); - write(lab, "src/sections/x/mock.ts", "export const handlers = [];\n"); - write(lab, "src/sections/x/mock.test.ts", 'test("mock", () => {});\n'); - write(lab, "src/sections/x/generators.ts", "export const draw = 1;\n"); - write(lab, "src/schema.docs.yml", "title: Schema\n"); - const rest = commitAll(lab, "test(x): the mock, its generators, and the docs prose"); - expect(nextPublishVerdict(lab, rest, versionOf(lab, rest, 7), at)).toEqual(skipped(rest, 7)); - // A module named like an excluded directory is packed source, and so is the section's own module. - write(lab, "src/sections/x/scenarios.ts", "export const scenarios = 1;\n"); - write(lab, "src/sections/x/index.ts", "export const x = 1;\n"); - const packed = commitAll(lab, "feat(x): the section"); - expect(nextPublishVerdict(lab, packed, versionOf(lab, packed, 8), at)).toEqual({ - publish: true, - version: versionOf(lab, packed, 8), - notices: [ - `2 shipped files changed since ${main.further} (source ${further.slice(0, 7)}): src/sections/x/index.ts, src/sections/x/scenarios.ts`, - ], - }); - }); - - test("the shipped surface's build inputs are paths in this repository, and package.json's files list is plain paths", () => { - for (const input of NEXT_BUILD_INPUTS) { - expect(existsSync(join(ROOT, input)), input).toBe(true); - } - // A pattern would make every verdict fall open to publishing, with the notice naming it. - const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { files: string[] }; - expect(pkg.files.filter((entry) => /[*?[\]{}!]/.test(entry))).toEqual([]); }); const stableVerdicts: [string, string, Packument | null, PublishVerdict][] = [ @@ -1728,13 +1494,7 @@ describe("prereleaseVersion", () => { "404 is an unpublished package", { channel: "next" }, () => ({ status: 404 }), - (own) => ({ - publish: true, - version: own, - notices: [ - "the registry holds no record of this package yet, so there is no build to compare the shipped surface against; publishing", - ], - }), + (own) => ({ publish: true, version: own, notices: [] }), ], [ "a stable version the record holds is skipped", @@ -1831,12 +1591,7 @@ describe("prereleaseVersion", () => { "npm-verdict", "next", ), - ).toEqual({ - stdout: `publish ${own}\n`, - stderr: - "the registry holds no record of this package yet, so there is no build to compare the shipped surface against; publishing\n", - status: 0, - }); + ).toEqual({ stdout: `publish ${own}\n`, stderr: "", status: 0 }); expect( await subcommand( fx.work, @@ -1862,7 +1617,7 @@ describe("prereleaseVersion", () => { ), ).toEqual({ stdout: `publish ${own}\n`, - stderr: `${unresolved} names 0000000, which is no commit in this checkout; ignored, and next names it, so there is no build to compare the shipped surface against: every change counts as shipped\n`, + stderr: `${unresolved} names 0000000, which is no commit in this checkout; ignored\n`, status: 0, }); }, @@ -1903,7 +1658,7 @@ describe("prereleaseVersion", () => { /** The drift the confirmation reports when next stayed on this run's version while a descendant's is on the record. */ const behind = (fx: Fixture, ahead: string): string => `the registry's next is ${published(fx)} while it holds ${ahead}, whose source ${ahead.slice(-7)} is a descendant ` + - `of ${fx.mergeSha.slice(0, 7)} on main; this stale run moved next back, and the next green push that changes the shipped surface moves it forward ` + + `of ${fx.mergeSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR refresh moves it forward ` + `(npm dist-tag add @scope/pkg@${ahead} next repairs it by hand)`; test("a record that lags the publish is read again until it shows the version, each read past the CDN cache", async () => { @@ -1932,7 +1687,7 @@ describe("prereleaseVersion", () => { const unsettled = { outcome: "unsettled" as const, version: published(fx), - reason: `the registry's record still lacks ${published(fx)} after 3 reads over 0 s; a run judged before it shows may move next back, and the green push after it moves next forward`, + reason: `the registry's record still lacks ${published(fx)} after 3 reads over 0 s; a run judged before it shows may move next back, and the release-PR refresh after it moves next forward`, }; const lagged = await withRegistry({ status: 200, body: lagging }, async (url, requests) => ({ verdict: await confirm(fx, url, 3), @@ -1980,7 +1735,7 @@ describe("prereleaseVersion", () => { }); }); - test("a newer release the record holds is not a drift: next sits below latest until the next push", async () => { + test("a newer release the record holds is not a drift: next sits below latest until the next release-PR refresh", async () => { const fx = seedFixture(); const released = registry(["2.1.0", published(fx), "2.2.0"], { latest: "2.2.0",