Skip to content
Open
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
86 changes: 86 additions & 0 deletions .agents/docs/architecture/release-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Release channels

What each GHCR image tag, npm dist-tag and GitHub release means, and which branch moves it. Cutting a
release is `.agents/docs/playbooks/cut-a-release.md`; this file is the model it acts on.

> **`latest` is a tagged release, never a branch head.** Default deployments pull `latest`
> (`RELEASE_CHANNEL=latest` in `.env.template`). Anything that moves `latest` changes what every
> default deployment runs on its next pull, so only a run that also creates a `v*` tag may move it.

## The decision is `planRelease`, and it reads only the repository

`.github/scripts/release.cjs` exports `planRelease({ ref, tags, version })`. `release.yaml`'s
`configure` job calls it with the dispatched or pushed ref, every `v*` git tag and the root
`package.json` version, and every later job acts on its outputs. It never reads GHCR or npm: a deleted
image or package must not be able to wedge the next release (the GHCR lookup it replaced did exactly
that).

**One Release runs at a time, across every ref.** The plan is only as fresh as its tag snapshot, so
`release.yaml` uses one concurrency group with no ref in it. Without it, a `release/2.5` dispatch and a
release from `main` overlap, both see neither tag, both plan `latest`, and the one that finishes last
owns `latest`; or two refs release the same version from different commits. A full re-run plans
again from fresh tags; `gh run rerun --failed` does not, so it is never used on this workflow.

**A release must be the newest of its line.** `planRelease` refuses an untagged version that is lower
than a tag on its own line (`2.5.3` once `v2.5.4` exists), so a late retry cannot move the `2.5` image
tag or the `release-2.5` dist-tag back.

**A re-run never leaves an npm channel behind.** A re-run planned after a newer release can carry another
`npmTag` than the run that published, and trusted publishing cannot move a dist-tag. So
`.github/scripts/publish-npm.cjs` skips a published version only while `npmTag` points at it, and fails
otherwise — the fix is a fresh patch, not a green run with `release-<major>.<minor>` on an older version.

| Output | Meaning |
| --------- | ------------------------------------------------------------------------------------------ |
| `release` | `v<version>` is not a tag yet, so this run publishes it |
| `latest` | `release`, and `<version>` is higher than every `v<major>.<minor>.<patch>` tag |
| `edge` | the ref is `main` |
| `channel` | `<major>.<minor>` |
| `npmTag` | `latest` when `latest`, else `release-<major>.<minor>`; npm rejects a semver-like dist-tag |

## What each run publishes

| Run | GHCR image tags | npm | GitHub release |
| ------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------- | --------------------- |
| push to `main`, version already tagged | `edge`, `sha-<7>` | nothing | nothing |
| push to `main`, new version | `edge`, `sha-<7>`, `<version>`, `<channel>`, `latest`¹ | every package under `npmTag` | `v<version>`, latest¹ |
| dispatch on `release/<major>.<minor>`, new version | `<version>`, `<channel>`, `latest`¹ | every package under `npmTag` | `v<version>`, latest¹ |
| dispatch on `release/<major>.<minor>`, version already tagged | — `configure` fails: bump first | — | — |
| any other ref | — `configure` fails | — | — |

¹ only when the `latest` output is `true`.

**A release branch's version must sit on its line.** `planRelease` fails `configure` when the
version on `release/2.5` is not `2.5.*`, so a merge from `main` that brought a version bump with it
cannot publish under the wrong channel.

**The GitHub tag goes on the commit the run built.** `Create Release` sets
`target_commitish: ${{ github.sha }}`; without it `softprops/action-gh-release` creates a new tag on
the default branch, which for a release branch is a different commit.

**`edge` builds carry the last version.** `RELEASE_VERSION` must be `<major>.<minor>.<patch>`
(`packages/release-info/AGENTS.md`), so an `edge` image reports the root `package.json` version even
though it is newer code. The `org.opencontainers.image.revision` label is the exact commit.

## Release branches

A release branch is `release/<major>.<minor>`, cut from the commit the line's images were built from,
and released only by `gh workflow run Release --ref release/<major>.<minor>`. A push to it publishes
nothing. Changes land on it through a pull request, which `ci.yaml` gates as it gates one to `main`.

