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);