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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
159 changes: 159 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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@<version> dist.attestations`
- Deployments: repo → **Deployments → npm**
- Users update with `npx plain-forge@latest update`
Loading