From a881fd5ffa3a26b811a5ba779afff1801b52391c Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:29:08 +0300 Subject: [PATCH] ci(release): publish from CI with trusted publishing, one tag and release The Changesets action now only opens the release PR. After it merges, a publish job waits for the npm-publish environment's approval, then scripts/publish-ci.ts smoke-tests the packed tarballs, publishes exactly those with npm trusted publishing (OIDC, provenance), waits until npm serves all 13, and creates one vX.Y.Z tag and GitHub Release with notes built from the changelogs (dependency-only lines dropped). Re-runs skip what is already done. Top-level permissions are empty, third-party actions are pinned by SHA. Still gated by RELEASE_ENABLED. CONTRIBUTING gains a Releasing section. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .github/workflows/release.yml | 94 ++++++++++++++++++----- CONTRIBUTING.md | 13 ++++ packages/cli/test/release-plan.test.ts | 79 +++++++++++++++++++ scripts/publish-ci.ts | 102 +++++++++++++++++++++++++ scripts/release-plan.ts | 89 +++++++++++++++++++++ scripts/smoke-pack.ts | 9 ++- 6 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 packages/cli/test/release-plan.test.ts create mode 100644 scripts/publish-ci.ts create mode 100644 scripts/release-plan.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8610dcd..31fe173d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,45 +1,99 @@ name: Release +# Releases from CI (ADR-0013; the decisions in CONTRIBUTING.md "Releasing"): +# 1. `version`: on every push to main, the Changesets action opens or updates the release PR +# `chore(release): x.y.z` from the pending changesets. Merging it is the decision to release. +# 2. `plan`: after that merge, finds that npm lacks the packages' new version. +# 3. `publish`: waits for the `npm-publish` environment's approval (one click, the maintainer's +# 2FA), then scripts/publish-ci.ts builds, smoke-tests the packed tarballs, publishes them with +# npm trusted publishing (OIDC, provenance), and creates one `v` tag and GitHub Release. +# npm's trusted-publisher entry for each package names this file and the `npm-publish` +# environment: renaming either breaks publishing. + on: push: branches: [main] workflow_dispatch: -permissions: - contents: write - pull-requests: write - id-token: write +permissions: {} -concurrency: release-${{ github.ref }} +concurrency: + group: release + cancel-in-progress: false jobs: - changesets: - name: Version or publish (Changesets) - # Publishing is gated until the npm org `cloudhal` exists and trusted publishing is - # configured: set the repository variable RELEASE_ENABLED=true to turn it on. + version: + name: Release PR (Changesets) if: vars.RELEASE_ENABLED == 'true' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - name: Create release PR or publish to npm - # changesets/action v2: kebab-case inputs, and the token goes in `github-token` (the - # GITHUB_TOKEN environment variable is no longer read). npm auth stays trusted publishing. - uses: changesets/action@v2 + - run: pnpm install --frozen-lockfile --ignore-scripts + # Version only: publishing happens in `publish`, behind the environment approval. + - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: version-script: pnpm changeset version - publish-script: pnpm release commit-message: "chore(release): version packages" pr-title: "chore(release): version packages" github-token: ${{ secrets.GITHUB_TOKEN }} + + plan: + name: Anything to publish? + if: vars.RELEASE_ENABLED == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + publish: ${{ steps.plan.outputs.publish }} + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - id: plan + run: node scripts/publish-ci.ts --plan + + publish: + name: Publish ${{ needs.plan.outputs.version }} to npm + needs: plan + if: needs.plan.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: npm-publish + url: https://www.npmjs.com/package/@cloudhal/cli/v/${{ needs.plan.outputs.version }} + permissions: + contents: write # the v tag and the GitHub Release + id-token: write # npm trusted publishing (OIDC) and provenance + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: pnpm + # No registry-url: its generated .npmrc token line is not needed with OIDC. + # Trusted publishing needs npm 11.5.1 or later. + - run: npm install -g npm@12 + - run: pnpm install --frozen-lockfile + - run: node scripts/build-packages.ts --check + - run: node scripts/publish-ci.ts env: - NPM_CONFIG_PROVENANCE: "true" + GH_TOKEN: ${{ github.token }} # crates.io publishing is added once cloudhal-schema reaches 0.1 (see docs/adr/0006). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb306464..7f198855 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,19 @@ Commit the regenerated files; CI fails on drift. Add a valid and an invalid fixt - Keep modules small and single-purpose: [`docs/engineering/code-structure.md`](docs/engineering/code-structure.md) sets file/function limits that `pnpm size:check` enforces with a shrink-only baseline. - CI must be green: lint, size check, typecheck, tests (TS + Rust), contract drift. +## Releasing + +All publishable packages (`@cloudhal/*` and `create-cloudhal`) share one version: a changesets "fixed" group. A release is one `vX.Y.Z` tag and one GitHub Release, published from CI by `.github/workflows/release.yml`: + +1. **Changesets.** Every PR that changes a published package adds one (`pnpm changeset`). In 0.x, use `patch` for fixes and additive features, so `^0.x.y` ranges (the template, existing projects) pick them up; `minor` means a breaking change. +2. **Release PR.** On each push to `main`, the Changesets action opens or updates `chore(release): version packages` with the version bump and changelogs. GitHub does not run CI on PRs opened by Actions; the publish job runs the full checks instead. Merging it is the decision to release. +3. **Approval.** The merge starts the `publish` job, which waits for the `npm-publish` environment's approval (the maintainer, with 2FA). Approve it in the run's page on GitHub. +4. **Publish.** `scripts/publish-ci.ts` checks the build, packs every package and installs the tarballs from scratch with npm (`scripts/smoke-pack.ts`), publishes exactly those tarballs with npm trusted publishing (OIDC, with provenance), waits until npm serves all of them, then creates the `vX.Y.Z` tag and the GitHub Release from the changelogs. + +**If something fails:** re-run the workflow (Actions › Release › Run workflow). The script skips packages npm already has and an existing tag or release, so a re-run finishes a half-done release. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing. + +**Turning it off:** the repository variable `RELEASE_ENABLED` gates the workflow. Without it, release by hand: `pnpm changeset version` in a PR, merge, then `pnpm release` from an up-to-date `main` and push one `vX.Y.Z` tag. + ## Where to start Issues labelled `good first issue` and `help wanted` are curated for newcomers; most live in capabilities, starters and docs, which do not touch the core graph. Larger changes start as an RFC in `docs/rfcs/` (see [GOVERNANCE.md](GOVERNANCE.md)). diff --git a/packages/cli/test/release-plan.test.ts b/packages/cli/test/release-plan.test.ts new file mode 100644 index 00000000..7c0a203f --- /dev/null +++ b/packages/cli/test/release-plan.test.ts @@ -0,0 +1,79 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + type Pkg, + readPackages, + releaseNotes, + sharedVersion, + unpublished, +} from "../../../scripts/release-plan.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const pkg = (name: string, version: string): Pkg => ({ dir: name, name, version }); + +describe("release plan", () => { + it("the repo's publishable packages share one version (changesets fixed group)", () => { + const pkgs = readPackages(ROOT); + expect(pkgs).toHaveLength(13); + expect(sharedVersion(pkgs)).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("refuses packages at different versions", () => { + expect(() => sharedVersion([pkg("a", "0.3.4"), pkg("b", "0.3.5")])).toThrow( + /a@0\.3\.4, b@0\.3\.5/, + ); + }); + + it("lists what npm lacks, keeping publish order", async () => { + const onNpm = async (name: string) => name === "b"; + const missing = await unpublished( + [pkg("a", "1.0.0"), pkg("b", "1.0.0"), pkg("c", "1.0.0")], + onNpm, + ); + expect(missing.map((p) => p.name)).toEqual(["a", "c"]); + }); + + it("builds one set of notes: entries once, no dependency noise, no hashes", () => { + const api = [ + "# @cloudhal/api", + "", + "## 0.3.5", + "", + "### Patch Changes", + "", + "- abc1234: Agent keys work as Bearer.", + " Second line of the entry.", + "- Updated dependencies [abc1234]", + " - @cloudhal/mcp-server@0.3.5", + "", + "## 0.3.4", + "", + "- def5678: Older entry.", + ].join("\n"); + const cli = [ + "# @cloudhal/cli", + "", + "## 0.3.5", + "", + "- 9999999: New projects exempt pnpm's release age.", + "- Updated dependencies [abc1234]", + "- @cloudhal/core-contracts@0.3.5", + " - @cloudhal/storage@0.3.5", + ].join("\n"); + const notes = releaseNotes([api, cli, api], "0.3.5"); + expect(notes).toContain("- Agent keys work as Bearer.\n Second line of the entry."); + expect(notes).toContain("- New projects exempt pnpm's release age."); + expect(notes.match(/Agent keys work as Bearer/g)).toHaveLength(1); + expect(notes).not.toContain("Updated dependencies"); + expect(notes).not.toContain("@cloudhal/core-contracts@0.3.5"); + expect(notes).not.toContain("Older entry"); + expect(notes).toContain("npm install @cloudhal/cli@0.3.5"); + }); + + it("says so when a release only moves dependencies", () => { + expect(releaseNotes(["## 0.3.6\n\n- Updated dependencies [x]"], "0.3.6")).toContain( + "- Dependency updates only.", + ); + }); +}); diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts new file mode 100644 index 00000000..a23902b2 --- /dev/null +++ b/scripts/publish-ci.ts @@ -0,0 +1,102 @@ +/** + * Publishes a release from CI (`.github/workflows/release.yml`, after the `npm-publish` + * environment's approval): npm trusted publishing (OIDC) with provenance, then one `v` + * tag and one GitHub Release for the whole fixed group. + * + * node scripts/publish-ci.ts --plan # prints `publish=true|false` and `version=` for $GITHUB_OUTPUT + * node scripts/publish-ci.ts # build, smoke-test, publish what npm lacks, tag, release + * + * Re-runnable: packages npm already has are skipped, an existing tag or release is left alone. + * The tarballs published are the ones the smoke test installed (`smoke-pack.ts --tarballs`). + */ +import { execFileSync } from "node:child_process"; +import { appendFileSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readPackages, releaseNotes, sharedVersion, unpublished } from "./release-plan.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sh = (cmd: string, args: string[], quiet = false) => + execFileSync(cmd, args, { cwd: ROOT, encoding: "utf8", stdio: quiet ? "pipe" : "inherit" }); + +async function onNpm(name: string, version: string): Promise { + const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${version}`); + return res.ok; +} + +const pkgs = readPackages(ROOT); +const version = sharedVersion(pkgs); +const missing = await unpublished(pkgs, onNpm); + +if (process.argv.includes("--plan")) { + const out = `publish=${missing.length > 0}\nversion=${version}\n`; + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out); + console.log(missing.length ? `to publish: ${missing.map((p) => p.name).join(", ")}` : out); + process.exit(0); +} + +if (missing.length) { + const tarballs = mkdtempSync(path.join(os.tmpdir(), "cloudhal-release-")); + // Builds, packs and installs every package from scratch with npm; fails the release on error. + sh(process.execPath, ["scripts/smoke-pack.ts", "--tarballs", tarballs]); + const files = readdirSync(tarballs); + for (const p of missing) { + const tgz = `${p.name.replace("@", "").replace("/", "-")}-${p.version}.tgz`; + if (!files.includes(tgz)) throw new Error(`no tarball ${tgz} in ${tarballs}`); + // OIDC: npm exchanges the workflow's identity token; provenance is attached automatically. + sh("npm", [ + "publish", + path.join(tarballs, tgz), + "--access", + "public", + "--provenance", + "--ignore-scripts", + ]); + } + for (let i = 0; i < 30 && (await unpublished(pkgs, onNpm)).length; i++) + await new Promise((r) => setTimeout(r, 10_000)); + const left = await unpublished(pkgs, onNpm); + if (left.length) throw new Error(`npm does not serve yet: ${left.map((p) => p.name).join(", ")}`); +} else console.log(`npm already has every package at ${version}`); + +const tag = `v${version}`; +if (!sh("git", ["tag", "--list", tag], true).trim()) { + sh("git", [ + "-c", + "user.name=github-actions[bot]", + "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", + "tag", + "-a", + tag, + "-m", + `Cloudhal ${version}`, + ]); + sh("git", ["push", "origin", tag]); +} +let released = true; +try { + sh("gh", ["release", "view", tag], true); +} catch { + released = false; +} +if (!released) { + const changelogs = pkgs.map((p) => + readFileSync(path.join(ROOT, "packages", p.dir, "CHANGELOG.md"), "utf8"), + ); + const notes = path.join(os.tmpdir(), `cloudhal-${version}-notes.md`); + writeFileSync(notes, releaseNotes(changelogs, version)); + sh("gh", [ + "release", + "create", + tag, + "--title", + `Cloudhal ${version}`, + "--notes-file", + notes, + "--verify-tag", + "--latest", + ]); +} +console.log(`released ${tag}`); diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts new file mode 100644 index 00000000..c8db6ae6 --- /dev/null +++ b/scripts/release-plan.ts @@ -0,0 +1,89 @@ +/** + * What a CI release publishes (ADR-0013; the fixed changesets group): the one version all + * publishable packages share, which of them npm does not have yet, and the release notes for the + * single `v` tag and GitHub Release. Pure functions over package files, used by + * `scripts/publish-ci.ts` and its tests. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { PACKAGES } from "./build-packages.ts"; + +export interface Pkg { + /** Folder under `packages/`. */ + readonly dir: string; + readonly name: string; + readonly version: string; +} + +export function readPackages(root: string): Pkg[] { + return PACKAGES.map((dir) => { + const pkg = JSON.parse( + readFileSync(path.join(root, "packages", dir, "package.json"), "utf8"), + ) as { name: string; version: string }; + return { dir, name: pkg.name, version: pkg.version }; + }); +} + +/** The fixed group's version; throws if the packages disagree (a half-applied release). */ +export function sharedVersion(pkgs: readonly Pkg[]): string { + const versions = new Set(pkgs.map((p) => p.version)); + if (versions.size !== 1) { + const list = pkgs.map((p) => `${p.name}@${p.version}`).join(", "); + throw new Error(`publishable packages must share one version (fixed group): ${list}`); + } + return [...versions][0] as string; +} + +/** The packages whose current version npm does not have, in dependency (publish) order. */ +export async function unpublished( + pkgs: readonly Pkg[], + onNpm: (name: string, version: string) => Promise, +): Promise { + const out: Pkg[] = []; + for (const p of pkgs) if (!(await onNpm(p.name, p.version))) out.push(p); + return out; +} + +/** Lines the fixed group writes into every changelog: dependency bumps, not changes. */ +const DEPENDENCY_ONLY = /^- (?:Updated dependencies|(?:@cloudhal\/[a-z-]+|create-cloudhal)@\d)/; + +/** + * Release notes for `version`: every entry under `## ` in the packages' changelogs, + * once each, without the "Updated dependencies" lines the fixed group adds to every package. + */ +export function releaseNotes(changelogs: readonly string[], version: string): string { + const seen = new Set(); + const entries: string[] = []; + for (const text of changelogs) { + const start = text.indexOf(`\n## ${version}\n`); + if (start === -1) continue; + const rest = text.slice(start + version.length + 5); + const section = rest.slice(0, rest.search(/\n## /) === -1 ? undefined : rest.search(/\n## /)); + for (const block of section.split(/\n(?=- )/)) { + const entry = block.trim(); + if (!entry.startsWith("- ") || DEPENDENCY_ONLY.test(entry)) continue; + const text = entry.replace(/^- [0-9a-f]{7,40}: /, "- "); + if (!seen.has(text)) { + seen.add(text); + entries.push(text); + } + } + } + return [ + `All \`@cloudhal/*\` packages and \`create-cloudhal\` at **${version}**.`, + "", + "## Changes", + "", + ...(entries.length ? entries : ["- Dependency updates only."]), + "", + "## Upgrade", + "", + "```sh", + `npm install @cloudhal/cli@${version} @cloudhal/core-ts@${version} @cloudhal/astro-renderer@${version} @cloudhal/cloudflare@${version}`, + "npx cloudhal deploy", + "```", + "", + "Docs: https://cloudhal.dev/docs/start/installation#upgrading", + "", + ].join("\n"); +} diff --git a/scripts/smoke-pack.ts b/scripts/smoke-pack.ts index edfd7955..11273e1b 100644 --- a/scripts/smoke-pack.ts +++ b/scripts/smoke-pack.ts @@ -5,7 +5,10 @@ * Cloudflare Workers with `cloudhal deploy --dry-run`. Needs network access to the npm registry * for third-party dependencies; @cloudhal/* come from the local tarballs. * - * node scripts/smoke-pack.ts [--keep] + * node scripts/smoke-pack.ts [--keep] [--tarballs ] + * + * `--tarballs ` packs into that folder and leaves the tarballs there: the release job + * (`publish-ci.ts`) publishes exactly what this test installed. */ import { execFileSync, spawn } from "node:child_process"; import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -16,6 +19,8 @@ import { PACKAGES } from "./build-packages.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const keep = process.argv.includes("--keep"); +const tarballsArg = process.argv.indexOf("--tarballs"); +const tarballsDir = tarballsArg === -1 ? undefined : process.argv[tarballsArg + 1]; const run = (cmd: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = {}) => { console.log(`$ ${cmd} ${args.join(" ")} (in ${path.relative(os.tmpdir(), cwd) || cwd})`); const { VITEST: _v, NODE_ENV: _n, npm_config_user_agent: _ua, ...base } = process.env; @@ -25,7 +30,7 @@ const run = (cmd: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = const tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-smoke-")); try { run(process.execPath, ["scripts/build-packages.ts"], ROOT); - const tarballs = path.join(tmp, "tarballs"); + const tarballs = tarballsDir ? path.resolve(tarballsDir) : path.join(tmp, "tarballs"); for (const name of PACKAGES) run("pnpm", ["pack", "--pack-destination", tarballs], path.join(ROOT, "packages", name)); const files = await readdir(tarballs);