diff --git a/.changeset/hip-memes-rhyme.md b/.changeset/hip-memes-rhyme.md new file mode 100644 index 0000000..2078820 --- /dev/null +++ b/.changeset/hip-memes-rhyme.md @@ -0,0 +1,5 @@ +--- +"@zemd/gha": patch +--- + +Use token-free OIDC staging by default, automatically direct-publish only first releases with an optional npm token, preserve submitted versions as immutable releases even when npm approval is rejected, and advance the private shared-workflow contract version in release pull requests. diff --git a/.github/scripts/gha.mjs b/.github/scripts/gha.mjs index ba8235c..6d5b931 100644 --- a/.github/scripts/gha.mjs +++ b/.github/scripts/gha.mjs @@ -1,8 +1,209 @@ // Generated by `pnpm --filter @zemd/gha run build` from internal/gha/src. Do not edit. -import { readFileSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; +//#region src/semver.ts +const SEMVER = /^\d+\.\d+\.\d+$/; +const isReleaseVersion = (version) => SEMVER.test(version); +const parseVersion = (version) => { + const [core = "", ...prerelease] = version.split("-"); + const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); + return { + major, + minor, + patch, + prerelease: prerelease.join("-") + }; +}; +const bumpType = (from, to) => { + const a = parseVersion(from); + const b = parseVersion(to); + if (b.prerelease || a.prerelease) return "prerelease"; + if (b.major !== a.major) return "major"; + if (b.minor !== a.minor) return "minor"; + return "patch"; +}; + +//#endregion +//#region src/contract-version.ts +const BUMP_PRIORITY = { + patch: 0, + minor: 1, + major: 2 +}; +const scalar = (value) => { + const trimmed = value.trim(); + const quote = trimmed.at(0); + if ((quote === "\"" || quote === "'") && trimmed.at(-1) === quote) return trimmed.slice(1, -1); + return trimmed; +}; +const frontmatter = (source) => { + return source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? ""; +}; +const packageBump = (source, packageName) => { + for (const line of frontmatter(source).split(/\r?\n/)) { + const separator = line.indexOf(":"); + if (separator < 0 || scalar(line.slice(0, separator)) !== packageName) continue; + const bump = scalar(line.slice(separator + 1)); + if (bump === "major" || bump === "minor" || bump === "patch") return bump; + } +}; +const bumpContractVersion = (version, bump) => { + if (!isReleaseVersion(version)) throw new Error(`contract version must be plain semver, got "${version}"`); + const parsed = parseVersion(version); + if (bump === "major") return `${parsed.major + 1}.0.0`; + if (bump === "minor") return `${parsed.major}.${parsed.minor + 1}.0`; + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +}; +const planContractVersion = (manifest, intents) => { + const releases = intents.flatMap((intent) => { + const bump = packageBump(intent.source, manifest.name); + return bump === void 0 ? [] : [{ + id: intent.id, + bump + }]; + }); + if (releases.length === 0) return void 0; + const bump = releases.reduce((highest, release) => BUMP_PRIORITY[release.bump] > BUMP_PRIORITY[highest] ? release.bump : highest, releases[0]?.bump ?? "patch"); + return { + name: manifest.name, + currentVersion: manifest.version, + newVersion: bumpContractVersion(manifest.version, bump), + bump, + intentIds: releases.map(({ id }) => id).sort() + }; +}; +const reconcileContractRelease = (releases, plan) => { + const matching = releases.filter(({ name }) => name === plan.name); + if (matching.length !== 1) throw new Error(`pnpm version reported ${matching.length} releases for ${plan.name}; expected exactly one`); + const release = matching[0]; + if (!release) throw new Error(`pnpm version did not report ${plan.name}`); + if (release.currentVersion === plan.currentVersion && release.newVersion === plan.newVersion) return releases; + if (release.currentVersion !== plan.newVersion || release.newVersion !== plan.newVersion) throw new Error(`pnpm version reported an unexpected ${plan.name} transition: ${release.currentVersion} -> ${release.newVersion}; expected ${plan.newVersion} -> ${plan.newVersion}`); + return releases.map((entry) => entry === release ? { + ...entry, + currentVersion: plan.currentVersion + } : entry); +}; + +//#endregion +//#region src/pnpm.ts +const asArray = (value, context) => { + if (!Array.isArray(value)) throw new Error(`${context}: expected an array, got ${typeof value}`); + return value; +}; +const asRecord = (value, context) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${context}: expected an object, got ${JSON.stringify(value)}`); + return value; +}; +const asString = (source, key, context) => { + const value = source[key]; + if (typeof value !== "string") throw new Error(`${context}: expected "${key}" to be a string, got ${JSON.stringify(value)}`); + return value; +}; +const parseAppliedReleases = (json) => asArray(JSON.parse(json), "pnpm version -r --json").map((entry, index) => { + const context = `pnpm version -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + currentVersion: asString(record, "currentVersion", context), + newVersion: asString(record, "newVersion", context) + }; +}); +const parseWorkspacePackages = (json) => asArray(JSON.parse(json), "pnpm list -r --json").map((entry, index) => { + const context = `pnpm list -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context), + path: asString(record, "path", context), + private: record["private"] === true + }; +}); +const parsePublishSummary = (json) => { + const published = asRecord(JSON.parse(json), "pnpm publish --report-summary")["publishedPackages"]; + if (published === void 0) return []; + return asArray(published, "pnpm publish --report-summary.publishedPackages").map((entry, index) => { + const context = `pnpm publish --report-summary.publishedPackages[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context) + }; + }); +}; + +//#endregion +//#region src/commands/contract-version.ts +const parseManifest = (source, path) => { + const value = JSON.parse(source); + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path}: expected a package manifest object`); + const manifest = value; + if (typeof manifest["name"] !== "string" || typeof manifest["version"] !== "string") throw new Error(`${path}: expected string name and version fields`); + if (manifest["private"] !== true) throw new Error(`${path}: contract version preparation is restricted to private packages`); + return { + name: manifest["name"], + version: manifest["version"], + private: true + }; +}; +const replaceManifestVersion = (source, currentVersion, newVersion, path) => { + const property = /("version"\s*:\s*")([^"]*)(")/g; + const matches = [...source.matchAll(property)]; + if (matches.length !== 1 || matches[0]?.[2] !== currentVersion) throw new Error(`${path}: could not replace the unique version field`); + return source.replace(property, `$1${newVersion}$3`); +}; +const readIntents = (directory) => readdirSync(directory).filter((file) => file.endsWith(".md") && file.toLowerCase() !== "readme.md").sort().map((file) => ({ + id: basename(file, ".md"), + source: readFileSync(join(directory, file), "utf8") +})); +const parsePlan = (source, path) => { + const value = JSON.parse(source); + if (value === null) return void 0; + if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${path}: expected a contract version plan or null`); + const plan = value; + const bump = plan["bump"]; + if (typeof plan["name"] !== "string" || typeof plan["currentVersion"] !== "string" || typeof plan["newVersion"] !== "string" || bump !== "major" && bump !== "minor" && bump !== "patch" || !Array.isArray(plan["intentIds"]) || !plan["intentIds"].every((id) => typeof id === "string")) throw new Error(`${path}: invalid contract version plan`); + return { + name: plan["name"], + currentVersion: plan["currentVersion"], + newVersion: plan["newVersion"], + bump, + intentIds: plan["intentIds"] + }; +}; +const prepare$1 = (packagePath, intentsDirectory, statePath) => { + const manifestSource = readFileSync(packagePath, "utf8"); + const manifest = parseManifest(manifestSource, packagePath); + const plan = planContractVersion(manifest, readIntents(intentsDirectory)); + writeFileSync(statePath, `${JSON.stringify(plan ?? null, void 0, 2)}\n`); + if (!plan) return; + writeFileSync(packagePath, replaceManifestVersion(manifestSource, plan.currentVersion, plan.newVersion, packagePath)); +}; +const finalize = (statePath, releasesPath) => { + const plan = parsePlan(readFileSync(statePath, "utf8"), statePath); + if (!plan) return; + const releases = parseAppliedReleases(readFileSync(releasesPath, "utf8")); + writeFileSync(releasesPath, `${JSON.stringify(reconcileContractRelease(releases, plan), void 0, 2)}\n`); +}; +const contractVersion = { + usage: "prepare | finalize ", + run: (argv) => { + const [operation, firstPath, secondPath, thirdPath] = argv; + if (operation === "prepare" && firstPath && secondPath && thirdPath) { + prepare$1(firstPath, secondPath, thirdPath); + return; + } + if (operation === "finalize" && firstPath && secondPath && !thirdPath) { + finalize(firstPath, secondPath); + return; + } + throw new Error("usage: contract-version prepare | finalize "); + } +}; + +//#endregion //#region src/env.ts const requireEnv = (name) => { const value = process.env[name]; @@ -31,20 +232,35 @@ const changelogEntry = (packagePath, version) => { return (end === -1 ? rest : rest.slice(0, end)).join("\n").replace(/^#{1,6}\s+(.+)$/gm, "**$1**").trim(); }; +//#endregion +//#region src/release-tags.ts +const packageReleaseTag = (name, version) => `${name}@${version}`; + //#endregion //#region src/github-releases.ts const RELEASE_TAG_PREFIX = "release-"; -const renderCombinedReleaseBody = ({ published, paths, notes }) => { - const out = []; - out.push("## Published packages"); +const appendPackageTable = (out, heading, packages, approvalRequired) => { + if (packages.length === 0) return; + out.push(`## ${heading}`); + if (approvalRequired) { + out.push(""); + out.push("These versions require maintainer approval with 2FA before they become available from npm."); + out.push("Rejecting one does not roll back this release or make its version reusable; release changes under a new version instead."); + } out.push(""); out.push("| Package | Version |"); out.push("| :--- | ---: |"); - for (const { name, version } of published) out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); + for (const { name, version } of packages) out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); out.push(""); +}; +const renderCombinedReleaseBody = ({ published, staged = [], paths, notes }) => { + const out = []; + const submitted = [...published, ...staged]; + appendPackageTable(out, "Published packages", published, false); + appendPackageTable(out, "Packages staged on npm", staged, true); out.push("### Changelogs"); out.push(""); - for (const { name, version } of published) { + for (const { name, version } of submitted) { const packagePath = paths.get(name); out.push("
"); out.push(`${name}@${version}`); @@ -88,15 +304,17 @@ const createTag = async (api, tag, sha) => { console.error(`failed to create tag ${tag}:`, response.payload); return false; }; -const releasePublishedPackages = async ({ api, sha, published, workspace, now = /* @__PURE__ */ new Date() }) => { - if (published.length === 0) { - console.log("no packages were published, nothing to release"); +const releasePublishedPackages = async ({ api, sha, published, staged = [], workspace, now = /* @__PURE__ */ new Date() }) => { + if (published.length === 0 && staged.length === 0) { + console.log("no packages were submitted to npm, nothing to release"); return; } - const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const publishedReleases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const stagedReleases = [...staged].sort((a, b) => a.name.localeCompare(b.name)); + const releases = [...publishedReleases, ...stagedReleases].sort((a, b) => a.name.localeCompare(b.name)); const paths = new Map(workspace.map((entry) => [entry.name, entry.path])); let failed = false; - for (const { name, version } of releases) if (!await createTag(api, `${name}@${version}`, sha)) failed = true; + for (const { name, version } of releases) if (!await createTag(api, packageReleaseTag(name, version), sha)) failed = true; const releaseTag = await nextReleaseTag(api, now); const notes = await api.generateNotes(releaseTag, sha, await previousReleaseTag(api)); const created = await api.createRelease({ @@ -104,7 +322,8 @@ const releasePublishedPackages = async ({ api, sha, published, workspace, now = name: releaseTag, targetCommitish: sha, body: renderCombinedReleaseBody({ - published: releases, + published: publishedReleases, + staged: stagedReleases, paths, notes }), @@ -118,53 +337,6 @@ const releasePublishedPackages = async ({ api, sha, published, workspace, now = if (failed) throw new Error("one or more release steps failed"); }; -//#endregion -//#region src/pnpm.ts -const asArray = (value, context) => { - if (!Array.isArray(value)) throw new Error(`${context}: expected an array, got ${typeof value}`); - return value; -}; -const asRecord = (value, context) => { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${context}: expected an object, got ${JSON.stringify(value)}`); - return value; -}; -const asString = (source, key, context) => { - const value = source[key]; - if (typeof value !== "string") throw new Error(`${context}: expected "${key}" to be a string, got ${JSON.stringify(value)}`); - return value; -}; -const parseAppliedReleases = (json) => asArray(JSON.parse(json), "pnpm version -r --json").map((entry, index) => { - const context = `pnpm version -r --json[${index}]`; - const record = asRecord(entry, context); - return { - name: asString(record, "name", context), - currentVersion: asString(record, "currentVersion", context), - newVersion: asString(record, "newVersion", context) - }; -}); -const parseWorkspacePackages = (json) => asArray(JSON.parse(json), "pnpm list -r --json").map((entry, index) => { - const context = `pnpm list -r --json[${index}]`; - const record = asRecord(entry, context); - return { - name: asString(record, "name", context), - version: asString(record, "version", context), - path: asString(record, "path", context), - private: record["private"] === true - }; -}); -const parsePublishSummary = (json) => { - const published = asRecord(JSON.parse(json), "pnpm publish --report-summary")["publishedPackages"]; - if (published === void 0) return []; - return asArray(published, "pnpm publish --report-summary.publishedPackages").map((entry, index) => { - const context = `pnpm publish --report-summary.publishedPackages[${index}]`; - const record = asRecord(entry, context); - return { - name: asString(record, "name", context), - version: asString(record, "version", context) - }; - }); -}; - //#endregion //#region src/github.ts const COMMIT_MUTATION = ` @@ -207,7 +379,12 @@ const createGitHubApi = (options) => { query, variables }), - tagExists: async (tag) => (await request(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET")).ok, + tagExists: async (tag) => { + const response = await request(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET"); + if (response.status === 404) return false; + if (!response.ok) throw new Error(`failed to check git tag "${tag}": GitHub returned ${response.status}`); + return true; + }, createRef: (ref, sha) => request(`/repos/${repository}/git/refs`, "POST", { ref, sha @@ -274,41 +451,98 @@ const apiFromEnv = () => createGitHubApi({ //#endregion //#region src/commands/github-releases.ts +const readSummary = (path) => existsSync(path) ? parsePublishSummary(readFileSync(path, "utf8")) : []; const githubReleases = { - usage: " ", + usage: " ", run: async (argv) => { - const [summaryPath, workspacePath] = argv; - if (!summaryPath || !workspacePath) throw new Error("usage: github-releases "); + const [publishedSummaryPath, stagedSummaryPath, workspacePath] = argv; + if (!publishedSummaryPath || !stagedSummaryPath || !workspacePath) throw new Error("usage: github-releases "); await releasePublishedPackages({ api: apiFromEnv(), sha: requireEnv("GITHUB_SHA"), - published: parsePublishSummary(readFileSync(summaryPath, "utf8")), + published: readSummary(publishedSummaryPath), + staged: readSummary(stagedSummaryPath), workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")) }); } }; //#endregion -//#region src/semver.ts -const SEMVER = /^\d+\.\d+\.\d+$/; -const isReleaseVersion = (version) => SEMVER.test(version); -const parseVersion = (version) => { - const [core = "", ...prerelease] = version.split("-"); - const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); +//#region src/npm-publishing.ts +const packageUrl = (registryUrl, packageName) => { + const registry = new URL(registryUrl); + if (!registry.pathname.endsWith("/")) registry.pathname += "/"; + const encodedName = encodeURIComponent(packageName).replace(/^%40/, "@"); + return new URL(encodedName, registry); +}; +const packageExistsOnRegistry = async (packageName, registryUrl, request) => { + const response = await request(packageUrl(registryUrl, packageName), { headers: { accept: "application/vnd.npm.install-v1+json" } }); + if (response.status === 404) return false; + if (!response.ok) { + const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status); + throw new Error(`npm registry lookup for "${packageName}" failed: ${status}`); + } + return true; +}; +const planNpmPublishing = async (workspace, stagedPublishing, packageExists, releaseTagExists) => { + const publicPackages = workspace.filter((workspacePackage) => !workspacePackage.private); + const submissionState = await Promise.all(publicPackages.map(async (workspacePackage) => ({ + workspacePackage, + submitted: await releaseTagExists(packageReleaseTag(workspacePackage.name, workspacePackage.version)) + }))); + const previouslySubmittedPackages = submissionState.filter(({ submitted }) => submitted).map(({ workspacePackage: { name, version } }) => ({ + name, + version + })); + const pendingPackages = submissionState.filter(({ submitted }) => !submitted).map(({ workspacePackage }) => workspacePackage); + const existence = await Promise.all(pendingPackages.map(async (workspacePackage) => ({ + name: workspacePackage.name, + exists: await packageExists(workspacePackage.name) + }))); + const firstReleasePackages = existence.filter(({ exists }) => !exists).map(({ name }) => name); + const directPackages = stagedPublishing ? firstReleasePackages : existence.map(({ name }) => name); + const stagedPackages = stagedPublishing ? existence.filter(({ exists }) => exists).map(({ name }) => name) : []; + let mode; + if (directPackages.length > 0) mode = stagedPackages.length > 0 ? "mixed" : "direct"; + else if (stagedPackages.length > 0) mode = "staged"; + else mode = "none"; return { - major, - minor, - patch, - prerelease: prerelease.join("-") + mode, + directPackages, + firstReleasePackages, + previouslySubmittedPackages, + stagedPackages }; }; -const bumpType = (from, to) => { - const a = parseVersion(from); - const b = parseVersion(to); - if (b.prerelease || a.prerelease) return "prerelease"; - if (b.major !== a.major) return "major"; - if (b.minor !== a.minor) return "minor"; - return "patch"; + +//#endregion +//#region src/commands/npm-publishing-mode.ts +const parseBoolean = (value) => { + if (value === "true") return true; + if (value === "false") return false; + throw new Error(`npm-publishing-mode: expected "true" or "false", got "${value}"`); +}; +const npmPublishingMode = { + usage: " ", + run: async (argv) => { + const [workspacePath, registryUrl, rawStagedPublishing, firstReleasesPath, directPackagesPath, stagedPackagesPath] = argv; + if (!workspacePath || !registryUrl || !rawStagedPublishing || !firstReleasesPath || !directPackagesPath || !stagedPackagesPath) throw new Error("usage: npm-publishing-mode "); + const stagedPublishing = parseBoolean(rawStagedPublishing); + const api = apiFromEnv(); + const plan = await planNpmPublishing(parseWorkspacePackages(readFileSync(workspacePath, "utf8")), stagedPublishing, (packageName) => packageExistsOnRegistry(packageName, registryUrl, (url, init) => fetch(url, { headers: init.headers })), (tag) => api.tagExists(tag)); + if (plan.previouslySubmittedPackages.length > 0) console.error(`Skipping versions already recorded by immutable release tags: ${plan.previouslySubmittedPackages.map(({ name, version }) => packageReleaseTag(name, version)).join(", ")}`); + if (plan.firstReleasePackages.length > 0) console.error(`Regular npm publishing is required for first release: ${plan.firstReleasePackages.join(", ")}`); + writeFileSync(firstReleasesPath, plan.firstReleasePackages.map((packageName) => `${packageName}\n`).join("")); + writeFileSync(directPackagesPath, plan.directPackages.map((packageName) => `${packageName}\n`).join("")); + writeFileSync(stagedPackagesPath, plan.stagedPackages.map((packageName) => `${packageName}\n`).join("")); + process.stdout.write([ + `mode=${plan.mode}`, + `direct=${plan.directPackages.length > 0}`, + `stage=${plan.stagedPackages.length > 0}`, + `first_release=${plan.firstReleasePackages.length > 0}`, + "" + ].join("\n")); + } }; //#endregion @@ -570,7 +804,9 @@ const signedCommit = { //#endregion //#region src/commands/index.ts const commands = { + "contract-version": contractVersion, "github-releases": githubReleases, + "npm-publishing-mode": npmPublishingMode, "release-pr-body": releasePrBody, "shared-workflows-release": sharedWorkflowsRelease, "signed-commit": signedCommit diff --git a/.github/workflows-examples/README.md b/.github/workflows-examples/README.md index 0f499dc..5ef6073 100644 --- a/.github/workflows-examples/README.md +++ b/.github/workflows-examples/README.md @@ -5,7 +5,7 @@ Copy-paste callers for the reusable workflows published from this repository. | File | Calls | Purpose | | :----------------------------------- | :--------------------- | :------------------------------------------------------------------------- | | [`ci.yml`](./ci.yml) | `shared-ci.yml` | Lint, format, typecheck, build, test matrix, Playwright, dependency review | -| [`release.yml`](./release.yml) | `shared-release.yml` | Release pull request, npm publish, git tags, GitHub release | +| [`release.yml`](./release.yml) | `shared-release.yml` | Release pull request, npm submission, git tags, GitHub release | | [`codeql.yml`](./codeql.yml) | `shared-codeql.yml` | CodeQL analysis | | [`scorecard.yml`](./scorecard.yml) | `shared-scorecard.yml` | OpenSSF Scorecard | | [`zizmor.yml`](./zizmor.yml) | `shared-zizmor.yml` | Blocking security lint for GitHub Actions and Dependabot | @@ -38,21 +38,46 @@ Dependabot rewrites both the SHA and the trailing `# v1` comment from then on. When it updates `release.yml`, keep `shared-tooling-ref` equal to the SHA in the `uses:` line so the release scripts and reusable workflow stay on one revision. +`contract-version-package` is empty by default. Set it to a private package's +manifest only when that package versions a release contract but is never +published to npm. The release workflow advances it from its matching change +intents before pnpm prepares the release pull request. + ## Release setup `shared-release.yml` expects [`pnpm change`](https://pnpm.io) intents on `main`. On every push it either opens/refreshes a `release/main` pull request, or — when -no intents are pending — publishes, tags and creates a combined GitHub release. +no intents are pending — stages packages on npm, tags them and creates a combined +GitHub release. A maintainer must then review and approve each staged package +with 2FA before it becomes available from npm. If any publishable workspace +package does not exist in the registry, the workflow publishes that package +regularly so it can be created while still staging updates to existing packages. + +Submission is the immutable release boundary. The workflow tags both directly +published and staged package versions immediately. Approval only controls npm +availability: rejecting a staged package does not roll back its release or let a +later run reuse that version. Record a new change intent so the next attempt uses +the next version. + +[Staged publishing](https://docs.npmjs.com/staged-publishing/) is the default. +Set `staged-publishing: false` in the caller's `with:` block when packages must +always publish immediately. npm cannot stage a package that does not exist yet, +so first-release detection overrides the staged default for that package. -For npm **trusted publishing**: +For npm [**trusted publishing**](https://docs.npmjs.com/trusted-publishers/): - Keep the caller named `release.yml`. npm validates the calling workflow's filename, not the reusable workflow that runs the publish. - Register the trusted publisher per package with the _consumer_ repository and `release.yml`. +- Configure each existing package's trusted publisher to allow only + `npm stage publish` for the default behavior. Consumers that disable staged + publishing must allow `npm publish` instead (or allow both actions). - `id-token: write` must be granted by the caller job, which the example does. -- Keep `NPM_TOKEN` until every package exists on npm; a trusted publisher cannot - be configured for a package that was never published. +- Pass `NPM_TOKEN` as the optional reusable-workflow secret until every package + exists on npm. It is exposed only to regular publishing and is required when + first-release detection adds the package-creation step. After the first release, + configure that package's stage-only trusted publisher. - `repository.url` in each `package.json` must match the repository exactly. ## Repository settings diff --git a/.github/workflows-examples/release.yml b/.github/workflows-examples/release.yml index 1b114d0..07fb62b 100644 --- a/.github/workflows-examples/release.yml +++ b/.github/workflows-examples/release.yml @@ -1,5 +1,5 @@ # Keep this file named `release.yml`: npm trusted publishing validates the -# *calling* workflow filename, not the reusable workflow that runs `npm publish`. +# *calling* workflow filename, not the reusable workflow that submits packages. name: Release permissions: {} @@ -31,10 +31,12 @@ jobs: # base-branch: main # release-branch: release/main # release-title: "chore(release): version packages" + # contract-version-package: "" # Private workflow/tooling contract, if any. # build-script: build # publint-script: lint-publish + # staged-publishing: true # Set false when every release should publish directly. # registry-url: "https://registry.npmjs.org" secrets: - # Only needed until every package exists on npm: a trusted publisher - # cannot be configured for a package that has never been published. + # Optional after every package exists. First releases automatically use + # regular publishing and require this repository secret. NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f4f43..6d6bd1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: shared-version: name: Shared workflow contract runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30f65d0..b2abf9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ jobs: with: shared-tooling-repository: ${{ github.repository }} shared-tooling-ref: ${{ github.sha }} + # pnpm does not advance this private, unpublished contract package. + contract-version-package: internal/gha/package.json secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -34,6 +36,7 @@ jobs: needs: release if: needs.release.outputs.pending == 'false' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write # publish the shared workflow tags and GitHub release diff --git a/.github/workflows/shared-ci.yml b/.github/workflows/shared-ci.yml index 2d5611c..f9c32a5 100644 --- a/.github/workflows/shared-ci.yml +++ b/.github/workflows/shared-ci.yml @@ -82,6 +82,7 @@ jobs: quality: name: Lint & Format runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read @@ -124,6 +125,7 @@ jobs: test: name: "Build & Test: ${{ matrix.os }}" runs-on: ${{ matrix.os }} + timeout-minutes: 15 permissions: contents: read @@ -180,6 +182,7 @@ jobs: name: Browser Tests if: inputs.browser-test-script != '' runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read @@ -235,6 +238,7 @@ jobs: name: Dependency Review if: inputs.dependency-review && github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/shared-codeql.yml b/.github/workflows/shared-codeql.yml index 909a279..e7c11f9 100644 --- a/.github/workflows/shared-codeql.yml +++ b/.github/workflows/shared-codeql.yml @@ -18,6 +18,7 @@ jobs: analyze: name: "Analyze: ${{ matrix.language }}" runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read # checkout the caller repository for analysis actions: read # let CodeQL read workflow-run metadata diff --git a/.github/workflows/shared-release.yml b/.github/workflows/shared-release.yml index 23cdb53..e95d25c 100644 --- a/.github/workflows/shared-release.yml +++ b/.github/workflows/shared-release.yml @@ -1,7 +1,7 @@ name: Shared Release # Shared release pipeline: opens/refreshes a release pull request while intents -# are pending, and publishes to npm once main holds the released versions. +# are pending, and submits packages to npm once main holds the released versions. # Call it from a repository with: # uses: zemd/js/.github/workflows/shared-release.yml@ # v1 # @@ -33,6 +33,10 @@ on: description: Commit message and title of the release pull request. type: string default: "chore(release): version packages" + contract-version-package: + description: Private package manifest whose version is advanced manually from matching change intents. + type: string + default: "" base-branch: description: Branch the release pull request targets. type: string @@ -45,17 +49,21 @@ on: description: package.json script that validates publishable packages. Empty skips the step. type: string default: "lint-publish" + staged-publishing: + description: Stage existing packages for npm approval without making rejection roll back the submitted version. First releases publish directly. + type: boolean + default: true registry-url: - description: Registry written to .npmrc so the NODE_AUTH_TOKEN fallback works. + description: Registry used for package-existence checks and written to .npmrc for publishing. type: string default: "https://registry.npmjs.org" secrets: NPM_TOKEN: - description: Fallback token for the first publish of a package that does not exist on npm yet. + description: Optional authentication for regular publishing; required when a package does not exist in the registry yet. required: false outputs: pending: - description: "'true' when the run opened or refreshed a release pull request instead of publishing." + description: "'true' when the run opened or refreshed a release pull request instead of submitting packages." value: ${{ jobs.version.outputs.pending }} # Not inherited from the caller: `env` defined at the caller's workflow level is @@ -72,6 +80,7 @@ jobs: version: name: Version runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write # create or update the release branch pull-requests: write # open and refresh the release pull request @@ -84,6 +93,21 @@ jobs: fetch-depth: 0 persist-credentials: false + # Kept out of git's view so it never lands in the release commit that + # the signed-commit command builds from the working tree. + - name: Ignore the shared tooling checkout + run: echo "/.shared-ci/" >> .git/info/exclude + + - name: Checkout shared tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The caller must pass the repository and commit SHA matching its + # pinned workflow reference, so the scripts use the same revision. + repository: ${{ inputs.shared-tooling-repository }} + ref: ${{ inputs.shared-tooling-ref }} + path: .shared-ci + persist-credentials: false + - name: Setup pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: @@ -98,36 +122,42 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile --prefer-offline + # pnpm preserves the version of a private package that is absent from the + # registry. Prepare that contract version explicitly before it consumes + # the intents, then repair its same-version entry in the release report. + - name: Prepare private contract version + if: inputs.contract-version-package != '' + env: + CONTRACT_VERSION_PACKAGE: ${{ inputs.contract-version-package }} + run: | + node "${SHARED_CLI}" contract-version prepare \ + "$CONTRACT_VERSION_PACKAGE" \ + .changeset \ + "${RUNNER_TEMP}/contract-version.json" + # Consumes the change intents in .changeset/, bumps every affected package # and its workspace dependents, and writes the changelog entries. - name: Apply pending release intents - id: version run: | - pnpm version -r --json > "${RUNNER_TEMP}/releases.json" + pnpm version -r --json --no-git-checks > "${RUNNER_TEMP}/releases.json" pnpm install --lockfile-only + + - name: Finalize private contract version + if: inputs.contract-version-package != '' + run: | + node "${SHARED_CLI}" contract-version finalize \ + "${RUNNER_TEMP}/contract-version.json" \ + "${RUNNER_TEMP}/releases.json" + + - name: Detect pending release + id: version + run: | if [ -n "$(git status --porcelain)" ]; then echo "pending=true" >> "$GITHUB_OUTPUT" else echo "pending=false" >> "$GITHUB_OUTPUT" fi - # Kept out of git's view so it never lands in the release commit that - # signed-commit.mjs builds from the working tree. - - name: Ignore the shared tooling checkout - if: steps.version.outputs.pending == 'true' - run: echo "/.shared-ci/" >> .git/info/exclude - - - name: Checkout shared tooling - if: steps.version.outputs.pending == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # The caller must pass the repository and commit SHA matching its - # pinned workflow reference, so the scripts use the same revision. - repository: ${{ inputs.shared-tooling-repository }} - ref: ${{ inputs.shared-tooling-ref }} - path: .shared-ci - persist-credentials: false - - name: Render release pull request body if: steps.version.outputs.pending == 'true' run: | @@ -159,12 +189,13 @@ jobs: fi # No intents were pending, so main already holds the released versions. - # `pnpm publish -r` skips anything the registry already serves. + # Both recursive publish modes skip anything the registry already serves. publish: - name: Publish + name: Submit packages to npm needs: version if: needs.version.outputs.pending == 'false' runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write # create git tags and GitHub releases id-token: write # npm trusted publishing (OIDC) @@ -174,6 +205,14 @@ jobs: with: persist-credentials: false + - name: Checkout shared tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.shared-tooling-repository }} + ref: ${{ inputs.shared-tooling-ref }} + path: .shared-ci + persist-credentials: false + - name: Setup pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: @@ -203,27 +242,69 @@ jobs: SCRIPT: ${{ inputs.publint-script }} run: pnpm run "$SCRIPT" - - name: Publish to npm + # npm cannot stage a package that does not exist in the registry. Keep + # existing packages on stage-only OIDC while routing only first releases + # through regular publishing with the optional token. + - name: Select npm publishing mode + id: publishing + env: + GITHUB_TOKEN: ${{ github.token }} + REGISTRY_URL: ${{ inputs.registry-url }} + STAGED_PUBLISHING: ${{ inputs.staged-publishing }} + run: | + pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/workspace.json" + node "${SHARED_CLI}" npm-publishing-mode \ + "${RUNNER_TEMP}/workspace.json" \ + "$REGISTRY_URL" \ + "$STAGED_PUBLISHING" \ + "${RUNNER_TEMP}/first-releases.txt" \ + "${RUNNER_TEMP}/direct-packages.txt" \ + "${RUNNER_TEMP}/staged-packages.txt" >> "$GITHUB_OUTPUT" + + - name: Publish packages to npm directly + if: steps.publishing.outputs.direct == 'true' env: - # pnpm prefers OIDC when it succeeds and falls back to this token for - # the first publish of a package that does not exist in npm yet. + DIRECT_PACKAGES_FILE: ${{ runner.temp }}/direct-packages.txt + FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm publish -r --access public --no-git-checks --report-summary + run: | + if [ "$FIRST_RELEASE" = "true" ] && [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::error::NPM_TOKEN is required to publish a package that does not exist in the registry." + exit 1 + fi - - name: Checkout shared tooling - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ inputs.shared-tooling-repository }} - ref: ${{ inputs.shared-tooling-ref }} - path: .shared-ci - persist-credentials: false + mapfile -t packages < "$DIRECT_PACKAGES_FILE" + filters=() + for package in "${packages[@]}"; do + filters+=("--filter=$package") + done + + pnpm publish -r "${filters[@]}" --access public --no-git-checks --report-summary + if [ -f pnpm-publish-summary.json ]; then + mv pnpm-publish-summary.json "${RUNNER_TEMP}/published-summary.json" + fi + + - name: Stage packages on npm + if: steps.publishing.outputs.stage == 'true' + env: + STAGED_PACKAGES_FILE: ${{ runner.temp }}/staged-packages.txt + run: | + mapfile -t packages < "$STAGED_PACKAGES_FILE" + filters=() + for package in "${packages[@]}"; do + filters+=("--filter=$package") + done + + pnpm stage publish -r "${filters[@]}" --access public --no-git-checks --report-summary + mv pnpm-publish-summary.json "${RUNNER_TEMP}/staged-summary.json" - # pnpm only talks to the registry, so tags and the release are created here. + # Submission is the immutable release point. Tag direct and staged + # versions alike so rejection cannot cause a later run to reuse one. - name: Tag packages and create GitHub release env: GITHUB_TOKEN: ${{ github.token }} run: | - pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/versions.json" node "${SHARED_CLI}" github-releases \ - pnpm-publish-summary.json \ - "${RUNNER_TEMP}/versions.json" + "${RUNNER_TEMP}/published-summary.json" \ + "${RUNNER_TEMP}/staged-summary.json" \ + "${RUNNER_TEMP}/workspace.json" diff --git a/.github/workflows/shared-scorecard.yml b/.github/workflows/shared-scorecard.yml index fb8cba2..135335c 100644 --- a/.github/workflows/shared-scorecard.yml +++ b/.github/workflows/shared-scorecard.yml @@ -22,6 +22,7 @@ jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read # checkout the caller repository for analysis actions: read # inspect workflow runs for dangerous patterns diff --git a/internal/gha/README.md b/internal/gha/README.md index abca312..fd6b651 100644 --- a/internal/gha/README.md +++ b/internal/gha/README.md @@ -15,10 +15,17 @@ which is what CI keys on to require a release intent for this package. Its version is the shared workflow contract version: the release workflow tags `vX.Y.Z` and moves `vX` to match. +For package releases, each `name@version` tag is the immutable submission +record. The publishing planner skips tagged versions even when a staged version +was rejected on npm; another release must advance the package version. + ## Commands -``` -gha.mjs github-releases +```text +gha.mjs contract-version prepare +gha.mjs contract-version finalize +gha.mjs github-releases +gha.mjs npm-publishing-mode gha.mjs release-pr-body gha.mjs shared-workflows-release gha.mjs signed-commit diff --git a/internal/gha/src/commands/contract-version.test.ts b/internal/gha/src/commands/contract-version.test.ts new file mode 100644 index 0000000..9080387 --- /dev/null +++ b/internal/gha/src/commands/contract-version.test.ts @@ -0,0 +1,70 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { contractVersion } from "./contract-version"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("prepares a private contract bump and corrects pnpm's release report", async () => { + const directory = mkdtempSync(join(tmpdir(), "contract-version-")); + directories.push(directory); + const intents = join(directory, ".changeset"); + const manifest = join(directory, "package.json"); + const state = join(directory, "contract-version.json"); + const releases = join(directory, "releases.json"); + mkdirSync(intents); + writeFileSync( + manifest, + '{\n "name": "@zemd/gha",\n "version": "1.0.0",\n "private": true\n}\n', + ); + writeFileSync( + join(intents, "fix.md"), + '---\n"@zemd/gha": patch\n---\n\nFix the release contract.\n', + ); + + await contractVersion.run(["prepare", manifest, intents, state]); + + expect(JSON.parse(readFileSync(manifest, "utf8"))).toMatchObject({ version: "1.0.1" }); + expect(JSON.parse(readFileSync(state, "utf8"))).toEqual({ + name: "@zemd/gha", + currentVersion: "1.0.0", + newVersion: "1.0.1", + bump: "patch", + intentIds: ["fix"], + }); + + writeFileSync( + releases, + JSON.stringify([{ name: "@zemd/gha", currentVersion: "1.0.1", newVersion: "1.0.1" }]), + ); + await contractVersion.run(["finalize", state, releases]); + + expect(JSON.parse(readFileSync(releases, "utf8"))).toEqual([ + { name: "@zemd/gha", currentVersion: "1.0.0", newVersion: "1.0.1" }, + ]); +}); + +test("writes a no-op state when no intent targets the configured package", async () => { + const directory = mkdtempSync(join(tmpdir(), "contract-version-")); + directories.push(directory); + const intents = join(directory, ".changeset"); + const manifest = join(directory, "package.json"); + const state = join(directory, "contract-version.json"); + mkdirSync(intents); + const source = '{\n "name": "@zemd/gha",\n "version": "1.0.0",\n "private": true\n}\n'; + writeFileSync(manifest, source); + writeFileSync(join(intents, "other.md"), "---\nother: patch\n---\n"); + + await contractVersion.run(["prepare", manifest, intents, state]); + + expect(readFileSync(manifest, "utf8")).toBe(source); + expect(readFileSync(state, "utf8")).toBe("null\n"); +}); diff --git a/internal/gha/src/commands/contract-version.ts b/internal/gha/src/commands/contract-version.ts new file mode 100644 index 0000000..d9e13fd --- /dev/null +++ b/internal/gha/src/commands/contract-version.ts @@ -0,0 +1,137 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { + planContractVersion, + reconcileContractRelease, + type ChangeIntent, + type ContractVersionPlan, +} from "../contract-version"; +import { parseAppliedReleases } from "../pnpm"; +import type { Command } from "./command"; + +interface PackageManifest { + readonly name: string; + readonly version: string; + readonly private: true; +} + +const parseManifest = (source: string, path: string): PackageManifest => { + const value: unknown = JSON.parse(source); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path}: expected a package manifest object`); + } + + const manifest = value as Record; + if (typeof manifest["name"] !== "string" || typeof manifest["version"] !== "string") { + throw new Error(`${path}: expected string name and version fields`); + } + if (manifest["private"] !== true) { + throw new Error(`${path}: contract version preparation is restricted to private packages`); + } + + return { + name: manifest["name"], + version: manifest["version"], + private: true, + }; +}; + +const replaceManifestVersion = ( + source: string, + currentVersion: string, + newVersion: string, + path: string, +): string => { + const property = /("version"\s*:\s*")([^"]*)(")/g; + const matches = [...source.matchAll(property)]; + if (matches.length !== 1 || matches[0]?.[2] !== currentVersion) { + throw new Error(`${path}: could not replace the unique version field`); + } + return source.replace(property, `$1${newVersion}$3`); +}; + +const readIntents = (directory: string): readonly ChangeIntent[] => + readdirSync(directory) + .filter((file) => file.endsWith(".md") && file.toLowerCase() !== "readme.md") + .sort() + .map((file) => ({ + id: basename(file, ".md"), + source: readFileSync(join(directory, file), "utf8"), + })); + +const parsePlan = (source: string, path: string): ContractVersionPlan | undefined => { + const value: unknown = JSON.parse(source); + if (value === null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${path}: expected a contract version plan or null`); + } + + const plan = value as Record; + const bump = plan["bump"]; + if ( + typeof plan["name"] !== "string" || + typeof plan["currentVersion"] !== "string" || + typeof plan["newVersion"] !== "string" || + (bump !== "major" && bump !== "minor" && bump !== "patch") || + !Array.isArray(plan["intentIds"]) || + !plan["intentIds"].every((id) => typeof id === "string") + ) { + throw new Error(`${path}: invalid contract version plan`); + } + + return { + name: plan["name"], + currentVersion: plan["currentVersion"], + newVersion: plan["newVersion"], + bump, + intentIds: plan["intentIds"], + }; +}; + +const prepare = (packagePath: string, intentsDirectory: string, statePath: string): void => { + const manifestSource = readFileSync(packagePath, "utf8"); + const manifest = parseManifest(manifestSource, packagePath); + const plan = planContractVersion(manifest, readIntents(intentsDirectory)); + + writeFileSync(statePath, `${JSON.stringify(plan ?? null, undefined, 2)}\n`); + if (!plan) return; + + writeFileSync( + packagePath, + replaceManifestVersion(manifestSource, plan.currentVersion, plan.newVersion, packagePath), + ); +}; + +const finalize = (statePath: string, releasesPath: string): void => { + const plan = parsePlan(readFileSync(statePath, "utf8"), statePath); + if (!plan) return; + + const releases = parseAppliedReleases(readFileSync(releasesPath, "utf8")); + writeFileSync( + releasesPath, + `${JSON.stringify(reconcileContractRelease(releases, plan), undefined, 2)}\n`, + ); +}; + +export const contractVersion: Command = { + usage: + "prepare | finalize ", + run: (argv) => { + const [operation, firstPath, secondPath, thirdPath] = argv; + + if (operation === "prepare" && firstPath && secondPath && thirdPath) { + prepare(firstPath, secondPath, thirdPath); + return; + } + if (operation === "finalize" && firstPath && secondPath && !thirdPath) { + finalize(firstPath, secondPath); + return; + } + + throw new Error( + "usage: contract-version prepare | " + + "finalize ", + ); + }, +}; diff --git a/internal/gha/src/commands/github-releases.ts b/internal/gha/src/commands/github-releases.ts index 9722f4f..7b33551 100644 --- a/internal/gha/src/commands/github-releases.ts +++ b/internal/gha/src/commands/github-releases.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { requireEnv } from "../env"; import { releasePublishedPackages } from "../github-releases"; @@ -6,21 +6,27 @@ import { parsePublishSummary, parseWorkspacePackages } from "../pnpm"; import type { Command } from "./command"; import { apiFromEnv } from "./context"; -// Tags the published commit once per package and publishes a single combined -// GitHub release for the run. +const readSummary = (path: string) => + existsSync(path) ? parsePublishSummary(readFileSync(path, "utf8")) : []; + +// Tags the submitted commit once per package and publishes a single combined +// GitHub release that distinguishes published and staged versions. export const githubReleases: Command = { - usage: " ", + usage: " ", run: async (argv) => { - const [summaryPath, workspacePath] = argv; + const [publishedSummaryPath, stagedSummaryPath, workspacePath] = argv; - if (!summaryPath || !workspacePath) { - throw new Error("usage: github-releases "); + if (!publishedSummaryPath || !stagedSummaryPath || !workspacePath) { + throw new Error( + "usage: github-releases ", + ); } await releasePublishedPackages({ api: apiFromEnv(), sha: requireEnv("GITHUB_SHA"), - published: parsePublishSummary(readFileSync(summaryPath, "utf8")), + published: readSummary(publishedSummaryPath), + staged: readSummary(stagedSummaryPath), workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), }); }, diff --git a/internal/gha/src/commands/index.test.ts b/internal/gha/src/commands/index.test.ts index fc4646c..b544e1c 100644 --- a/internal/gha/src/commands/index.test.ts +++ b/internal/gha/src/commands/index.test.ts @@ -4,7 +4,9 @@ import { commands, usage } from "./index"; test("exposes every release step the shared workflows need", () => { expect(Object.keys(commands).sort()).toEqual([ + "contract-version", "github-releases", + "npm-publishing-mode", "release-pr-body", "shared-workflows-release", "signed-commit", diff --git a/internal/gha/src/commands/index.ts b/internal/gha/src/commands/index.ts index 688d708..d3ad35b 100644 --- a/internal/gha/src/commands/index.ts +++ b/internal/gha/src/commands/index.ts @@ -1,11 +1,15 @@ import type { Command } from "./command"; +import { contractVersion } from "./contract-version"; import { githubReleases } from "./github-releases"; +import { npmPublishingMode } from "./npm-publishing-mode"; import { releasePrBody } from "./release-pr-body"; import { sharedWorkflowsRelease } from "./shared-workflows-release"; import { signedCommit } from "./signed-commit"; export const commands: Readonly> = { + "contract-version": contractVersion, "github-releases": githubReleases, + "npm-publishing-mode": npmPublishingMode, "release-pr-body": releasePrBody, "shared-workflows-release": sharedWorkflowsRelease, "signed-commit": signedCommit, diff --git a/internal/gha/src/commands/npm-publishing-mode.ts b/internal/gha/src/commands/npm-publishing-mode.ts new file mode 100644 index 0000000..b63dd88 --- /dev/null +++ b/internal/gha/src/commands/npm-publishing-mode.ts @@ -0,0 +1,94 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +import { packageExistsOnRegistry, planNpmPublishing } from "../npm-publishing"; +import { parseWorkspacePackages } from "../pnpm"; +import { packageReleaseTag } from "../release-tags"; +import type { Command } from "./command"; +import { apiFromEnv } from "./context"; + +const parseBoolean = (value: string): boolean => { + if (value === "true") return true; + if (value === "false") return false; + throw new Error(`npm-publishing-mode: expected "true" or "false", got "${value}"`); +}; + +// Staging cannot create a package, so a run containing any first release must +// use regular publishing. The command writes values ready for GITHUB_OUTPUT. +export const npmPublishingMode: Command = { + usage: + " ", + run: async (argv) => { + const [ + workspacePath, + registryUrl, + rawStagedPublishing, + firstReleasesPath, + directPackagesPath, + stagedPackagesPath, + ] = argv; + + if ( + !workspacePath || + !registryUrl || + !rawStagedPublishing || + !firstReleasesPath || + !directPackagesPath || + !stagedPackagesPath + ) { + throw new Error( + "usage: npm-publishing-mode ", + ); + } + + const stagedPublishing = parseBoolean(rawStagedPublishing); + const api = apiFromEnv(); + const plan = await planNpmPublishing( + parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + stagedPublishing, + (packageName) => + packageExistsOnRegistry(packageName, registryUrl, (url, init) => + fetch(url, { headers: init.headers }), + ), + (tag) => api.tagExists(tag), + ); + + if (plan.previouslySubmittedPackages.length > 0) { + console.error( + `Skipping versions already recorded by immutable release tags: ${plan.previouslySubmittedPackages + .map(({ name, version }) => packageReleaseTag(name, version)) + .join(", ")}`, + ); + } + + if (plan.firstReleasePackages.length > 0) { + console.error( + `Regular npm publishing is required for first release: ${plan.firstReleasePackages.join( + ", ", + )}`, + ); + } + + writeFileSync( + firstReleasesPath, + plan.firstReleasePackages.map((packageName) => `${packageName}\n`).join(""), + ); + writeFileSync( + directPackagesPath, + plan.directPackages.map((packageName) => `${packageName}\n`).join(""), + ); + writeFileSync( + stagedPackagesPath, + plan.stagedPackages.map((packageName) => `${packageName}\n`).join(""), + ); + + process.stdout.write( + [ + `mode=${plan.mode}`, + `direct=${plan.directPackages.length > 0}`, + `stage=${plan.stagedPackages.length > 0}`, + `first_release=${plan.firstReleasePackages.length > 0}`, + "", + ].join("\n"), + ); + }, +}; diff --git a/internal/gha/src/contract-version.test.ts b/internal/gha/src/contract-version.test.ts new file mode 100644 index 0000000..d5c8c57 --- /dev/null +++ b/internal/gha/src/contract-version.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "vitest"; + +import { + bumpContractVersion, + planContractVersion, + reconcileContractRelease, +} from "./contract-version"; + +test.each([ + ["1.2.3", "patch", "1.2.4"], + ["1.2.3", "minor", "1.3.0"], + ["1.2.3", "major", "2.0.0"], +] as const)("applies a %s contract bump", (version, bump, expected) => { + expect(bumpContractVersion(version, bump)).toBe(expected); +}); + +test("plans the highest bump across matching change intents", () => { + const plan = planContractVersion({ name: "@zemd/gha", version: "1.0.0" }, [ + { id: "patch-one", source: '---\n"@zemd/gha": patch\n---\n\nPatch.\n' }, + { id: "unrelated", source: "---\nother: major\n---\n\nOther.\n" }, + { id: "minor-one", source: "---\n'@zemd/gha': 'minor'\n---\n\nMinor.\n" }, + ]); + + expect(plan).toEqual({ + name: "@zemd/gha", + currentVersion: "1.0.0", + newVersion: "1.1.0", + bump: "minor", + intentIds: ["minor-one", "patch-one"], + }); +}); + +test("does not plan a bump without a matching release intent", () => { + expect( + planContractVersion({ name: "@zemd/gha", version: "1.0.0" }, [ + { id: "unrelated", source: "---\nother: patch\n---\n" }, + ]), + ).toBeUndefined(); +}); + +test("restores the real old version in pnpm's same-version result", () => { + const releases = reconcileContractRelease( + [ + { name: "public-package", currentVersion: "2.0.0", newVersion: "2.0.1" }, + { name: "@zemd/gha", currentVersion: "1.0.1", newVersion: "1.0.1" }, + ], + { + name: "@zemd/gha", + currentVersion: "1.0.0", + newVersion: "1.0.1", + bump: "patch", + intentIds: ["fix"], + }, + ); + + expect(releases).toEqual([ + { name: "public-package", currentVersion: "2.0.0", newVersion: "2.0.1" }, + { name: "@zemd/gha", currentVersion: "1.0.0", newVersion: "1.0.1" }, + ]); +}); + +test("accepts pnpm reporting the intended transition itself", () => { + const releases = [{ name: "@zemd/gha", currentVersion: "1.0.0", newVersion: "1.0.1" }]; + expect( + reconcileContractRelease(releases, { + name: "@zemd/gha", + currentVersion: "1.0.0", + newVersion: "1.0.1", + bump: "patch", + intentIds: ["fix"], + }), + ).toBe(releases); +}); + +test("rejects an unexpected pnpm transition", () => { + expect(() => + reconcileContractRelease( + [{ name: "@zemd/gha", currentVersion: "1.0.1", newVersion: "1.0.2" }], + { + name: "@zemd/gha", + currentVersion: "1.0.0", + newVersion: "1.0.1", + bump: "patch", + intentIds: ["fix"], + }, + ), + ).toThrow(/unexpected @zemd\/gha transition/); +}); diff --git a/internal/gha/src/contract-version.ts b/internal/gha/src/contract-version.ts new file mode 100644 index 0000000..bc01dd8 --- /dev/null +++ b/internal/gha/src/contract-version.ts @@ -0,0 +1,121 @@ +import type { AppliedRelease } from "./pnpm"; +import { isReleaseVersion, parseVersion } from "./semver"; + +export type ContractBump = "major" | "minor" | "patch"; + +export interface ChangeIntent { + readonly id: string; + readonly source: string; +} + +export interface ContractPackage { + readonly name: string; + readonly version: string; +} + +export interface ContractVersionPlan { + readonly name: string; + readonly currentVersion: string; + readonly newVersion: string; + readonly bump: ContractBump; + readonly intentIds: readonly string[]; +} + +const BUMP_PRIORITY: Readonly> = { + patch: 0, + minor: 1, + major: 2, +}; + +const scalar = (value: string): string => { + const trimmed = value.trim(); + const quote = trimmed.at(0); + + if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) { + return trimmed.slice(1, -1); + } + return trimmed; +}; + +const frontmatter = (source: string): string => { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + return match?.[1] ?? ""; +}; + +const packageBump = (source: string, packageName: string): ContractBump | undefined => { + for (const line of frontmatter(source).split(/\r?\n/)) { + const separator = line.indexOf(":"); + if (separator < 0 || scalar(line.slice(0, separator)) !== packageName) continue; + + const bump = scalar(line.slice(separator + 1)); + if (bump === "major" || bump === "minor" || bump === "patch") return bump; + } + + return undefined; +}; + +export const bumpContractVersion = (version: string, bump: ContractBump): string => { + if (!isReleaseVersion(version)) { + throw new Error(`contract version must be plain semver, got "${version}"`); + } + + const parsed = parseVersion(version); + if (bump === "major") return `${parsed.major + 1}.0.0`; + if (bump === "minor") return `${parsed.major}.${parsed.minor + 1}.0`; + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +}; + +export const planContractVersion = ( + manifest: ContractPackage, + intents: readonly ChangeIntent[], +): ContractVersionPlan | undefined => { + const releases = intents.flatMap((intent) => { + const bump = packageBump(intent.source, manifest.name); + return bump === undefined ? [] : [{ id: intent.id, bump }]; + }); + if (releases.length === 0) return undefined; + + const bump = releases.reduce( + (highest, release) => + BUMP_PRIORITY[release.bump] > BUMP_PRIORITY[highest] ? release.bump : highest, + releases[0]?.bump ?? "patch", + ); + + return { + name: manifest.name, + currentVersion: manifest.version, + newVersion: bumpContractVersion(manifest.version, bump), + bump, + intentIds: releases.map(({ id }) => id).sort(), + }; +}; + +export const reconcileContractRelease = ( + releases: readonly AppliedRelease[], + plan: ContractVersionPlan, +): readonly AppliedRelease[] => { + const matching = releases.filter(({ name }) => name === plan.name); + if (matching.length !== 1) { + throw new Error( + `pnpm version reported ${matching.length} releases for ${plan.name}; expected exactly one`, + ); + } + + const release = matching[0]; + if (!release) throw new Error(`pnpm version did not report ${plan.name}`); + + if (release.currentVersion === plan.currentVersion && release.newVersion === plan.newVersion) { + return releases; + } + if (release.currentVersion !== plan.newVersion || release.newVersion !== plan.newVersion) { + throw new Error( + `pnpm version reported an unexpected ${plan.name} transition: ` + + `${release.currentVersion} -> ${release.newVersion}; expected ` + + `${plan.newVersion} -> ${plan.newVersion}`, + ); + } + + return releases.map((entry) => + entry === release ? { ...entry, currentVersion: plan.currentVersion } : entry, + ); +}; diff --git a/internal/gha/src/github-releases.test.ts b/internal/gha/src/github-releases.test.ts index 294df00..bbf215d 100644 --- a/internal/gha/src/github-releases.test.ts +++ b/internal/gha/src/github-releases.test.ts @@ -24,6 +24,34 @@ test("renders one npm link and one changelog block per package", () => { expect(body).toContain("## What's Changed"); }); +test("labels packages that still require npm staged-publish approval", () => { + const body = renderCombinedReleaseBody({ + published: [], + staged: [{ name: "@acme/one", version: "1.0.0" }], + paths: new Map(), + }); + + expect(body).toContain("## Packages staged on npm"); + expect(body).toContain("require maintainer approval with 2FA"); + expect(body).toContain("does not roll back this release or make its version reusable"); + expect(body).not.toContain("## Published packages"); +}); + +test("separates directly published first releases from staged updates", () => { + const body = renderCombinedReleaseBody({ + published: [{ name: "@acme/new", version: "1.0.0" }], + staged: [{ name: "@acme/existing", version: "2.0.0" }], + paths: new Map(), + }); + + expect(body).toContain("## Published packages"); + expect(body).toContain("| [`@acme/new`](https://www.npmjs.com/package/@acme/new) | `1.0.0` |"); + expect(body).toContain("## Packages staged on npm"); + expect(body).toContain( + "| [`@acme/existing`](https://www.npmjs.com/package/@acme/existing) | `2.0.0` |", + ); +}); + test("builds a minute-stamped release tag", async () => { const github = fakeGitHub(); @@ -50,7 +78,7 @@ test("picks the newest previous combined release", async () => { expect(await previousReleaseTag(github.api)).toBe("release-2026-06-01-0000"); }); -test("tags every published package and creates one combined release", async () => { +test("tags every submitted package and creates one combined release", async () => { const github = fakeGitHub(); await releasePublishedPackages({ @@ -60,16 +88,19 @@ test("tags every published package and creates one combined release", async () = { name: "@acme/two", version: "2.0.0" }, { name: "@acme/one", version: "1.0.0" }, ], + staged: [{ name: "@acme/staged", version: "3.0.0" }], workspace: [], now: NOW, }); expect(github.createdRefs.map((entry) => entry.ref)).toEqual([ "refs/tags/@acme/one@1.0.0", + "refs/tags/@acme/staged@3.0.0", "refs/tags/@acme/two@2.0.0", ]); expect(github.createdReleases[0]?.tag).toBe("release-2026-08-05-0941"); expect(github.createdReleases[0]?.prerelease).toBe(false); + expect(github.createdReleases[0]?.body).toContain("## Packages staged on npm"); }); test("marks the release as a prerelease when every version is one", async () => { diff --git a/internal/gha/src/github-releases.ts b/internal/gha/src/github-releases.ts index 3d4b0cf..2c07f44 100644 --- a/internal/gha/src/github-releases.ts +++ b/internal/gha/src/github-releases.ts @@ -1,32 +1,60 @@ import { changelogEntry } from "./changelog"; import type { GitHubApi } from "./github"; import type { PublishedPackage, WorkspacePackage } from "./pnpm"; +import { packageReleaseTag } from "./release-tags"; const RELEASE_TAG_PREFIX = "release-"; export interface CombinedRelease { readonly published: readonly PublishedPackage[]; + readonly staged?: readonly PublishedPackage[]; readonly paths: ReadonlyMap; readonly notes?: string; } -export const renderCombinedReleaseBody = ({ published, paths, notes }: CombinedRelease): string => { - const out: string[] = []; +const appendPackageTable = ( + out: string[], + heading: string, + packages: readonly PublishedPackage[], + approvalRequired: boolean, +): void => { + if (packages.length === 0) return; - out.push("## Published packages"); + out.push(`## ${heading}`); + if (approvalRequired) { + out.push(""); + out.push( + "These versions require maintainer approval with 2FA before they become available from npm.", + ); + out.push( + "Rejecting one does not roll back this release or make its version reusable; release changes under a new version instead.", + ); + } out.push(""); out.push("| Package | Version |"); out.push("| :--- | ---: |"); - for (const { name, version } of published) { + for (const { name, version } of packages) { out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); } - out.push(""); +}; + +export const renderCombinedReleaseBody = ({ + published, + staged = [], + paths, + notes, +}: CombinedRelease): string => { + const out: string[] = []; + const submitted = [...published, ...staged]; + + appendPackageTable(out, "Published packages", published, false); + appendPackageTable(out, "Packages staged on npm", staged, true); out.push("### Changelogs"); out.push(""); - for (const { name, version } of published) { + for (const { name, version } of submitted) { const packagePath = paths.get(name); out.push("
"); out.push(`${name}@${version}`); @@ -93,31 +121,38 @@ export interface PackageReleaseInput { readonly api: GitHubApi; readonly sha: string; readonly published: readonly PublishedPackage[]; + readonly staged?: readonly PublishedPackage[]; readonly workspace: readonly WorkspacePackage[]; readonly now?: Date; } -// `pnpm publish` only talks to the registry, so the tags and the combined -// GitHub release for the run are created here. +// Submission consumes a package version whether npm approval follows or not. +// These tags are therefore created for both direct and staged submissions; the +// publishing planner uses them to prevent a rejected version from being reused. export const releasePublishedPackages = async ({ api, sha, published, + staged = [], workspace, now = new Date(), }: PackageReleaseInput): Promise => { - if (published.length === 0) { - console.log("no packages were published, nothing to release"); + if (published.length === 0 && staged.length === 0) { + console.log("no packages were submitted to npm, nothing to release"); return; } - const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const publishedReleases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const stagedReleases = [...staged].sort((a, b) => a.name.localeCompare(b.name)); + const releases = [...publishedReleases, ...stagedReleases].sort((a, b) => + a.name.localeCompare(b.name), + ); const paths = new Map(workspace.map((entry) => [entry.name, entry.path])); let failed = false; for (const { name, version } of releases) { - if (!(await createTag(api, `${name}@${version}`, sha))) failed = true; + if (!(await createTag(api, packageReleaseTag(name, version), sha))) failed = true; } const releaseTag = await nextReleaseTag(api, now); @@ -128,7 +163,12 @@ export const releasePublishedPackages = async ({ tag: releaseTag, name: releaseTag, targetCommitish: sha, - body: renderCombinedReleaseBody({ published: releases, paths, notes }), + body: renderCombinedReleaseBody({ + published: publishedReleases, + staged: stagedReleases, + paths, + notes, + }), prerelease: releases.every(({ version }) => version.includes("-")), }); diff --git a/internal/gha/src/github.test.ts b/internal/gha/src/github.test.ts index 3e91a23..f64870b 100644 --- a/internal/gha/src/github.test.ts +++ b/internal/gha/src/github.test.ts @@ -64,6 +64,30 @@ test("escapes tags when checking whether they exist", async () => { ); }); +test("only treats a 404 as a missing tag", async () => { + const missingFetch = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 404 })); + const missingApi = createGitHubApi({ + token: "secret", + repository: "acme/repo", + fetch: missingFetch, + }); + + await expect(missingApi.tagExists("@acme/pkg@1.0.0")).resolves.toBe(false); + + const failedFetch = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 503 })); + const failedApi = createGitHubApi({ + token: "secret", + repository: "acme/repo", + fetch: failedFetch, + }); + + await expect(failedApi.tagExists("@acme/pkg@1.0.0")).rejects.toThrow(/GitHub returned 503/); +}); + test("tolerates an empty response body", async () => { const fetch = vi .fn() diff --git a/internal/gha/src/github.ts b/internal/gha/src/github.ts index 4f3f112..2650fe6 100644 --- a/internal/gha/src/github.ts +++ b/internal/gha/src/github.ts @@ -101,8 +101,17 @@ export const createGitHubApi = (options: GitHubApiOptions): GitHubApi => { graphql: (query, variables) => requestUrl(graphqlUrl, "POST", { query, variables }), - tagExists: async (tag) => - (await request(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET")).ok, + tagExists: async (tag) => { + const response = await request( + `/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, + "GET", + ); + if (response.status === 404) return false; + if (!response.ok) { + throw new Error(`failed to check git tag "${tag}": GitHub returned ${response.status}`); + } + return true; + }, createRef: (ref, sha) => request(`/repos/${repository}/git/refs`, "POST", { ref, sha }), diff --git a/internal/gha/src/npm-publishing.test.ts b/internal/gha/src/npm-publishing.test.ts new file mode 100644 index 0000000..1abac6d --- /dev/null +++ b/internal/gha/src/npm-publishing.test.ts @@ -0,0 +1,154 @@ +import { expect, test, vi } from "vitest"; + +import { packageExistsOnRegistry, planNpmPublishing } from "./npm-publishing"; +import type { WorkspacePackage } from "./pnpm"; + +const workspacePackage = ( + name: string, + isPrivate = false, + version = "1.0.0", +): WorkspacePackage => ({ + name, + version, + path: `/workspace/${name}`, + private: isPrivate, +}); + +test("uses staged publishing when every public package exists", async () => { + const packageExists = vi.fn(async () => true); + + await expect( + planNpmPublishing( + [workspacePackage("public"), workspacePackage("internal", true)], + true, + packageExists, + async () => false, + ), + ).resolves.toEqual({ + mode: "staged", + directPackages: [], + firstReleasePackages: [], + previouslySubmittedPackages: [], + stagedPackages: ["public"], + }); + expect(packageExists).toHaveBeenCalledExactlyOnceWith("public"); +}); + +test("uses direct publishing when any public package needs its first release", async () => { + const packageExists = vi.fn(async (name: string) => name !== "@scope/new-package"); + + await expect( + planNpmPublishing( + [workspacePackage("existing"), workspacePackage("@scope/new-package")], + true, + packageExists, + async () => false, + ), + ).resolves.toEqual({ + mode: "mixed", + directPackages: ["@scope/new-package"], + firstReleasePackages: ["@scope/new-package"], + previouslySubmittedPackages: [], + stagedPackages: ["existing"], + }); +}); + +test("checks whether regular publishing needs a token when it was requested explicitly", async () => { + const packageExists = vi.fn(async () => false); + + await expect( + planNpmPublishing([workspacePackage("public")], false, packageExists, async () => false), + ).resolves.toEqual({ + mode: "direct", + directPackages: ["public"], + firstReleasePackages: ["public"], + previouslySubmittedPackages: [], + stagedPackages: [], + }); + expect(packageExists).toHaveBeenCalledExactlyOnceWith("public"); +}); + +test("encodes scoped names when checking the registry", async () => { + const request = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + })); + + await expect( + packageExistsOnRegistry("@scope/package", "https://registry.example.test/npm", request), + ).resolves.toBe(true); + expect(request).toHaveBeenCalledExactlyOnceWith( + new URL("https://registry.example.test/npm/@scope%2Fpackage"), + { headers: { accept: "application/vnd.npm.install-v1+json" } }, + ); +}); + +test("only treats a registry 404 as a missing package", async () => { + await expect( + packageExistsOnRegistry("new-package", "https://registry.example.test", async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + })), + ).resolves.toBe(false); + + await expect( + packageExistsOnRegistry("existing", "https://registry.example.test", async () => ({ + ok: false, + status: 503, + statusText: "Unavailable", + })), + ).rejects.toThrow('npm registry lookup for "existing" failed: 503 Unavailable'); +}); + +test("uses direct publishing for existing packages when staging is disabled", async () => { + const packageExists = vi.fn(async () => true); + + await expect( + planNpmPublishing([workspacePackage("public")], false, packageExists, async () => false), + ).resolves.toEqual({ + mode: "direct", + directPackages: ["public"], + firstReleasePackages: [], + previouslySubmittedPackages: [], + stagedPackages: [], + }); +}); + +test("never resubmits a tagged version after staged approval is rejected", async () => { + const packageExists = vi.fn(async () => true); + const releaseTagExists = vi.fn(async (tag: string) => tag === "@scope/package@2.0.0"); + + await expect( + planNpmPublishing( + [workspacePackage("@scope/package", false, "2.0.0")], + true, + packageExists, + releaseTagExists, + ), + ).resolves.toEqual({ + mode: "none", + directPackages: [], + firstReleasePackages: [], + previouslySubmittedPackages: [{ name: "@scope/package", version: "2.0.0" }], + stagedPackages: [], + }); + expect(packageExists).not.toHaveBeenCalled(); + + await expect( + planNpmPublishing( + [workspacePackage("@scope/package", false, "2.0.1")], + true, + packageExists, + releaseTagExists, + ), + ).resolves.toEqual({ + mode: "staged", + directPackages: [], + firstReleasePackages: [], + previouslySubmittedPackages: [], + stagedPackages: ["@scope/package"], + }); + expect(packageExists).toHaveBeenCalledExactlyOnceWith("@scope/package"); +}); diff --git a/internal/gha/src/npm-publishing.ts b/internal/gha/src/npm-publishing.ts new file mode 100644 index 0000000..f80b513 --- /dev/null +++ b/internal/gha/src/npm-publishing.ts @@ -0,0 +1,104 @@ +import type { PublishedPackage, WorkspacePackage } from "./pnpm"; +import { packageReleaseTag } from "./release-tags"; + +export type NpmPublishingMode = "direct" | "mixed" | "none" | "staged"; + +export interface NpmPublishingPlan { + readonly mode: NpmPublishingMode; + readonly directPackages: readonly string[]; + readonly firstReleasePackages: readonly string[]; + readonly previouslySubmittedPackages: readonly PublishedPackage[]; + readonly stagedPackages: readonly string[]; +} + +interface RegistryResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; +} + +export type RegistryRequest = ( + url: URL, + init: { readonly headers: Readonly> }, +) => Promise; + +const packageUrl = (registryUrl: string, packageName: string): URL => { + const registry = new URL(registryUrl); + if (!registry.pathname.endsWith("/")) registry.pathname += "/"; + const encodedName = encodeURIComponent(packageName).replace(/^%40/, "@"); + + return new URL(encodedName, registry); +}; + +export const packageExistsOnRegistry = async ( + packageName: string, + registryUrl: string, + request: RegistryRequest, +): Promise => { + const response = await request(packageUrl(registryUrl, packageName), { + headers: { accept: "application/vnd.npm.install-v1+json" }, + }); + + if (response.status === 404) return false; + if (!response.ok) { + const status = response.statusText + ? `${response.status} ${response.statusText}` + : String(response.status); + throw new Error(`npm registry lookup for "${packageName}" failed: ${status}`); + } + + return true; +}; + +export const planNpmPublishing = async ( + workspace: readonly WorkspacePackage[], + stagedPublishing: boolean, + packageExists: (packageName: string) => Promise, + releaseTagExists: (tag: string) => Promise, +): Promise => { + const publicPackages = workspace.filter((workspacePackage) => !workspacePackage.private); + const submissionState = await Promise.all( + publicPackages.map(async (workspacePackage) => ({ + workspacePackage, + submitted: await releaseTagExists( + packageReleaseTag(workspacePackage.name, workspacePackage.version), + ), + })), + ); + const previouslySubmittedPackages = submissionState + .filter(({ submitted }) => submitted) + .map(({ workspacePackage: { name, version } }) => ({ name, version })); + const pendingPackages = submissionState + .filter(({ submitted }) => !submitted) + .map(({ workspacePackage }) => workspacePackage); + const existence = await Promise.all( + pendingPackages.map(async (workspacePackage) => ({ + name: workspacePackage.name, + exists: await packageExists(workspacePackage.name), + })), + ); + const firstReleasePackages = existence.filter(({ exists }) => !exists).map(({ name }) => name); + const directPackages = stagedPublishing + ? firstReleasePackages + : existence.map(({ name }) => name); + const stagedPackages = stagedPublishing + ? existence.filter(({ exists }) => exists).map(({ name }) => name) + : []; + + let mode: NpmPublishingMode; + if (directPackages.length > 0) { + mode = stagedPackages.length > 0 ? "mixed" : "direct"; + } else if (stagedPackages.length > 0) { + mode = "staged"; + } else { + mode = "none"; + } + + return { + mode, + directPackages, + firstReleasePackages, + previouslySubmittedPackages, + stagedPackages, + }; +}; diff --git a/internal/gha/src/release-tags.ts b/internal/gha/src/release-tags.ts new file mode 100644 index 0000000..5783bb0 --- /dev/null +++ b/internal/gha/src/release-tags.ts @@ -0,0 +1 @@ +export const packageReleaseTag = (name: string, version: string): string => `${name}@${version}`; diff --git a/internal/gha/src/workflows.test.ts b/internal/gha/src/workflows.test.ts index 9e4b179..3c26a9b 100644 --- a/internal/gha/src/workflows.test.ts +++ b/internal/gha/src/workflows.test.ts @@ -10,6 +10,7 @@ const examplesDir = `${root}.github/workflows-examples/`; const scriptsDir = `${root}.github/scripts/`; const BUNDLE = "gha.mjs"; +const MAX_JOB_TIMEOUT_MINUTES = 15; const yamlFiles = (directory: string): string[] => readdirSync(directory).filter((file) => file.endsWith(".yml")); @@ -51,6 +52,44 @@ const usesReferences = (source: string): Array<{ reference: string; comment?: st ...(comment === undefined ? {} : { comment }), })); +const workflowStep = (source: string, name: string): string => { + const lines = source.split("\n"); + const marker = `- name: ${name}`; + const start = lines.findIndex((line) => line.trimStart() === marker); + if (start < 0) throw new Error(`Workflow does not define the "${name}" step`); + + const firstLine = lines[start]; + if (firstLine === undefined) throw new Error(`Workflow does not define the "${name}" step`); + + const indentation = firstLine.length - firstLine.trimStart().length; + let end = start + 1; + + while (end < lines.length) { + const line = lines[end]; + if (line === undefined) break; + + const trimmed = line.trimStart(); + if (trimmed.length > 0 && line.length - trimmed.length <= indentation) break; + end += 1; + } + + return lines.slice(start, end).join("\n"); +}; + +test("workflow step extraction does not cross whitespace-heavy sibling boundaries", () => { + const source = [ + " - name: Stage packages on npm", + " if: inputs.staged-publishing", + ...Array.from({ length: 10_000 }, () => " "), + " - name: Later step", + " run: pnpm stage publish -r --report-summary", + ].join("\n"); + + const step = workflowStep(source, "Stage packages on npm"); + expect(step).not.toContain("Later step"); + expect(step).not.toContain("run:"); +}); + test("every action and workflow reference is pinned to a full commit SHA", () => { for (const file of yamlFiles(workflowsDir)) { for (const { reference, comment } of usesReferences(read(workflowsDir, file))) { @@ -96,6 +135,38 @@ test("callers delegate to the shared workflows", () => { } }); +test("every runner job has a bounded timeout", () => { + for (const file of yamlFiles(workflowsDir)) { + const lines = read(workflowsDir, file).split("\n"); + + for (const [lineIndex, line] of lines.entries()) { + if (!/^ {4}runs-on:/.test(line)) continue; + + const precedingLines = lines.slice(0, lineIndex).reverse(); + const jobOffset = precedingLines.findIndex((candidate) => + /^ {2}[A-Za-z_][A-Za-z0-9_-]*:$/.test(candidate), + ); + expect(jobOffset, `${file}:${lineIndex + 1} must belong to a job`).toBeGreaterThanOrEqual(0); + + const jobStart = lineIndex - jobOffset - 1; + const nextJobOffset = lines + .slice(jobStart + 1) + .findIndex((candidate) => /^ {2}[A-Za-z_][A-Za-z0-9_-]*:$/.test(candidate)); + const jobEnd = nextJobOffset < 0 ? lines.length : jobStart + 1 + nextJobOffset; + const job = lines.slice(jobStart, jobEnd).join("\n"); + const jobName = lines[jobStart]?.trim().replace(/:$/, "") ?? "unknown"; + const timeout = job.match(/^ {4}timeout-minutes: (\d+)$/m)?.[1]; + + expect(timeout, `${file}:${jobName} must set timeout-minutes`).toBeDefined(); + expect(Number(timeout), `${file}:${jobName} timeout must be positive`).toBeGreaterThan(0); + expect( + Number(timeout), + `${file}:${jobName} timeout must not exceed ${MAX_JOB_TIMEOUT_MINUTES} minutes`, + ).toBeLessThanOrEqual(MAX_JOB_TIMEOUT_MINUTES); + } + } +}); + test("shared workflows set the telemetry opt-out themselves", () => { for (const file of ["shared-ci.yml", "shared-release.yml"]) { expect(read(workflowsDir, file), `${file} must set DO_NOT_TRACK`).toMatch( @@ -125,15 +196,76 @@ test("the release tooling is checked out from the pinned shared revision", () => expect(source).toContain(`SHARED_CLI: .shared-ci/.github/scripts/${BUNDLE}`); }); -test("keeps OIDC with an npm token fallback for first publishes", () => { +test("keeps tokens out of staging and uses direct publishing for first releases", () => { const source = read(workflowsDir, "shared-release.yml"); + const example = read(examplesDir, "release.yml"); + const publishingModeStep = workflowStep(source, "Select npm publishing mode"); + const stagedPublishingStep = workflowStep(source, "Stage packages on npm"); + const directPublishingStep = workflowStep(source, "Publish packages to npm directly"); expect(source).toMatch(/id-token: write # npm trusted publishing \(OIDC\)/); expect(source).toMatch(/default: "https:\/\/registry\.npmjs\.org"/); expect(source).toMatch(/registry-url: \$\{\{ inputs\.registry-url \}\}/); - expect(source).toMatch( - /- name: Publish to npm\n\s+env:\n(?:\s+#.*\n){2}\s+NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}\n\s+run: pnpm publish -r/, + expect(source).toMatch(/staged-publishing:\n(?: {8}.*\n){2} {8}default: true/); + expect(publishingModeStep).toMatch(/^ {8}id: publishing$/m); + expect(publishingModeStep).toContain("GITHUB_TOKEN: ${{ github.token }}"); + expect(publishingModeStep).toContain('node "${SHARED_CLI}" npm-publishing-mode'); + expect(publishingModeStep).toContain('"${RUNNER_TEMP}/first-releases.txt"'); + expect(publishingModeStep).toContain('"${RUNNER_TEMP}/direct-packages.txt"'); + expect(publishingModeStep).toContain('"${RUNNER_TEMP}/staged-packages.txt" >> "$GITHUB_OUTPUT"'); + expect(stagedPublishingStep).toMatch(/^ {8}if: steps\.publishing\.outputs\.stage == 'true'$/m); + expect(stagedPublishingStep).toContain( + 'pnpm stage publish -r "${filters[@]}" --access public --no-git-checks --report-summary', + ); + expect(stagedPublishingStep).toContain( + "STAGED_PACKAGES_FILE: ${{ runner.temp }}/staged-packages.txt", + ); + expect(stagedPublishingStep).toContain('filters+=("--filter=$package")'); + expect(stagedPublishingStep).not.toContain("NPM_TOKEN"); + expect(stagedPublishingStep).not.toContain("NODE_AUTH_TOKEN"); + expect(directPublishingStep).toMatch(/^ {8}if: steps\.publishing\.outputs\.direct == 'true'$/m); + expect(directPublishingStep).toContain( + "FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }}", + ); + expect(directPublishingStep).toContain( + "DIRECT_PACKAGES_FILE: ${{ runner.temp }}/direct-packages.txt", ); + expect(directPublishingStep).toContain('mapfile -t packages < "$DIRECT_PACKAGES_FILE"'); + expect(directPublishingStep).toContain('filters+=("--filter=$package")'); + expect(directPublishingStep).not.toContain("DIRECT_ALL"); + expect(directPublishingStep).toContain("NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"); + expect(directPublishingStep).toContain( + 'pnpm publish -r "${filters[@]}" --access public --no-git-checks --report-summary', + ); + expect(source.match(/NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/g)).toHaveLength(1); + expect(source).toMatch(/NPM_TOKEN:\n(?: {8}.*\n) {8}required: false/); + expect(source).toContain('"${RUNNER_TEMP}/published-summary.json"'); + expect(source).toContain('"${RUNNER_TEMP}/staged-summary.json"'); + expect(example).toContain("# staged-publishing: true"); + expect(example).toContain("NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"); +}); + +test("can advance a private release-contract version before pnpm consumes its intents", () => { + const source = read(workflowsDir, "shared-release.yml"); + const caller = read(workflowsDir, "release.yml"); + const example = read(examplesDir, "release.yml"); + + expect(source).toMatch(/contract-version-package:\n(?: {8}.*\n){2} {8}default: ""/); + expect(caller).toContain("contract-version-package: internal/gha/package.json"); + expect(example).toContain('# contract-version-package: ""'); + expect(source).toContain("CONTRACT_VERSION_PACKAGE: ${{ inputs.contract-version-package }}"); + expect(source).toContain('node "${SHARED_CLI}" contract-version prepare'); + expect(source).toContain("pnpm version -r --json --no-git-checks"); + expect(source).toContain('node "${SHARED_CLI}" contract-version finalize'); + + const toolingCheckout = source.indexOf("- name: Checkout shared tooling"); + const prepare = source.indexOf("contract-version prepare"); + const version = source.indexOf("pnpm version -r --json"); + const finalize = source.indexOf("contract-version finalize"); + expect(toolingCheckout).toBeGreaterThan(-1); + expect(toolingCheckout).toBeLessThan(prepare); + expect(prepare).toBeLessThan(version); + expect(version).toBeLessThan(finalize); }); test("zizmor is pinned and fails the workflow on every finding", () => {