After a release from a branch, merge the branch back into `main`. That carries its `CHANGELOG.md`
section and makes its tag reachable, so `scripts/changelog.ts` on `main` starts the next range at it.

What holds a branch other than these two is hosted configuration: the `release` environment's
deployment-branch policy (`main` and `release/*`), each npm trusted publisher (names `release.yaml`
and the `release` environment), and the `Release tags` ruleset (only admins move or delete a `v*`
tag). GHCR is not covered — any workflow on any branch can push an image with `GITHUB_TOKEN`.

## Base images

**`main` floats; a release branch pins.** On `main` every Dockerfile builds `FROM node:lts-krypton`,
so each `edge` build and each release from `main` picks up the newest Node 24 patch with no commit. A
release branch replaces that line with the exact version, `FROM node:<major>.<minor>.<patch>`, in a
`fix:` commit before its first bump, so a re-run of an old release builds the same runtime. A Node
security release for a maintained line is therefore a `fix:` commit on its branch that moves the pin,
then a patch bump; nothing updates a branch's pin for you.
9 changes: 5 additions & 4 deletions .agents/docs/architecture/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ What is tested where, and what CI actually blocks a merge on. Writing an e2e tes
## The failure that is silent

The root `vitest.config.ts` declares
`projects: ['apps/*/vitest.config.ts', 'packages/*/vitest.config.ts', 'runtime/*/vitest.config.ts']`.
`projects: ['.github/vitest.config.ts', 'apps/*/vitest.config.ts', 'packages/*/vitest.config.ts', 'runtime/*/vitest.config.ts']`.
**A workspace with no `vitest.config.ts` of its own is invisible to `pnpm test`.** A test file added
to `packages/licenses` today is collected by nothing, reported by nothing, and passes CI
green.
Expand All @@ -28,11 +28,12 @@ path exercised against a real database is Playwright.

## Vitest projects

Twenty-one. Scope a run with `pnpm exec vitest --project <name>`; the name is the `name` field in that
Twenty-two. Scope a run with `pnpm exec vitest --project <name>`; the name is the `name` field in that
package's config, which is **not** always the directory name.

| Project | Package | Notable config |
| ------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `github` | `.github` | not a workspace. Tests `scripts/release.cjs` (the release plan) and `scripts/publish-npm.cjs` (the npm publish step), the two `.github` files in the coverage `include`; `scripts/issues.cjs` stays untested |
| `api` | `apps/api` | libnest SWC plugin, confined to `apps/api` sources; `globals: true` |
| `web` | `apps/web` | `environment: 'happy-dom'`; redeclares the `@` alias |
| `gateway` | `apps/gateway` | node environment; redeclares the `@` alias. Request schemas, `resolveLanguage`, and the HTML `src/entry-server.tsx` renders — the routers and hydration are covered by `testing/src/specs/gateway-*.spec.ts`. A component test opts into happy-dom per file with a `// @vitest-environment happy-dom` docblock |
Expand Down Expand Up @@ -144,7 +145,7 @@ generated `route-tree.ts`, and the bare entrypoints.
## What CI gates

`.github/workflows/ci.yaml`, one job (`lint-and-test`, ubuntu-latest), triggered on
`pull_request` to `main` and `workflow_dispatch` only.
`pull_request` to `main` or a `release/**` branch, and `workflow_dispatch` only.

| # | Step | Command |
| --- | -------------------- | ------------------------------------------------------ |
Expand All @@ -165,7 +166,7 @@ Not gated, despite existing in the repo:
- **No format check.** Prettier runs only in the `.husky/pre-commit` hook (`prettier-pre-commit`),
which formats rather than verifies; the hook does not run lint or tests.

`.github/workflows/release.yaml` (push to `main`) has a `Validate` job that runs `pnpm lint` only —
`.github/workflows/release.yaml` (push to `main`, dispatch on `release/<major>.<minor>`) has a `Validate` job that runs `pnpm lint` only —
**no unit tests and no e2e before a release build.**

`.github/workflows/commitlint.yaml` (`pull_request` to any branch, so forks included) runs
Expand Down
Loading
Loading