From 30ec6929c49660125118d18f5e34418f6a5364cb Mon Sep 17 00:00:00 2001 From: zanjonke Date: Fri, 7 Aug 2026 08:09:09 +0200 Subject: [PATCH] docs: add RELEASING.md --- AGENTS.md | 1 + CLAUDE.md | 1 + RELEASING.md | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 RELEASING.md diff --git a/AGENTS.md b/AGENTS.md index bf94e97..0cc093c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ node --test test/cli.test.mjs # run a single test f - Test runner is **Node's built-in `node:test`** (`node:assert/strict`) — not jest/vitest, no test framework dependency. Requires Node ≥18. - `test/cli.test.mjs` mixes unit tests (importing named exports from `bin/cli.mjs`) with black-box integration tests that `spawnSync` the real CLI into isolated temp `HOME`/cwd dirs. +- Releases are cut by publishing a GitHub Release tagged `vX.Y.Z`, which publishes to npm via OIDC — see [RELEASING.md](RELEASING.md). The version lives in the tag, so `package.json` on `main` is intentionally stale. - **There is no working build or lint step.** `package.json` declares `build`/`clean` scripts (`tsx bin/forge-build.ts`) and `tsconfig.json` references `bin/**/*.ts` + `runtimes/**/*.ts`, but **those files do not exist** — `npm run build`/`npm run clean` error out. The TS toolchain (tsx/typescript) is vestigial; the CLI is plain `.mjs` run directly by Node. Don't try to build. ## The installer CLI (`bin/cli.mjs`) diff --git a/CLAUDE.md b/CLAUDE.md index bf94e97..0cc093c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ node --test test/cli.test.mjs # run a single test f - Test runner is **Node's built-in `node:test`** (`node:assert/strict`) — not jest/vitest, no test framework dependency. Requires Node ≥18. - `test/cli.test.mjs` mixes unit tests (importing named exports from `bin/cli.mjs`) with black-box integration tests that `spawnSync` the real CLI into isolated temp `HOME`/cwd dirs. +- Releases are cut by publishing a GitHub Release tagged `vX.Y.Z`, which publishes to npm via OIDC — see [RELEASING.md](RELEASING.md). The version lives in the tag, so `package.json` on `main` is intentionally stale. - **There is no working build or lint step.** `package.json` declares `build`/`clean` scripts (`tsx bin/forge-build.ts`) and `tsconfig.json` references `bin/**/*.ts` + `runtimes/**/*.ts`, but **those files do not exist** — `npm run build`/`npm run clean` error out. The TS toolchain (tsx/typescript) is vestigial; the CLI is plain `.mjs` run directly by Node. Don't try to build. ## The installer CLI (`bin/cli.mjs`) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..89c81b0 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,159 @@ +# Releasing plain-forge + +Publishing a GitHub Release is the release. Everything else is automated. + +``` +you: create release v1.0.21 → tests → npm publish → Deployments + Slack +``` + +## The version lives in the tag, not in package.json + +The release tag is the single source of truth for the version. The workflow reads +`v1.0.21` off the tag and writes `1.0.21` into `package.json` **inside the runner**, +immediately before publishing. Nothing is committed back. + +This is deliberate: writing the version to `main` would mean a bookkeeping commit on +a protected branch, which needs either a stored credential or a self-merging PR. The +tag avoids both — the same model the [codeplain](https://github.com/Codeplain-ai/codeplain) +repo uses, where hatch-vcs derives the version from the tag. + +The consequence, so it doesn't surprise anyone: **`package.json` on `main` is stale by +design.** At the time of writing it says `1.0.19` while npm serves `1.0.20`. The tag and +the registry are authoritative; the file is scaffolding. + +## Cutting a release + +1. Make sure `main` is green and has everything you want to ship. +2. Create a GitHub Release: + - **Choose a tag** → type `vX.Y.Z` → *Create new tag on publish* + - **Target**: `main` + - **Title**: `vX.Y.Z` + - **Notes**: see [Release notes](#release-notes) below + - Leave **Set as a pre-release** unchecked for a normal release +3. Publish it. The `publish` workflow starts on its own. + +Or from the CLI: + +```bash +gh release create v1.0.21 --target main --title v1.0.21 --notes-file notes.md +``` + +Tags are `v`-prefixed. A bare `1.0.21` would still publish — the workflow strips a +leading `v` — but every message, link and convention here assumes the prefix. + +## What the workflow does + +[`.github/workflows/publish.yml`](.github/workflows/publish.yml), triggered by +`release: [published]`: + +| step | detail | +|---|---| +| Test | full suite against the tagged tree; a failure stops the release and posts to Slack | +| Read version | strips the `v`, refuses anything that isn't semver | +| Refuse duplicates | fails if that version is already on npm | +| Set version | `npm version --no-git-tag-version` in the runner only | +| Publish | `npm publish --access public`, or `--tag next` for a pre-release | +| Record | Deployment under the `npm` environment; Slack start / success / failure | + +Auth is **OIDC trusted publishing** — there is no npm token in this repo. npm exchanges +the workflow's OIDC token for a short-lived credential and attaches a SLSA provenance +attestation automatically. + +### Pre-releases + +Tick **Set as a pre-release** on the release form. The package publishes under the +`next` dist-tag, so `npm install plain-forge` (which resolves `latest`) is unaffected. +Semver pre-release tags work too: `v1.1.0-rc.1`. + +## Release notes + +Generated notes (`--generate-notes`, or the button on the form) list merged PRs and are +a fine starting point, but the convention here is a written summary in three sections, +with the changelog link last: + +```markdown +## New features & improvements + +- **Short bold lead.** What changed and why it matters, in a sentence or two. + +## Fixes + +- What was broken, and what the fix actually was. + +## Internal improvements + +- Refactors, CI, dependencies — things that don't change behaviour for users. + +**Full Changelog**: https://github.com/Codeplain-ai/plain-forge/compare/v1.0.20...v1.0.21 +``` + +Editing a release afterwards fires `release: edited`, not `published`, so fixing typos +in the notes will **not** re-trigger a publish. That's safe to do at any time. + +## Releasing without GitHub Actions + +[`scripts/deploy.sh`](scripts/deploy.sh) performs the same release from a workstation — +for when Actions is down, or you want to ship without waiting on it. + +```bash +npm login # OIDC only works from CI; locally you publish as yourself +./scripts/deploy.sh --dry-run # test + pack the latest release, publish nothing +./scripts/deploy.sh # publish the latest GitHub release +./scripts/deploy.sh --tag v1.0.21 # publish a specific tag +./scripts/deploy.sh --otp 123456 # if your npm account requires 2FA on publish +``` + +It checks the tag out into a temporary git worktree, so your working tree, branch and +uncommitted edits have no bearing on what ships. It runs the same guards (semver, +already-published), records the same Deployment, and posts the same Slack messages. + +Slack comes from `SLACK_WEBHOOK_URL` in the environment, or from a `.env.release` file +at the repo root — gitignored, and the only place a webhook should live locally. + +## Prerequisites (already configured) + +| what | where | note | +|---|---|---| +| Trusted publisher | npmjs.com → `plain-forge` → Settings → Trusted Publisher | GitHub Actions, `Codeplain-ai/plain-forge`, workflow `publish.yml` | +| `SLACK_WEBHOOK_URL` | repo secret | the only secret this repo has; notifications are skipped without it | +| Branch protection | `main` | `Test (node 18 / 20 / 22)` required, no force pushes, no deletion | + +**The workflow filename is part of the trust contract.** Renaming `publish.yml` breaks +publishing until the trusted publisher entry on npmjs.com is updated to match. + +## Troubleshooting + +**"is already published — tag a new version."** npm versions are immutable. Cut a new +version; don't try to overwrite. + +**"is not a semver release tag."** The tag must be `vX.Y.Z`, optionally with a +pre-release suffix. `release-2` or `v1.0` will not do. + +**The workflow ran an old version of itself.** For `release` events the workflow file is +read from the commit the tag points at, not from the tip of `main`. If you changed the +workflow after tagging, re-cut the release (below) so the tag lands on the newer commit. + +**Re-running a failed run doesn't pick up fixes.** A re-run replays the same commit. To +run a *changed* workflow, delete and re-create the release so a fresh `published` event +fires: + +```bash +gh release delete v1.0.21 --yes --cleanup-tag +gh release create v1.0.21 --target main --title v1.0.21 --notes-file notes.md +``` + +Safe as long as nothing was published from that tag — deleting a release does **not** +unpublish anything from npm, and it **does** destroy the release notes, so keep them in +a file. + +**The run sits in `queued` forever.** Usually a GitHub incident rather than anything in +this repo — check [githubstatus.com](https://www.githubstatus.com). Queued jobs may +never start and can become uncancellable. Either wait for the all-clear and re-cut, or +publish with `scripts/deploy.sh`. + +## After a release + +- npm: `npm view plain-forge version` +- Provenance: `npm view plain-forge@ dist.attestations` +- Deployments: repo → **Deployments → npm** +- Users update with `npx plain-forge@latest update`