Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 74 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<version>` 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<version> 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).
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/test/release-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]",
"",
"## 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/[email protected]",
" - @cloudhal/[email protected]",
].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/[email protected]");
expect(notes).not.toContain("Older entry");
expect(notes).toContain("npm install @cloudhal/[email protected]");
});

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.",
);
});
});
102 changes: 102 additions & 0 deletions scripts/publish-ci.ts
Original file line number Diff line number Diff line change
@@ -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<version>`
* 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<boolean> {
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}`);
Loading
Loading