From fea163dff511458c0113b2d30b1fa39e11671865 Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Sat, 8 Aug 2026 19:38:22 +0200 Subject: [PATCH 1/5] Use stage releases --- .changeset/hip-memes-rhyme.md | 5 + .github/scripts/gha.mjs | 300 +++++++++++++----- .github/workflows-examples/README.md | 25 +- .github/workflows-examples/release.yml | 4 +- .github/workflows/release.yml | 2 + .github/workflows/shared-release.yml | 97 ++++-- internal/gha/README.md | 4 +- .../gha/src/commands/contract-version.test.ts | 70 ++++ internal/gha/src/commands/contract-version.ts | 137 ++++++++ internal/gha/src/commands/github-releases.ts | 21 +- internal/gha/src/commands/index.test.ts | 1 + internal/gha/src/commands/index.ts | 2 + internal/gha/src/contract-version.test.ts | 88 +++++ internal/gha/src/contract-version.ts | 121 +++++++ internal/gha/src/github-releases.test.ts | 12 + internal/gha/src/github-releases.ts | 28 +- internal/gha/src/workflows.test.ts | 38 ++- 17 files changed, 826 insertions(+), 129 deletions(-) create mode 100644 .changeset/hip-memes-rhyme.md create mode 100644 internal/gha/src/commands/contract-version.test.ts create mode 100644 internal/gha/src/commands/contract-version.ts create mode 100644 internal/gha/src/contract-version.test.ts create mode 100644 internal/gha/src/contract-version.ts diff --git a/.changeset/hip-memes-rhyme.md b/.changeset/hip-memes-rhyme.md new file mode 100644 index 0000000..455d169 --- /dev/null +++ b/.changeset/hip-memes-rhyme.md @@ -0,0 +1,5 @@ +--- +"@zemd/gha": patch +--- + +Use npm staged publishing by default, allow consumers to opt into direct publishing, 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..5fa15b5 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 { 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]; @@ -34,9 +235,13 @@ const changelogEntry = (packagePath, version) => { //#endregion //#region src/github-releases.ts const RELEASE_TAG_PREFIX = "release-"; -const renderCombinedReleaseBody = ({ published, paths, notes }) => { +const renderCombinedReleaseBody = ({ published, paths, npmState = "published", notes }) => { const out = []; - out.push("## Published packages"); + out.push(npmState === "staged" ? "## Packages staged on npm" : "## Published packages"); + if (npmState === "staged") { + out.push(""); + out.push("These versions require maintainer approval with 2FA before they become available from npm."); + } out.push(""); out.push("| Package | Version |"); out.push("| :--- | ---: |"); @@ -88,9 +293,9 @@ 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() }) => { +const releasePublishedPackages = async ({ api, sha, published, workspace, npmState = "published", now = /* @__PURE__ */ new Date() }) => { if (published.length === 0) { - console.log("no packages were published, nothing to release"); + console.log(`no packages were ${npmState}, nothing to release`); return; } const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); @@ -106,6 +311,7 @@ const releasePublishedPackages = async ({ api, sha, published, workspace, now = body: renderCombinedReleaseBody({ published: releases, paths, + npmState, notes }), prerelease: releases.every(({ version }) => version.includes("-")) @@ -118,53 +324,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 = ` @@ -275,42 +434,22 @@ const apiFromEnv = () => createGitHubApi({ //#endregion //#region src/commands/github-releases.ts const githubReleases = { - usage: " ", + usage: " [published|staged]", run: async (argv) => { - const [summaryPath, workspacePath] = argv; - if (!summaryPath || !workspacePath) throw new Error("usage: github-releases "); + const [summaryPath, workspacePath, rawState = "published"] = argv; + if (!summaryPath || !workspacePath) throw new Error("usage: github-releases [published|staged]"); + if (rawState !== "published" && rawState !== "staged") throw new Error(`github-releases: expected npm state "published" or "staged", got "${rawState}"`); + const npmState = rawState; await releasePublishedPackages({ api: apiFromEnv(), sha: requireEnv("GITHUB_SHA"), published: parsePublishSummary(readFileSync(summaryPath, "utf8")), - workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")) + workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + npmState }); } }; -//#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); - 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/release-pr-body.ts const badge = { @@ -570,6 +709,7 @@ const signedCommit = { //#endregion //#region src/commands/index.ts const commands = { + "contract-version": contractVersion, "github-releases": githubReleases, "release-pr-body": releasePrBody, "shared-workflows-release": sharedWorkflowsRelease, diff --git a/.github/workflows-examples/README.md b/.github/workflows-examples/README.md index 0f499dc..57730d8 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,36 @@ 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. + +[Staged publishing](https://docs.npmjs.com/staged-publishing/) is the default. +Set `staged-publishing: false` in the caller's `with:` block when packages must +publish immediately. npm cannot stage a package that does not exist yet, so use +direct publishing for its first release, then return to the staged default. -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`. +- Allow `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. +- Keep `NPM_TOKEN` until every package exists on npm. Set + `staged-publishing: false` for that first publish because neither staged nor + trusted publishing can bootstrap a new package. - `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..eb7033b 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,8 +31,10 @@ 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 for direct or first-time publishes. # registry-url: "https://registry.npmjs.org" secrets: # Only needed until every package exists on npm: a trusted publisher diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30f65d0..eb0a783 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 }} diff --git a/.github/workflows/shared-release.yml b/.github/workflows/shared-release.yml index 23cdb53..515391a 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 packages for npm approval instead of publishing immediately. Disable for direct and first-time publishes. + type: boolean + default: true registry-url: description: Registry written to .npmrc so the NODE_AUTH_TOKEN fallback works. 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: Authentication fallback, including first-time direct publishes for packages that do not exist on npm 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 @@ -84,6 +92,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 +121,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,9 +188,9 @@ 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 @@ -203,10 +232,19 @@ jobs: SCRIPT: ${{ inputs.publint-script }} run: pnpm run "$SCRIPT" - - name: Publish to npm + - name: Stage packages on npm + if: inputs.staged-publishing + env: + # pnpm prefers OIDC when it succeeds and can use this token as a + # fallback for existing packages. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm stage publish -r --access public --no-git-checks --report-summary + + - name: Publish packages to npm directly + if: inputs.staged-publishing == false 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. + # OIDC cannot authenticate the first publish because the trusted + # publisher can only be configured after the package exists. NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: pnpm publish -r --access public --no-git-checks --report-summary @@ -219,11 +257,14 @@ jobs: persist-credentials: false # pnpm only talks to the registry, so tags and the release are created here. + # In staged mode the release body records that npm approval is still due. - name: Tag packages and create GitHub release env: GITHUB_TOKEN: ${{ github.token }} + NPM_RELEASE_STATE: ${{ inputs.staged-publishing && 'staged' || 'published' }} 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}/versions.json" \ + "$NPM_RELEASE_STATE" diff --git a/internal/gha/README.md b/internal/gha/README.md index abca312..670c51d 100644 --- a/internal/gha/README.md +++ b/internal/gha/README.md @@ -18,7 +18,9 @@ Its version is the shared workflow contract version: the release workflow tags ## Commands ``` -gha.mjs github-releases +gha.mjs contract-version prepare +gha.mjs contract-version finalize +gha.mjs github-releases [published|staged] 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..32a6811 100644 --- a/internal/gha/src/commands/github-releases.ts +++ b/internal/gha/src/commands/github-releases.ts @@ -1,27 +1,36 @@ import { readFileSync } from "node:fs"; import { requireEnv } from "../env"; -import { releasePublishedPackages } from "../github-releases"; +import { releasePublishedPackages, type NpmReleaseState } from "../github-releases"; 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. +// Tags the submitted commit once per package and publishes a single combined +// GitHub release for the run. The optional state keeps staged releases explicit. export const githubReleases: Command = { - usage: " ", + usage: " [published|staged]", run: async (argv) => { - const [summaryPath, workspacePath] = argv; + const [summaryPath, workspacePath, rawState = "published"] = argv; if (!summaryPath || !workspacePath) { - throw new Error("usage: github-releases "); + throw new Error( + "usage: github-releases [published|staged]", + ); } + if (rawState !== "published" && rawState !== "staged") { + throw new Error( + `github-releases: expected npm state "published" or "staged", got "${rawState}"`, + ); + } + const npmState: NpmReleaseState = rawState; await releasePublishedPackages({ api: apiFromEnv(), sha: requireEnv("GITHUB_SHA"), published: parsePublishSummary(readFileSync(summaryPath, "utf8")), workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + npmState, }); }, }; diff --git a/internal/gha/src/commands/index.test.ts b/internal/gha/src/commands/index.test.ts index fc4646c..367c434 100644 --- a/internal/gha/src/commands/index.test.ts +++ b/internal/gha/src/commands/index.test.ts @@ -4,6 +4,7 @@ import { commands, usage } from "./index"; test("exposes every release step the shared workflows need", () => { expect(Object.keys(commands).sort()).toEqual([ + "contract-version", "github-releases", "release-pr-body", "shared-workflows-release", diff --git a/internal/gha/src/commands/index.ts b/internal/gha/src/commands/index.ts index 688d708..601206a 100644 --- a/internal/gha/src/commands/index.ts +++ b/internal/gha/src/commands/index.ts @@ -1,10 +1,12 @@ import type { Command } from "./command"; +import { contractVersion } from "./contract-version"; import { githubReleases } from "./github-releases"; 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, "release-pr-body": releasePrBody, "shared-workflows-release": sharedWorkflowsRelease, 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..162b36c 100644 --- a/internal/gha/src/github-releases.test.ts +++ b/internal/gha/src/github-releases.test.ts @@ -24,6 +24,18 @@ 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: [{ name: "@acme/one", version: "1.0.0" }], + paths: new Map(), + npmState: "staged", + }); + + expect(body).toContain("## Packages staged on npm"); + expect(body).toContain("require maintainer approval with 2FA"); + expect(body).not.toContain("## Published packages"); +}); + test("builds a minute-stamped release tag", async () => { const github = fakeGitHub(); diff --git a/internal/gha/src/github-releases.ts b/internal/gha/src/github-releases.ts index 3d4b0cf..6beeb37 100644 --- a/internal/gha/src/github-releases.ts +++ b/internal/gha/src/github-releases.ts @@ -4,16 +4,30 @@ import type { PublishedPackage, WorkspacePackage } from "./pnpm"; const RELEASE_TAG_PREFIX = "release-"; +export type NpmReleaseState = "published" | "staged"; + export interface CombinedRelease { readonly published: readonly PublishedPackage[]; readonly paths: ReadonlyMap; + readonly npmState?: NpmReleaseState; readonly notes?: string; } -export const renderCombinedReleaseBody = ({ published, paths, notes }: CombinedRelease): string => { +export const renderCombinedReleaseBody = ({ + published, + paths, + npmState = "published", + notes, +}: CombinedRelease): string => { const out: string[] = []; - out.push("## Published packages"); + out.push(npmState === "staged" ? "## Packages staged on npm" : "## Published packages"); + if (npmState === "staged") { + out.push(""); + out.push( + "These versions require maintainer approval with 2FA before they become available from npm.", + ); + } out.push(""); out.push("| Package | Version |"); out.push("| :--- | ---: |"); @@ -94,20 +108,22 @@ export interface PackageReleaseInput { readonly sha: string; readonly published: readonly PublishedPackage[]; readonly workspace: readonly WorkspacePackage[]; + readonly npmState?: NpmReleaseState; readonly now?: Date; } -// `pnpm publish` only talks to the registry, so the tags and the combined -// GitHub release for the run are created here. +// pnpm's direct and staged publish commands only talk to the registry, so the +// tags and the combined GitHub release for the run are created here. export const releasePublishedPackages = async ({ api, sha, published, workspace, + npmState = "published", now = new Date(), }: PackageReleaseInput): Promise => { if (published.length === 0) { - console.log("no packages were published, nothing to release"); + console.log(`no packages were ${npmState}, nothing to release`); return; } @@ -128,7 +144,7 @@ export const releasePublishedPackages = async ({ tag: releaseTag, name: releaseTag, targetCommitish: sha, - body: renderCombinedReleaseBody({ published: releases, paths, notes }), + body: renderCombinedReleaseBody({ published: releases, paths, npmState, notes }), prerelease: releases.every(({ version }) => version.includes("-")), }); diff --git a/internal/gha/src/workflows.test.ts b/internal/gha/src/workflows.test.ts index 9e4b179..b8a41ee 100644 --- a/internal/gha/src/workflows.test.ts +++ b/internal/gha/src/workflows.test.ts @@ -125,15 +125,49 @@ 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("uses staged publishing by default and keeps direct publishing configurable", () => { const source = read(workflowsDir, "shared-release.yml"); + const example = read(examplesDir, "release.yml"); 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(/staged-publishing:\n(?: {8}.*\n){2} {8}default: true/); + expect(source).toMatch( + /- name: Stage packages on npm\n\s+if: inputs\.staged-publishing\n(?:\s+.*\n)*?\s+run: pnpm stage publish -r .*--report-summary/, + ); 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/, + /- name: Publish packages to npm directly\n\s+if: inputs\.staged-publishing == false\n(?:\s+.*\n)*?\s+run: pnpm publish -r .*--report-summary/, ); + expect(source.match(/NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/g)).toHaveLength(2); + expect(source).toContain( + "NPM_RELEASE_STATE: ${{ inputs.staged-publishing && 'staged' || 'published' }}", + ); + expect(source).toContain('"$NPM_RELEASE_STATE"'); + expect(example).toContain("# staged-publishing: true"); +}); + +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", () => { From abde9c36caa6d305c08d43674bea7ca13ddeacb9 Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Sun, 9 Aug 2026 12:29:30 +0200 Subject: [PATCH 2/5] Update --- .changeset/hip-memes-rhyme.md | 2 +- .github/scripts/gha.mjs | 112 ++++++++++++++---- .github/workflows-examples/README.md | 20 ++-- .github/workflows-examples/release.yml | 6 +- .github/workflows/shared-release.yml | 90 ++++++++++---- internal/gha/src/commands/github-releases.ts | 27 ++--- internal/gha/src/commands/index.test.ts | 1 + internal/gha/src/commands/index.ts | 2 + .../gha/src/commands/npm-publishing-mode.ts | 71 +++++++++++ internal/gha/src/github-releases.test.ts | 24 +++- internal/gha/src/github-releases.ts | 61 ++++++---- internal/gha/src/npm-publishing.test.ts | 87 ++++++++++++++ internal/gha/src/npm-publishing.ts | 79 ++++++++++++ internal/gha/src/workflows.test.ts | 80 +++++++++++-- 14 files changed, 555 insertions(+), 107 deletions(-) create mode 100644 internal/gha/src/commands/npm-publishing-mode.ts create mode 100644 internal/gha/src/npm-publishing.test.ts create mode 100644 internal/gha/src/npm-publishing.ts diff --git a/.changeset/hip-memes-rhyme.md b/.changeset/hip-memes-rhyme.md index 455d169..3857a18 100644 --- a/.changeset/hip-memes-rhyme.md +++ b/.changeset/hip-memes-rhyme.md @@ -2,4 +2,4 @@ "@zemd/gha": patch --- -Use npm staged publishing by default, allow consumers to opt into direct publishing, and advance the private shared-workflow contract version in release pull requests. +Use token-free OIDC staging by default, automatically direct-publish only first releases with an optional npm token, 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 5fa15b5..6ea8de2 100644 --- a/.github/scripts/gha.mjs +++ b/.github/scripts/gha.mjs @@ -1,5 +1,5 @@ // Generated by `pnpm --filter @zemd/gha run build` from internal/gha/src. Do not edit. -import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -235,21 +235,27 @@ const changelogEntry = (packagePath, version) => { //#endregion //#region src/github-releases.ts const RELEASE_TAG_PREFIX = "release-"; -const renderCombinedReleaseBody = ({ published, paths, npmState = "published", notes }) => { - const out = []; - out.push(npmState === "staged" ? "## Packages staged on npm" : "## Published packages"); - if (npmState === "staged") { +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(""); 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}`); @@ -293,12 +299,14 @@ const createTag = async (api, tag, sha) => { console.error(`failed to create tag ${tag}:`, response.payload); return false; }; -const releasePublishedPackages = async ({ api, sha, published, workspace, npmState = "published", now = /* @__PURE__ */ new Date() }) => { - if (published.length === 0) { - console.log(`no packages were ${npmState}, 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; @@ -309,9 +317,9 @@ const releasePublishedPackages = async ({ api, sha, published, workspace, npmSta name: releaseTag, targetCommitish: sha, body: renderCombinedReleaseBody({ - published: releases, + published: publishedReleases, + staged: stagedReleases, paths, - npmState, notes }), prerelease: releases.every(({ version }) => version.includes("-")) @@ -433,23 +441,84 @@ const apiFromEnv = () => createGitHubApi({ //#endregion //#region src/commands/github-releases.ts +const readSummary = (path) => existsSync(path) ? parsePublishSummary(readFileSync(path, "utf8")) : []; const githubReleases = { - usage: " [published|staged]", + usage: " ", run: async (argv) => { - const [summaryPath, workspacePath, rawState = "published"] = argv; - if (!summaryPath || !workspacePath) throw new Error("usage: github-releases [published|staged]"); - if (rawState !== "published" && rawState !== "staged") throw new Error(`github-releases: expected npm state "published" or "staged", got "${rawState}"`); - const npmState = rawState; + 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")), - workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), - npmState + published: readSummary(publishedSummaryPath), + staged: readSummary(stagedSummaryPath), + workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")) }); } }; +//#endregion +//#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) => { + const publicPackages = workspace.filter((workspacePackage) => !workspacePackage.private); + const existence = await Promise.all(publicPackages.map(async (workspacePackage) => ({ + name: workspacePackage.name, + exists: await packageExists(workspacePackage.name) + }))); + const firstReleasePackages = existence.filter(({ exists }) => !exists).map(({ name }) => name); + const stagedPackages = stagedPublishing ? existence.filter(({ exists }) => exists).map(({ name }) => name) : []; + let mode; + if (!stagedPublishing || firstReleasePackages.length > 0) mode = stagedPackages.length > 0 ? "mixed" : "direct"; + else mode = "staged"; + return { + mode, + firstReleasePackages, + stagedPackages + }; +}; + +//#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, stagedPackagesPath] = argv; + if (!workspacePath || !registryUrl || !rawStagedPublishing || !firstReleasesPath || !stagedPackagesPath) throw new Error("usage: npm-publishing-mode "); + const plan = await planNpmPublishing(parseWorkspacePackages(readFileSync(workspacePath, "utf8")), parseBoolean(rawStagedPublishing), (packageName) => packageExistsOnRegistry(packageName, registryUrl, (url, init) => fetch(url, { headers: init.headers }))); + 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(stagedPackagesPath, plan.stagedPackages.map((packageName) => `${packageName}\n`).join("")); + process.stdout.write([ + `mode=${plan.mode}`, + `direct=${rawStagedPublishing === "false" || plan.firstReleasePackages.length > 0}`, + `direct_all=${rawStagedPublishing === "false"}`, + `stage=${plan.stagedPackages.length > 0}`, + `first_release=${plan.firstReleasePackages.length > 0}`, + "" + ].join("\n")); + } +}; + //#endregion //#region src/release-pr-body.ts const badge = { @@ -711,6 +780,7 @@ const signedCommit = { 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 57730d8..4532767 100644 --- a/.github/workflows-examples/README.md +++ b/.github/workflows-examples/README.md @@ -49,12 +49,14 @@ intents before pnpm prepares the release pull request. On every push it either opens/refreshes a `release/main` pull request, or — when 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. +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. [Staged publishing](https://docs.npmjs.com/staged-publishing/) is the default. Set `staged-publishing: false` in the caller's `with:` block when packages must -publish immediately. npm cannot stage a package that does not exist yet, so use -direct publishing for its first release, then return to the staged default. +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**](https://docs.npmjs.com/trusted-publishers/): @@ -62,12 +64,14 @@ For npm [**trusted publishing**](https://docs.npmjs.com/trusted-publishers/): filename, not the reusable workflow that runs the publish. - Register the trusted publisher per package with the _consumer_ repository and `release.yml`. -- Allow `npm stage publish` for the default behavior. Consumers that disable - staged publishing must allow `npm publish` instead (or allow both actions). +- 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. Set - `staged-publishing: false` for that first publish because neither staged nor - trusted publishing can bootstrap a new package. +- 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 eb7033b..07fb62b 100644 --- a/.github/workflows-examples/release.yml +++ b/.github/workflows-examples/release.yml @@ -34,9 +34,9 @@ jobs: # contract-version-package: "" # Private workflow/tooling contract, if any. # build-script: build # publint-script: lint-publish - # staged-publishing: true # Set false for direct or first-time publishes. + # 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/shared-release.yml b/.github/workflows/shared-release.yml index 515391a..b271e26 100644 --- a/.github/workflows/shared-release.yml +++ b/.github/workflows/shared-release.yml @@ -50,16 +50,16 @@ on: type: string default: "lint-publish" staged-publishing: - description: Stage packages for npm approval instead of publishing immediately. Disable for direct and first-time publishes. + description: Stage existing packages for npm approval. First releases switch to regular publishing automatically. 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: Authentication fallback, including first-time direct publishes for packages that do 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: @@ -203,6 +203,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: @@ -232,39 +240,69 @@ jobs: SCRIPT: ${{ inputs.publint-script }} run: pnpm run "$SCRIPT" - - name: Stage packages on npm - if: inputs.staged-publishing + # 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: - # pnpm prefers OIDC when it succeeds and can use this token as a - # fallback for existing packages. - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm stage publish -r --access public --no-git-checks --report-summary + 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}/staged-packages.txt" >> "$GITHUB_OUTPUT" - name: Publish packages to npm directly - if: inputs.staged-publishing == false + if: steps.publishing.outputs.direct == 'true' env: - # OIDC cannot authenticate the first publish because the trusted - # publisher can only be configured after the package exists. + DIRECT_ALL: ${{ steps.publishing.outputs.direct_all }} + FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }} + FIRST_RELEASES_FILE: ${{ runner.temp }}/first-releases.txt 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 + filters=() + if [ "$DIRECT_ALL" != "true" ]; then + mapfile -t packages < "$FIRST_RELEASES_FILE" + for package in "${packages[@]}"; do + filters+=("--filter=$package") + done + fi + + pnpm publish -r "${filters[@]}" --access public --no-git-checks --report-summary + mv pnpm-publish-summary.json "${RUNNER_TEMP}/published-summary.json" + + - 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. - # In staged mode the release body records that npm approval is still due. + # The release body distinguishes direct first releases from versions that + # still require staged-publish approval. - name: Tag packages and create GitHub release env: GITHUB_TOKEN: ${{ github.token }} - NPM_RELEASE_STATE: ${{ inputs.staged-publishing && 'staged' || 'published' }} run: | - pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/versions.json" node "${SHARED_CLI}" github-releases \ - pnpm-publish-summary.json \ - "${RUNNER_TEMP}/versions.json" \ - "$NPM_RELEASE_STATE" + "${RUNNER_TEMP}/published-summary.json" \ + "${RUNNER_TEMP}/staged-summary.json" \ + "${RUNNER_TEMP}/workspace.json" diff --git a/internal/gha/src/commands/github-releases.ts b/internal/gha/src/commands/github-releases.ts index 32a6811..7b33551 100644 --- a/internal/gha/src/commands/github-releases.ts +++ b/internal/gha/src/commands/github-releases.ts @@ -1,36 +1,33 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { requireEnv } from "../env"; -import { releasePublishedPackages, type NpmReleaseState } from "../github-releases"; +import { releasePublishedPackages } from "../github-releases"; import { parsePublishSummary, parseWorkspacePackages } from "../pnpm"; import type { Command } from "./command"; import { apiFromEnv } from "./context"; +const readSummary = (path: string) => + existsSync(path) ? parsePublishSummary(readFileSync(path, "utf8")) : []; + // Tags the submitted commit once per package and publishes a single combined -// GitHub release for the run. The optional state keeps staged releases explicit. +// GitHub release that distinguishes published and staged versions. export const githubReleases: Command = { - usage: " [published|staged]", + usage: " ", run: async (argv) => { - const [summaryPath, workspacePath, rawState = "published"] = argv; + const [publishedSummaryPath, stagedSummaryPath, workspacePath] = argv; - if (!summaryPath || !workspacePath) { - throw new Error( - "usage: github-releases [published|staged]", - ); - } - if (rawState !== "published" && rawState !== "staged") { + if (!publishedSummaryPath || !stagedSummaryPath || !workspacePath) { throw new Error( - `github-releases: expected npm state "published" or "staged", got "${rawState}"`, + "usage: github-releases ", ); } - const npmState: NpmReleaseState = rawState; await releasePublishedPackages({ api: apiFromEnv(), sha: requireEnv("GITHUB_SHA"), - published: parsePublishSummary(readFileSync(summaryPath, "utf8")), + published: readSummary(publishedSummaryPath), + staged: readSummary(stagedSummaryPath), workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), - npmState, }); }, }; diff --git a/internal/gha/src/commands/index.test.ts b/internal/gha/src/commands/index.test.ts index 367c434..b544e1c 100644 --- a/internal/gha/src/commands/index.test.ts +++ b/internal/gha/src/commands/index.test.ts @@ -6,6 +6,7 @@ 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 601206a..d3ad35b 100644 --- a/internal/gha/src/commands/index.ts +++ b/internal/gha/src/commands/index.ts @@ -1,6 +1,7 @@ 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"; @@ -8,6 +9,7 @@ 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..73541a2 --- /dev/null +++ b/internal/gha/src/commands/npm-publishing-mode.ts @@ -0,0 +1,71 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +import { packageExistsOnRegistry, planNpmPublishing } from "../npm-publishing"; +import { parseWorkspacePackages } from "../pnpm"; +import type { Command } from "./command"; + +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, stagedPackagesPath] = + argv; + + if ( + !workspacePath || + !registryUrl || + !rawStagedPublishing || + !firstReleasesPath || + !stagedPackagesPath + ) { + throw new Error( + "usage: npm-publishing-mode ", + ); + } + + const plan = await planNpmPublishing( + parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + parseBoolean(rawStagedPublishing), + (packageName) => + packageExistsOnRegistry(packageName, registryUrl, (url, init) => + fetch(url, { headers: init.headers }), + ), + ); + + 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( + stagedPackagesPath, + plan.stagedPackages.map((packageName) => `${packageName}\n`).join(""), + ); + + process.stdout.write( + [ + `mode=${plan.mode}`, + `direct=${rawStagedPublishing === "false" || plan.firstReleasePackages.length > 0}`, + `direct_all=${rawStagedPublishing === "false"}`, + `stage=${plan.stagedPackages.length > 0}`, + `first_release=${plan.firstReleasePackages.length > 0}`, + "", + ].join("\n"), + ); + }, +}; diff --git a/internal/gha/src/github-releases.test.ts b/internal/gha/src/github-releases.test.ts index 162b36c..846e116 100644 --- a/internal/gha/src/github-releases.test.ts +++ b/internal/gha/src/github-releases.test.ts @@ -26,9 +26,9 @@ test("renders one npm link and one changelog block per package", () => { test("labels packages that still require npm staged-publish approval", () => { const body = renderCombinedReleaseBody({ - published: [{ name: "@acme/one", version: "1.0.0" }], + published: [], + staged: [{ name: "@acme/one", version: "1.0.0" }], paths: new Map(), - npmState: "staged", }); expect(body).toContain("## Packages staged on npm"); @@ -36,6 +36,21 @@ test("labels packages that still require npm staged-publish approval", () => { 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(); @@ -62,7 +77,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({ @@ -72,16 +87,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 6beeb37..d90eee9 100644 --- a/internal/gha/src/github-releases.ts +++ b/internal/gha/src/github-releases.ts @@ -4,25 +4,23 @@ import type { PublishedPackage, WorkspacePackage } from "./pnpm"; const RELEASE_TAG_PREFIX = "release-"; -export type NpmReleaseState = "published" | "staged"; - export interface CombinedRelease { readonly published: readonly PublishedPackage[]; + readonly staged?: readonly PublishedPackage[]; readonly paths: ReadonlyMap; - readonly npmState?: NpmReleaseState; readonly notes?: string; } -export const renderCombinedReleaseBody = ({ - published, - paths, - npmState = "published", - 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(npmState === "staged" ? "## Packages staged on npm" : "## Published packages"); - if (npmState === "staged") { + out.push(`## ${heading}`); + if (approvalRequired) { out.push(""); out.push( "These versions require maintainer approval with 2FA before they become available from npm.", @@ -32,15 +30,27 @@ export const renderCombinedReleaseBody = ({ 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}`); @@ -107,8 +117,8 @@ export interface PackageReleaseInput { readonly api: GitHubApi; readonly sha: string; readonly published: readonly PublishedPackage[]; + readonly staged?: readonly PublishedPackage[]; readonly workspace: readonly WorkspacePackage[]; - readonly npmState?: NpmReleaseState; readonly now?: Date; } @@ -118,16 +128,20 @@ export const releasePublishedPackages = async ({ api, sha, published, + staged = [], workspace, - npmState = "published", now = new Date(), }: PackageReleaseInput): Promise => { - if (published.length === 0) { - console.log(`no packages were ${npmState}, 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; @@ -144,7 +158,12 @@ export const releasePublishedPackages = async ({ tag: releaseTag, name: releaseTag, targetCommitish: sha, - body: renderCombinedReleaseBody({ published: releases, paths, npmState, notes }), + body: renderCombinedReleaseBody({ + published: publishedReleases, + staged: stagedReleases, + paths, + notes, + }), prerelease: releases.every(({ version }) => version.includes("-")), }); diff --git a/internal/gha/src/npm-publishing.test.ts b/internal/gha/src/npm-publishing.test.ts new file mode 100644 index 0000000..af39488 --- /dev/null +++ b/internal/gha/src/npm-publishing.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from "vitest"; + +import { packageExistsOnRegistry, planNpmPublishing } from "./npm-publishing"; +import type { WorkspacePackage } from "./pnpm"; + +const workspacePackage = (name: string, isPrivate = false): WorkspacePackage => ({ + name, + version: "1.0.0", + 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, + ), + ).resolves.toEqual({ + mode: "staged", + firstReleasePackages: [], + 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, + ), + ).resolves.toEqual({ + mode: "mixed", + firstReleasePackages: ["@scope/new-package"], + 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), + ).resolves.toEqual({ + mode: "direct", + firstReleasePackages: ["public"], + 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'); +}); diff --git a/internal/gha/src/npm-publishing.ts b/internal/gha/src/npm-publishing.ts new file mode 100644 index 0000000..85c872e --- /dev/null +++ b/internal/gha/src/npm-publishing.ts @@ -0,0 +1,79 @@ +import type { WorkspacePackage } from "./pnpm"; + +export type NpmPublishingMode = "direct" | "mixed" | "staged"; + +export interface NpmPublishingPlan { + readonly mode: NpmPublishingMode; + readonly firstReleasePackages: readonly string[]; + 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, +): Promise => { + const publicPackages = workspace.filter((workspacePackage) => !workspacePackage.private); + const existence = await Promise.all( + publicPackages.map(async (workspacePackage) => ({ + name: workspacePackage.name, + exists: await packageExists(workspacePackage.name), + })), + ); + const firstReleasePackages = existence.filter(({ exists }) => !exists).map(({ name }) => name); + const stagedPackages = stagedPublishing + ? existence.filter(({ exists }) => exists).map(({ name }) => name) + : []; + + let mode: NpmPublishingMode; + if (!stagedPublishing || firstReleasePackages.length > 0) { + mode = stagedPackages.length > 0 ? "mixed" : "direct"; + } else { + mode = "staged"; + } + + return { + mode, + firstReleasePackages, + stagedPackages, + }; +}; diff --git a/internal/gha/src/workflows.test.ts b/internal/gha/src/workflows.test.ts index b8a41ee..86f745e 100644 --- a/internal/gha/src/workflows.test.ts +++ b/internal/gha/src/workflows.test.ts @@ -51,6 +51,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))) { @@ -125,26 +163,50 @@ test("the release tooling is checked out from the pinned shared revision", () => expect(source).toContain(`SHARED_CLI: .shared-ci/.github/scripts/${BUNDLE}`); }); -test("uses staged publishing by default and keeps direct publishing configurable", () => { +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(/staged-publishing:\n(?: {8}.*\n){2} {8}default: true/); - expect(source).toMatch( - /- name: Stage packages on npm\n\s+if: inputs\.staged-publishing\n(?:\s+.*\n)*?\s+run: pnpm stage publish -r .*--report-summary/, + expect(publishingModeStep).toMatch(/^ {8}id: publishing$/m); + expect(publishingModeStep).toContain('node "${SHARED_CLI}" npm-publishing-mode'); + expect(publishingModeStep).toContain('"${RUNNER_TEMP}/first-releases.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(source).toMatch( - /- name: Publish packages to npm directly\n\s+if: inputs\.staged-publishing == false\n(?:\s+.*\n)*?\s+run: pnpm publish -r .*--report-summary/, + expect(stagedPublishingStep).toContain( + "STAGED_PACKAGES_FILE: ${{ runner.temp }}/staged-packages.txt", ); - expect(source.match(/NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/g)).toHaveLength(2); - expect(source).toContain( - "NPM_RELEASE_STATE: ${{ inputs.staged-publishing && 'staged' || 'published' }}", + 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( + "FIRST_RELEASES_FILE: ${{ runner.temp }}/first-releases.txt", + ); + expect(directPublishingStep).toContain('if [ "$DIRECT_ALL" != "true" ]'); + expect(directPublishingStep).toContain('filters+=("--filter=$package")'); + 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).toContain('"$NPM_RELEASE_STATE"'); + 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", () => { From cfcdd1b1bfb832828e610493e105cf95e8cda16d Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Sun, 9 Aug 2026 12:56:07 +0200 Subject: [PATCH 3/5] Update --- .changeset/hip-memes-rhyme.md | 2 +- .github/scripts/gha.mjs | 50 ++++++++++--- .github/workflows-examples/README.md | 6 ++ .github/workflows/shared-release.yml | 26 +++---- internal/gha/README.md | 7 +- .../gha/src/commands/npm-publishing-mode.ts | 37 +++++++-- internal/gha/src/github-releases.test.ts | 1 + internal/gha/src/github-releases.ts | 11 ++- internal/gha/src/github.test.ts | 24 ++++++ internal/gha/src/github.ts | 13 +++- internal/gha/src/npm-publishing.test.ts | 75 ++++++++++++++++++- internal/gha/src/npm-publishing.ts | 35 +++++++-- internal/gha/src/release-tags.ts | 1 + internal/gha/src/workflows.test.ts | 7 +- 14 files changed, 245 insertions(+), 50 deletions(-) create mode 100644 internal/gha/src/release-tags.ts diff --git a/.changeset/hip-memes-rhyme.md b/.changeset/hip-memes-rhyme.md index 3857a18..2078820 100644 --- a/.changeset/hip-memes-rhyme.md +++ b/.changeset/hip-memes-rhyme.md @@ -2,4 +2,4 @@ "@zemd/gha": patch --- -Use token-free OIDC staging by default, automatically direct-publish only first releases with an optional npm token, and advance the private shared-workflow contract version in release pull requests. +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 6ea8de2..6d5b931 100644 --- a/.github/scripts/gha.mjs +++ b/.github/scripts/gha.mjs @@ -232,6 +232,10 @@ 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-"; @@ -241,6 +245,7 @@ const appendPackageTable = (out, heading, packages, approvalRequired) => { 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 |"); @@ -309,7 +314,7 @@ const releasePublishedPackages = async ({ api, sha, published, staged = [], work 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({ @@ -374,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 @@ -474,20 +484,33 @@ const packageExistsOnRegistry = async (packageName, registryUrl, request) => { } return true; }; -const planNpmPublishing = async (workspace, stagedPublishing, packageExists) => { +const planNpmPublishing = async (workspace, stagedPublishing, packageExists, releaseTagExists) => { const publicPackages = workspace.filter((workspacePackage) => !workspacePackage.private); - const existence = await Promise.all(publicPackages.map(async (workspacePackage) => ({ + 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 (!stagedPublishing || firstReleasePackages.length > 0) mode = stagedPackages.length > 0 ? "mixed" : "direct"; - else mode = "staged"; + 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 }; }; @@ -500,18 +523,21 @@ const parseBoolean = (value) => { throw new Error(`npm-publishing-mode: expected "true" or "false", got "${value}"`); }; const npmPublishingMode = { - usage: " ", + usage: " ", run: async (argv) => { - const [workspacePath, registryUrl, rawStagedPublishing, firstReleasesPath, stagedPackagesPath] = argv; - if (!workspacePath || !registryUrl || !rawStagedPublishing || !firstReleasesPath || !stagedPackagesPath) throw new Error("usage: npm-publishing-mode "); - const plan = await planNpmPublishing(parseWorkspacePackages(readFileSync(workspacePath, "utf8")), parseBoolean(rawStagedPublishing), (packageName) => packageExistsOnRegistry(packageName, registryUrl, (url, init) => fetch(url, { headers: init.headers }))); + 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=${rawStagedPublishing === "false" || plan.firstReleasePackages.length > 0}`, - `direct_all=${rawStagedPublishing === "false"}`, + `direct=${plan.directPackages.length > 0}`, `stage=${plan.stagedPackages.length > 0}`, `first_release=${plan.firstReleasePackages.length > 0}`, "" diff --git a/.github/workflows-examples/README.md b/.github/workflows-examples/README.md index 4532767..5ef6073 100644 --- a/.github/workflows-examples/README.md +++ b/.github/workflows-examples/README.md @@ -53,6 +53,12 @@ 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, diff --git a/.github/workflows/shared-release.yml b/.github/workflows/shared-release.yml index b271e26..45e2041 100644 --- a/.github/workflows/shared-release.yml +++ b/.github/workflows/shared-release.yml @@ -50,7 +50,7 @@ on: type: string default: "lint-publish" staged-publishing: - description: Stage existing packages for npm approval. First releases switch to regular publishing automatically. + 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: @@ -246,6 +246,7 @@ jobs: - name: Select npm publishing mode id: publishing env: + GITHUB_TOKEN: ${{ github.token }} REGISTRY_URL: ${{ inputs.registry-url }} STAGED_PUBLISHING: ${{ inputs.staged-publishing }} run: | @@ -255,14 +256,14 @@ jobs: "$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: - DIRECT_ALL: ${{ steps.publishing.outputs.direct_all }} + DIRECT_PACKAGES_FILE: ${{ runner.temp }}/direct-packages.txt FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }} - FIRST_RELEASES_FILE: ${{ runner.temp }}/first-releases.txt NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | if [ "$FIRST_RELEASE" = "true" ] && [ -z "$NODE_AUTH_TOKEN" ]; then @@ -270,16 +271,16 @@ jobs: exit 1 fi + mapfile -t packages < "$DIRECT_PACKAGES_FILE" filters=() - if [ "$DIRECT_ALL" != "true" ]; then - mapfile -t packages < "$FIRST_RELEASES_FILE" - for package in "${packages[@]}"; do - filters+=("--filter=$package") - done - fi + for package in "${packages[@]}"; do + filters+=("--filter=$package") + done pnpm publish -r "${filters[@]}" --access public --no-git-checks --report-summary - mv pnpm-publish-summary.json "${RUNNER_TEMP}/published-summary.json" + 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' @@ -295,9 +296,8 @@ jobs: 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. - # The release body distinguishes direct first releases from versions that - # still require staged-publish approval. + # 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 }} diff --git a/internal/gha/README.md b/internal/gha/README.md index 670c51d..294c25f 100644 --- a/internal/gha/README.md +++ b/internal/gha/README.md @@ -15,12 +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 contract-version prepare gha.mjs contract-version finalize -gha.mjs github-releases [published|staged] +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/npm-publishing-mode.ts b/internal/gha/src/commands/npm-publishing-mode.ts index 73541a2..b63dd88 100644 --- a/internal/gha/src/commands/npm-publishing-mode.ts +++ b/internal/gha/src/commands/npm-publishing-mode.ts @@ -2,7 +2,9 @@ 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; @@ -14,32 +16,50 @@ const parseBoolean = (value: string): boolean => { // use regular publishing. The command writes values ready for GITHUB_OUTPUT. export const npmPublishingMode: Command = { usage: - " ", + " ", run: async (argv) => { - const [workspacePath, registryUrl, rawStagedPublishing, firstReleasesPath, stagedPackagesPath] = - argv; + const [ + workspacePath, + registryUrl, + rawStagedPublishing, + firstReleasesPath, + directPackagesPath, + stagedPackagesPath, + ] = argv; if ( !workspacePath || !registryUrl || !rawStagedPublishing || !firstReleasesPath || + !directPackagesPath || !stagedPackagesPath ) { throw new Error( - "usage: npm-publishing-mode ", + "usage: npm-publishing-mode ", ); } + const stagedPublishing = parseBoolean(rawStagedPublishing); + const api = apiFromEnv(); const plan = await planNpmPublishing( parseWorkspacePackages(readFileSync(workspacePath, "utf8")), - parseBoolean(rawStagedPublishing), + 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( @@ -52,6 +72,10 @@ export const npmPublishingMode: Command = { 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(""), @@ -60,8 +84,7 @@ export const npmPublishingMode: Command = { process.stdout.write( [ `mode=${plan.mode}`, - `direct=${rawStagedPublishing === "false" || plan.firstReleasePackages.length > 0}`, - `direct_all=${rawStagedPublishing === "false"}`, + `direct=${plan.directPackages.length > 0}`, `stage=${plan.stagedPackages.length > 0}`, `first_release=${plan.firstReleasePackages.length > 0}`, "", diff --git a/internal/gha/src/github-releases.test.ts b/internal/gha/src/github-releases.test.ts index 846e116..bbf215d 100644 --- a/internal/gha/src/github-releases.test.ts +++ b/internal/gha/src/github-releases.test.ts @@ -33,6 +33,7 @@ test("labels packages that still require npm staged-publish approval", () => { 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"); }); diff --git a/internal/gha/src/github-releases.ts b/internal/gha/src/github-releases.ts index d90eee9..2c07f44 100644 --- a/internal/gha/src/github-releases.ts +++ b/internal/gha/src/github-releases.ts @@ -1,6 +1,7 @@ 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-"; @@ -25,6 +26,9 @@ const appendPackageTable = ( 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 |"); @@ -122,8 +126,9 @@ export interface PackageReleaseInput { readonly now?: Date; } -// pnpm's direct and staged publish commands only talk 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, @@ -147,7 +152,7 @@ export const releasePublishedPackages = async ({ 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); 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 index af39488..1abac6d 100644 --- a/internal/gha/src/npm-publishing.test.ts +++ b/internal/gha/src/npm-publishing.test.ts @@ -3,9 +3,13 @@ import { expect, test, vi } from "vitest"; import { packageExistsOnRegistry, planNpmPublishing } from "./npm-publishing"; import type { WorkspacePackage } from "./pnpm"; -const workspacePackage = (name: string, isPrivate = false): WorkspacePackage => ({ +const workspacePackage = ( + name: string, + isPrivate = false, + version = "1.0.0", +): WorkspacePackage => ({ name, - version: "1.0.0", + version, path: `/workspace/${name}`, private: isPrivate, }); @@ -18,10 +22,13 @@ test("uses staged publishing when every public package exists", async () => { [workspacePackage("public"), workspacePackage("internal", true)], true, packageExists, + async () => false, ), ).resolves.toEqual({ mode: "staged", + directPackages: [], firstReleasePackages: [], + previouslySubmittedPackages: [], stagedPackages: ["public"], }); expect(packageExists).toHaveBeenCalledExactlyOnceWith("public"); @@ -35,10 +42,13 @@ test("uses direct publishing when any public package needs its first release", a [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"], }); }); @@ -47,17 +57,23 @@ test("checks whether regular publishing needs a token when it was requested expl const packageExists = vi.fn(async () => false); await expect( - planNpmPublishing([workspacePackage("public")], false, packageExists), + 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" })); + const request = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + })); await expect( packageExistsOnRegistry("@scope/package", "https://registry.example.test/npm", request), @@ -85,3 +101,54 @@ test("only treats a registry 404 as a missing package", async () => { })), ).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 index 85c872e..f80b513 100644 --- a/internal/gha/src/npm-publishing.ts +++ b/internal/gha/src/npm-publishing.ts @@ -1,10 +1,13 @@ -import type { WorkspacePackage } from "./pnpm"; +import type { PublishedPackage, WorkspacePackage } from "./pnpm"; +import { packageReleaseTag } from "./release-tags"; -export type NpmPublishingMode = "direct" | "mixed" | "staged"; +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[]; } @@ -51,29 +54,51 @@ 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 existence = await Promise.all( + 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 (!stagedPublishing || firstReleasePackages.length > 0) { + if (directPackages.length > 0) { mode = stagedPackages.length > 0 ? "mixed" : "direct"; - } else { + } 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 86f745e..32ab418 100644 --- a/internal/gha/src/workflows.test.ts +++ b/internal/gha/src/workflows.test.ts @@ -175,8 +175,10 @@ test("keeps tokens out of staging and uses direct publishing for first releases" expect(source).toMatch(/registry-url: \$\{\{ inputs\.registry-url \}\}/); 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( @@ -193,10 +195,11 @@ test("keeps tokens out of staging and uses direct publishing for first releases" "FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }}", ); expect(directPublishingStep).toContain( - "FIRST_RELEASES_FILE: ${{ runner.temp }}/first-releases.txt", + "DIRECT_PACKAGES_FILE: ${{ runner.temp }}/direct-packages.txt", ); - expect(directPublishingStep).toContain('if [ "$DIRECT_ALL" != "true" ]'); + 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', From 97df9ae21b029347c0c14a5e7834aa82cdf62ea2 Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Sun, 9 Aug 2026 19:34:33 +0200 Subject: [PATCH 4/5] Update --- internal/gha/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gha/README.md b/internal/gha/README.md index 294c25f..fd6b651 100644 --- a/internal/gha/README.md +++ b/internal/gha/README.md @@ -21,7 +21,7 @@ was rejected on npm; another release must advance the package version. ## Commands -``` +```text gha.mjs contract-version prepare gha.mjs contract-version finalize gha.mjs github-releases From 342a09eae9953aed5ef54ba723b8c10a6211a4ed Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Sun, 9 Aug 2026 20:20:16 +0200 Subject: [PATCH 5/5] Add timeout settings to CI workflows --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + .github/workflows/shared-ci.yml | 4 ++++ .github/workflows/shared-codeql.yml | 1 + .github/workflows/shared-release.yml | 2 ++ .github/workflows/shared-scorecard.yml | 1 + internal/gha/src/workflows.test.ts | 33 ++++++++++++++++++++++++++ 7 files changed, 43 insertions(+) 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 eb0a783..b2abf9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,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 45e2041..e95d25c 100644 --- a/.github/workflows/shared-release.yml +++ b/.github/workflows/shared-release.yml @@ -80,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 @@ -194,6 +195,7 @@ jobs: 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) 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/src/workflows.test.ts b/internal/gha/src/workflows.test.ts index 32ab418..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")); @@ -134,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(