diff --git a/.agents/docs/architecture/release-channels.md b/.agents/docs/architecture/release-channels.md new file mode 100644 index 000000000..9205d1380 --- /dev/null +++ b/.agents/docs/architecture/release-channels.md @@ -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-.` on an older version. + +| Output | Meaning | +| --------- | ------------------------------------------------------------------------------------------ | +| `release` | `v` is not a tag yet, so this run publishes it | +| `latest` | `release`, and `` is higher than every `v..` tag | +| `edge` | the ref is `main` | +| `channel` | `.` | +| `npmTag` | `latest` when `latest`, else `release-.`; 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>`, ``, ``, `latest`¹ | every package under `npmTag` | `v`, latest¹ | +| dispatch on `release/.`, new version | ``, ``, `latest`¹ | every package under `npmTag` | `v`, latest¹ | +| dispatch on `release/.`, 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 `..` +(`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/.`, cut from the commit the line's images were built from, +and released only by `gh workflow run Release --ref release/.`. 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:..`, 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. diff --git a/.agents/docs/architecture/testing-strategy.md b/.agents/docs/architecture/testing-strategy.md index 338fbe34c..39c4bd48a 100644 --- a/.agents/docs/architecture/testing-strategy.md +++ b/.agents/docs/architecture/testing-strategy.md @@ -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. @@ -28,11 +28,12 @@ path exercised against a real database is Playwright. ## Vitest projects -Twenty-one. Scope a run with `pnpm exec vitest --project `; the name is the `name` field in that +Twenty-two. Scope a run with `pnpm exec vitest --project `; 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 | @@ -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 | | --- | -------------------- | ------------------------------------------------------ | @@ -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/.`) 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 diff --git a/.agents/docs/playbooks/cut-a-release.md b/.agents/docs/playbooks/cut-a-release.md index fcec88a25..e464177f6 100644 --- a/.agents/docs/playbooks/cut-a-release.md +++ b/.agents/docs/playbooks/cut-a-release.md @@ -1,15 +1,17 @@ # Cut a release -A release is one version bump merged to `main`. Everything after the merge is automatic: +A release is one version bump merged to `main`, or pushed to a `release/.` branch and +dispatched (§From a release branch below). Everything after that is automatic: `.github/workflows/release.yaml` builds and pushes the container images, publishes the npm packages, and creates the GitHub release, whose body is the new version's section of `CHANGELOG.md`. Which packages are publishable is in `.agents/docs/workspace-map.md`; what CI gates is in `.agents/docs/architecture/testing-strategy.md`; the `RELEASE_VERSION` build-arg contract is in -`packages/release-info/AGENTS.md`. This file is only the order of operations. +`packages/release-info/AGENTS.md`; what each image tag and npm dist-tag means, and which run moves +`latest`, is in `.agents/docs/architecture/release-channels.md`. This file is only the order of operations. **Plan around this:** the root `package.json` version _is_ the release identity — `.github/scripts/release.cjs` reads it and it becomes both the container tag and the GitHub tag `v${version}` — but every npm package publishes at **its own** `package.json` version, and the publish -step skips any version already on the registry (`release.yaml:132-139`): +step skips any version already on the registry (`.github/scripts/publish-npm.cjs`): ``` Skipping @opendatacapture/runtime-v1@ (already published) @@ -33,8 +35,8 @@ agreement — nothing in CI compares them, and hand edits have moved the root al 2. **Bump from a branch that already contains `main`.** `git fetch origin && git merge origin/main`. `increment-version.ts` derives the next version from the root `package.json` in your working tree - alone, so a branch trailing `main` computes a version that is already released — the run then re-pushes - the same image tags, updates the same GitHub release, and prints `Skipping` for every package. Done + alone, so a branch trailing `main` computes a version that is already released — the run then finds its + tag and pushes `edge` alone, releasing nothing. Done when `git merge-base --is-ancestor origin/main HEAD` exits 0. 3. **Run `./scripts/increment-version.ts` from the repo root.** There is no `pnpm` script for it — invoke @@ -79,41 +81,39 @@ agreement — nothing in CI compares them, and hand edits have moved the root al of it, and `--commit` answers that prompt up front. It commits only; the tag is the release workflow's (`v${version}`, on the GitHub release). Saying yes skips the step-4 read of `CHANGELOG.md`, which is the only review that section gets before it becomes the release body, so read the commit afterwards. - `ci.yaml` fires on `pull_request` to `main` and on - `workflow_dispatch`, never on a push, so a PR based on `dev` or any other branch runs no lint, no unit - tests and no e2e — silently (`commitlint.yaml` runs on a PR to any branch). In-repo work branches on + A PR against any base but `main` or `release/**` runs no lint, no unit tests and no e2e — silently + (`.agents/docs/architecture/testing-strategy.md` §What CI gates). In-repo work branches on origin and merges into `main`; `CONTRIBUTING.md` describes a fork path, which addresses outside contributors. There is no changeset: `CHANGELOG.md` is the record, and the GitHub release body is a copy of its new section. -6. **Land one release at a time.** The workflow's concurrency group is per-workflow-per-ref with - `cancel-in-progress: true` (`release.yaml:9-11`), so a second merge cancels the release in flight. A +6. **Land one release at a time.** One concurrency group covers every ref, with + `cancel-in-progress: false` (`release.yaml:12-14`): a run waits for the one in flight, but GitHub keeps + only one waiting run, so a third run cancels the waiting one — dispatch it again. A `build` that is cancelled or fails leaves `publish-npm` and `release` reported as **skipped** rather than failed — `.agents/skills/odc-release/SKILL.md` reads that job tree; runs `30389796062` and `30378779338` show that tree. 7. **Watch the run:** `gh run watch`, or `gh run list --workflow=Release --limit 1` for its id. - | Job | What it does | Skips when | - | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | - | `configure` | derives the build matrix from `docker compose config` through the jq filter described below; runs `release.cjs` for `version` and `should_release` | never; **fails** when dispatched from any ref but `main` | - | `validate` | `pnpm lint` | never | - | `build` | buildx `linux/amd64,linux/arm64` per matrix leg, pushing `latest` and the bare version, with `RELEASE_VERSION` as a build arg | never — guards on `should_release`, which is `'true'` whenever `configure` passed | - | `publish-npm` | turbo-builds each publishable package and its closure, then publishes each version not already on npm, over OIDC (no `NPM_TOKEN`), in the `release` environment | never — same guard | - | `release` | extracts the `## ${version}` section of `CHANGELOG.md` (`scripts/changelog.ts section`) and creates the GitHub release tagged `v${version}` with it as the body | any of its `needs` skipped or failed | + | Job | What it does | Skips when | + | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `configure` | derives the build matrix from `docker compose config` through the jq filter described below; runs `release.cjs` for the release plan | never; **fails** on a ref that is neither `main` nor `release/.`, and on a release branch whose version is already tagged or off its line | + | `validate` | `pnpm lint` | never | + | `build` | buildx `linux/amd64,linux/arm64` per matrix leg, pushing the tags the plan enables, with `RELEASE_VERSION` as a build arg | never | + | `publish-npm` | turbo-builds each publishable package and its closure, then publishes each version not already on npm under the plan's `npmTag` (`.github/scripts/publish-npm.cjs`), over OIDC (no `NPM_TOKEN`), in the `release` environment | `release` is `false` — the version is already tagged | + | `release` | extracts the `## ${version}` section of `CHANGELOG.md` (`scripts/changelog.ts section`) and creates the GitHub release tagged `v${version}` on the built commit, with it as the body | `release` is `false`, or any of its `needs` skipped or failed | `release` **fails** — not skips — with `CHANGELOG.md has no section for ` when the bump bypassed `increment-version.ts`. That is deliberate: an empty body would look like every release before the changelog existed and would hide the bypass. - **Only `main` publishes.** `release.cjs` fails `configure` on a dispatch from any other ref, but - the dispatched ref supplies its own copy of the workflow, so that check stops accidents only. What - holds against a branch is hosted configuration, and it must stay in place: the `release` - environment allows deployments from `main` only, each package's npm trusted publisher names - `release.yaml` and the `release` environment, and the `Release tags` ruleset lets only admins - move or delete a `v*` tag. It cannot restrict creation: `Create Release` makes the tag with - `GITHUB_TOKEN`, and GitHub refuses the Actions app as a repository ruleset bypass actor. GHCR is - not covered — any workflow on a branch can push an image with `GITHUB_TOKEN`. + **Only `main` and `release/*` publish.** `release.cjs` fails `configure` on a dispatch from any + other ref, but the dispatched ref supplies its own copy of the workflow, so that check stops + accidents only. The hosted configuration that holds against a branch is listed in + `.agents/docs/architecture/release-channels.md` §Release branches, and it must stay in place. The + `Release tags` ruleset cannot restrict tag creation: `Create Release` makes the tag with + `GITHUB_TOKEN`, and GitHub refuses the Actions app as a repository ruleset bypass actor. **Four images ship: api, gateway, web and playground.** The filter keeps only compose services declaring **both** `build` and `image`, so a new image is a compose service with both keys and @@ -125,19 +125,10 @@ agreement — nothing in CI compares them, and hand edits have moved the root al leading `v` appears on the GitHub tag alone, and `RELEASE_VERSION` cannot carry one (`packages/release-info/AGENTS.md`). -8. **Expect a full build even when nothing changed.** `release.cjs` reads the root `package.json` - version and sets `should_release` to `'true'` on every run from `main` — by design, and it reads nothing - outside the repository to decide. Pushing without a bump re-pushes the same image tags and updates - the existing GitHub release in place rather than skipping. That is the intended behaviour, not a - finding. - - It used to ask GHCR which version carried the `latest` tag and skip when that matched. The - comparison never fired — the regex required a leading `v` and `build` pushes bare tags — while the - lookup threw `Failed to find package '' with tag 'latest'` and failed `configure` outright - whenever no image carried `latest`. Deleting a bad release does exactly that: `build` pushes - `latest` and the version onto one manifest, so removing that version removes both tags. Do not - reintroduce a gate that depends on registry state — a deleted release must never be able to wedge - the next one. +8. **Expect an `edge` build on every push to `main`, and a release only after a bump.** Without a + bump, `v` is already a tag, so the run pushes `edge` and `sha-<7>` and skips + `publish-npm` and `release`. The decision reads git tags, never GHCR or npm — do not reintroduce a + gate that depends on registry state; `.agents/docs/architecture/release-channels.md` says why. 9. **Confirm all three artifacts carry the new version** — the images, the npm packages, the GitHub release. Done when the `## Verify` block below is clean for each. @@ -147,6 +138,32 @@ agreement — nothing in CI compares them, and hand edits have moved the root al Run the script properly, commit, merge again — re-running a version the registry already has publishes nothing, so the fix is always forward. +## From a release branch + +A patch to a line `main` has moved past. The decision rules are in +`.agents/docs/architecture/release-channels.md`; these are only the steps. + +1. **Cut `release/.` from the commit the line's images were built from**, and push it. + The images' `org.opencontainers.image.revision` label names that commit; the `v*` tag may be older. + **A commit older than release-branch support (#1822) carries the old machinery**: its `configure` + refuses any ref but `main`, and its `ci.yaml` gates no PR to the branch. Make the branch's first + commit, pushed directly, `git checkout origin/main -- .github/workflows/release.yaml +.github/workflows/ci.yaml .github/scripts/release.cjs`; no later step works without it. +2. **Pin the base image on the branch.** Replace `FROM node:lts-krypton` in every Dockerfile with the + exact version, as a `fix:` commit; the floating tag on a branch would rebuild an old release on + whatever Node is newest. To find the version, run `docker run --rm node:lts-krypton node --version` + on the day you cut it. `.agents/docs/architecture/release-channels.md` §Base images says why. +3. **Land each fix on `main` first, then take it onto the branch by a PR against the branch.** + `ci.yaml` gates that PR as it gates one to `main`. +4. **Bump on the branch with `./scripts/increment-version.ts --bump patch`**, steps 3–5 above, but the + PR's base is the branch. `git describe` on the branch finds the line's last tag, so the changelog + range is the branch's own commits. +5. **Dispatch: `gh workflow run Release --ref release/.`.** The dispatched ref supplies + the workflow, so this needs the machinery from step 1. A push to the branch releases nothing. `configure` fails if the version is already tagged or is not on the branch's line. +6. **Merge the branch back into `main`.** That carries the `CHANGELOG.md` section and makes the tag + reachable, so the next range on `main` starts after it. Keep `main`'s version fields on a conflict + when `main` is ahead, and keep `main`'s floating `FROM node:lts-krypton` lines. + ## Verify ```sh @@ -154,14 +171,17 @@ node -p "require('./package.json').version" && scripts/list-publishable.sh # ro gh run list --workflow=Release --limit 1 # the run for your merge commit gh run view --json jobs -q '.jobs[]|"\(.name)\t\(.conclusion)"' # every build leg, publish-npm and release: success gh run view --log | grep -E 'Publishing|Skipping' -npm view @opendatacapture/runtime-v1 version # the version you just cut +npm view @opendatacapture/runtime-v1@ version # the version you just cut pnpm exec tsx scripts/changelog.ts section # the section the release body was cut from; exit 1 if absent -gh release view v # exists, tagged with the leading v, body is that section +gh release view v --json tagName,isLatest # tagged with the leading v; isLatest per the plan +git ls-remote origin "refs/tags/v" # the sha the run built, not main's head when cut from a branch +npm dist-tag ls @opendatacapture/runtime-v1 # latest, or release-., on ``` `Publishing @` for every package is the first run after a bump. `Skipping @ (already published)` is the **correct** output when re-running a release that already published — that -version guard is what makes a re-run safe — and is a defect only on the first run after a bump. +version guard is what makes a re-run safe — and is a defect only on the first run after a bump. A re-run whose +`npmTag` no longer points at the published version fails instead of skipping; cut a fresh patch. The images have no read-only command of their own: `gh api /orgs/DouglasNeuroinformatics/packages/container/open-data-capture-api/versions` answers diff --git a/.agents/docs/playbooks/run-locally.md b/.agents/docs/playbooks/run-locally.md index 1e9e6a5c0..30aa78a67 100644 --- a/.agents/docs/playbooks/run-locally.md +++ b/.agents/docs/playbooks/run-locally.md @@ -26,10 +26,10 @@ that never says the word storage. Step 4 is where you avoid it. Done when `bash --version` reports 5 or newer and `jq --version` and `openssl version` print. 2. **Install Node and enable pnpm.** `nvm install $(cat .nvmrc)` — `.nvmrc` pins `lts/krypton` and - `engines.node` requires `>=v24.15.0`. Then `corepack enable`; the pnpm version comes from + `engines.node` requires `^24.22.1 || >=26.12.1`. Then `corepack enable`; the pnpm version comes from `packageManager` in `package.json`, so there is nothing to pick by hand. - Done when `node --version` is v24.15.0 or newer and `pnpm --version` prints. + Done when `node --version` is v24.22.1 or a newer v24, or v26.12.1 or newer and `pnpm --version` prints. 3. **Generate `.env` — once per clone.** `pnpm generate:env` runs `scripts/generate-env.sh`, which writes `.env` wholesale from `.env.template`: it mints `SECRET_KEY`, `GATEWAY_API_KEY`, diff --git a/.agents/skills/odc-release/SKILL.md b/.agents/skills/odc-release/SKILL.md index 751783ea7..56732686f 100644 --- a/.agents/skills/odc-release/SKILL.md +++ b/.agents/skills/odc-release/SKILL.md @@ -1,12 +1,14 @@ --- name: odc-release -description: Ship a release of Open Data Capture — bump the version, publish the npm packages and the GHCR images, tag it. Use when a merge to main produced no new version, or when a release run logs `Skipping @ (already published)`. +description: Ship a release of Open Data Capture — bump the version, publish the npm packages and the GHCR images, tag it. Use when a merge to main produced no new version, when patching an older line from a release/. branch, or when a release run logs `Skipping @ (already published)`. --- -A release is **one version bump merged to `main`** — after that `.github/workflows/release.yaml` builds the -images, publishes the npm packages and creates the GitHub release with no further input. It also fires on -`workflow_dispatch` from `main`, which releases again with no bump and no merge; a dispatch from any other ref -fails `configure`. The release body is the new version's +A release is **one version bump merged to `main`**, or bumped on a `release/.` branch and +dispatched — after that `.github/workflows/release.yaml` builds the images, publishes the npm packages and +creates the GitHub release with no further input. A run is a release only when `v` is not a git tag +yet; any other run from `main` pushes only the `edge` and `sha-<7>` images, and a dispatch from any other ref fails +`configure`. What each tag means and which run moves `latest`: +`.agents/docs/architecture/release-channels.md`. The release body is the new version's section of `CHANGELOG.md`, which `scripts/increment-version.ts` generates from the commits since the last tag; the `release` job fails, rather than publishing an empty body, when that section is missing. Which workspaces publish: `.agents/docs/workspace-map.md`; how each artifact is selected: the job table in `.agents/docs/playbooks/cut-a-release.md`. @@ -19,13 +21,13 @@ Every publishable package must carry the root version. Each publishes at **its o the publish step skips a version npm already carries, so a bump that moved the root alone leaves `publish-npm` green and npm untouched. Nothing compares those files, so **you are the lockstep check**: after the bump, every row of `scripts/list-publishable.sh` must carry the version in the root `package.json`, which that script never -prints. `Skipping @ (already published)` is the ordinary output of any push that carried no bump — -a symptom only in the run for the commit that was supposed to bump. The mechanism, and the command that compares +prints. `Skipping @ (already published)` is the ordinary output of a re-run of a release that +already published — a symptom only in the first run after a bump. The mechanism, and the command that compares the two: `.agents/docs/playbooks/cut-a-release.md`. ## `pnpm lint` is the only test the release path runs -The `validate` job runs `pnpm lint` and stops there (`release.yaml:37-52`): the merge is the last moment a test +The `validate` job runs `pnpm lint` and stops there (`release.yaml:46-61`): the merge is the last moment a test runs against this code, and after it a red suite ships silently. Get the branch green first with `.agents/skills/odc-done/SKILL.md`, and — since nothing you run locally builds the Docker images, and `build` gates both publishes — require `gh run list --workflow=Release --limit 1` green on `main` before you bump. What @@ -34,16 +36,20 @@ CI does and does not gate: `.agents/docs/architecture/testing-strategy.md`. ## `skipped` is the shape of a release that never happened `publish-npm` and `release` inherit the default `success()` gate over their `needs`, so a `build` that dies takes -both of them down and GitHub reports them **skipped** rather than failed (`release.yaml:53-56,92-100,140-145`). -Read the cause off the run, not off the missing artifact: +both of them down and GitHub reports them **skipped** rather than failed (`release.yaml:62-64,111-114,151-155`). +On a run with no bump the two are skipped by design — `configure`'s `release` output is `false`; read it +before anything else. Otherwise read the cause off the run, not off the missing artifact: -- one leg `failure` and its siblings `cancelled` — `strategy.fail-fast: true` (`release.yaml:57-59`): an image +- one leg `failure` and its siblings `cancelled` — `strategy.fail-fast: true` (`release.yaml:65-66`): an image that does not build, not a collision. -- the whole run `cancelled` — the per-ref concurrency group with `cancel-in-progress: true` - (`release.yaml:9-11`): a second merge landed while it was in flight. +- the whole run `cancelled` before it started — one concurrency group covers every ref and keeps one waiting + run (`release.yaml:12-14`): a third run arrived while this one waited. Dispatch it again. -Recover by fixing the failing job and re-running the run (`gh run rerun `); the publish step's version guard -makes that safe. Cut another patch only when npm needs a version it has never seen. +Recover by fixing the failing job and re-running the whole run (`gh run rerun `); the publish step's version +guard makes that safe — unless a newer release landed in between and the re-run plans another `npmTag`: then +`publish-npm` fails with `trusted publishing cannot move a dist-tag, so cut a fresh patch`, and a fresh patch is +the fix. **Never `gh run rerun --failed` here:** it skips `configure` and reuses the plan from the +failed run, whose tag snapshot may be stale — `latest` could then move back to an older release. Cut another patch only when npm needs a version it has never seen. Done when the answer to "the merge produced no new version" names the job that owns the missing artifact, quoted from the run. @@ -53,10 +59,8 @@ from the run. Machinery oddities, verified and already understood — say so in your reply and carry on. A red `build` is not one of them; that is a failure to fix. -- Every push to `main` performs a full release — `release.cjs` sets `should_release` to `'true'` on every run from `main`, on - purpose. It consults nothing outside the repository, so no registry state can wedge it; do not add a gate that - reads GHCR or npm back. The gate that used to live there is in `.agents/docs/playbooks/cut-a-release.md`. -- No playground image ships — the matrix filter drops it. +- Every push to `main` builds and pushes `edge` and `sha-<7>`, release or not, on purpose. The release decision + reads git tags only, so no registry state can wedge it; do not add a gate that reads GHCR or npm back. - Nothing enforces the version lockstep; you do, above. ## Pulling a bad release takes `latest` with it @@ -67,7 +71,8 @@ both tags. Delete the two most recent releases and the images have no `latest` a and nothing in this repo reports that. Cut the replacement release promptly, or retag a known-good version by hand. Deleting the GitHub release and its `v` tag is independent of the images and of npm, where a version older than 72h cannot be unpublished at all — pulling a release means pulling it in three places, and only the images -carry `latest`. +carry `latest`. **Deleting the `v` tag re-arms the release:** the next push to `main` at that version +publishes it again, because the tag's absence is the whole release decision. Done when every release-machinery oddity you hit is either in that list or in your reply — the reason for any one of them is in `.agents/docs/playbooks/cut-a-release.md`. diff --git a/.agents/skills/odc-testing/SKILL.md b/.agents/skills/odc-testing/SKILL.md index e129acf06..826050950 100644 --- a/.agents/skills/odc-testing/SKILL.md +++ b/.agents/skills/odc-testing/SKILL.md @@ -92,7 +92,7 @@ printed your test titles under `[chromium]`, and you have watched one of them fa ## Pushing proves nothing -CI fires on a `pull_request` to `main` and on `workflow_dispatch`, never on a push +CI fires on a `pull_request` to `main` or a release branch and on `workflow_dispatch`, never on a push (`.agents/docs/architecture/testing-strategy.md` §What CI gates), so until that PR exists the run you did locally is the only one there has been. Run `.agents/skills/odc-done/SKILL.md` before you open it — that is the sweep that re-runs lint, unit and e2e together. diff --git a/.env.template b/.env.template index 1b8924cca..e603ad406 100644 --- a/.env.template +++ b/.env.template @@ -19,7 +19,8 @@ GATEWAY_PORT=3500 # another machine. BIND_ADDRESS=127.0.0.1 -# The Docker release to use (latest = stable) +# The Docker release to use: latest (newest release), . (newest patch of that line), +# .., or edge (unreleased main) RELEASE_CHANNEL=latest # The MongoDB version to use MONGODB_VERSION=7.0 diff --git a/.github/scripts/__tests__/publish-npm.test.ts b/.github/scripts/__tests__/publish-npm.test.ts new file mode 100644 index 000000000..f40ff791f --- /dev/null +++ b/.github/scripts/__tests__/publish-npm.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +import publishNpm from '../publish-npm.cjs'; + +type Registry = { [spec: string]: string }; + +function createExec(registry: Registry, publishable = '@odc/a\t2.5.3\t/a/package.json\n') { + return { + exec: vi.fn(() => Promise.resolve(0)), + getExecOutput: vi.fn((command: string, args: string[]) => { + if (command === 'scripts/list-publishable.sh') { + return Promise.resolve({ exitCode: 0, stdout: publishable }); + } + const value = registry[`${args[1]} ${args[2]}`]; + return Promise.resolve(value === undefined ? { exitCode: 1, stdout: '' } : { exitCode: 0, stdout: `${value}\n` }); + }) + }; +} + +describe('publishNpm', () => { + it('should refuse a malformed publishable row rather than publish a guessed name', async () => { + const exec = createExec({}, '@odc/a 2.5.3\n'); + await expect(publishNpm({ core: { info: vi.fn() }, exec, npmTag: 'latest' })).rejects.toThrow( + "scripts/list-publishable.sh printed a row that is not \t\t: '@odc/a 2.5.3'" + ); + }); + + it('should publish an unpublished version under the planned dist-tag', async () => { + const exec = createExec({}); + await publishNpm({ core: { info: vi.fn() }, exec, npmTag: 'release-2.5' }); + expect(exec.exec).toHaveBeenCalledWith('pnpm', [ + '--filter', + '@odc/a', + 'publish', + '--no-git-checks', + '--tag', + 'release-2.5' + ]); + }); + + it('should skip a published version whose planned dist-tag already points at it, so a re-run is safe', async () => { + const exec = createExec({ '@odc/a@2.5.3 version': '2.5.3', '@odc/a dist-tags.latest': '2.5.3' }); + const info = vi.fn(); + await publishNpm({ core: { info }, exec, npmTag: 'latest' }); + expect(exec.exec).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith('Skipping @odc/a@2.5.3 (already published)'); + }); + + it('should fail a re-run whose planned dist-tag is elsewhere, so the channel is never left behind silently', async () => { + const exec = createExec({ '@odc/a@2.5.3 version': '2.5.3', '@odc/a dist-tags.release-2.5': '2.5.2' }); + await expect(publishNpm({ core: { info: vi.fn() }, exec, npmTag: 'release-2.5' })).rejects.toThrow( + "@odc/a@2.5.3 is already published, but its release-2.5 dist-tag is on '2.5.2'; trusted publishing cannot move a dist-tag, so cut a fresh patch" + ); + expect(exec.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/.github/scripts/__tests__/release.test.ts b/.github/scripts/__tests__/release.test.ts new file mode 100644 index 000000000..0f58d840c --- /dev/null +++ b/.github/scripts/__tests__/release.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import release from '../release.cjs'; + +const { planRelease } = release; + +const MAIN = 'refs/heads/main'; +const RELEASE_2_5 = 'refs/heads/release/2.5'; + +describe('planRelease', () => { + it('should build edge without releasing when main carries a tagged version', () => { + expect(planRelease({ ref: MAIN, tags: ['v2.5.2'], version: '2.5.2' })).toMatchObject({ + edge: true, + latest: false, + release: false + }); + }); + + it('should release main as latest when its version is new and the highest', () => { + expect(planRelease({ ref: MAIN, tags: ['v2.5.2', 'v2.5.3'], version: '2.6.0' })).toEqual({ + channel: '2.6', + edge: true, + latest: true, + npmTag: 'latest', + release: true, + version: '2.6.0' + }); + }); + + it('should release a release branch as latest when no higher version is tagged', () => { + expect(planRelease({ ref: RELEASE_2_5, tags: ['v2.4.9', 'v2.5.2'], version: '2.5.3' })).toEqual({ + channel: '2.5', + edge: false, + latest: true, + npmTag: 'latest', + release: true, + version: '2.5.3' + }); + }); + + it('should keep latest on the higher line when a release branch cuts an older patch', () => { + expect(planRelease({ ref: RELEASE_2_5, tags: ['v2.5.3', 'v2.6.0'], version: '2.5.4' })).toMatchObject({ + latest: false, + npmTag: 'release-2.5', + release: true + }); + }); + + it('should refuse an untagged patch older than its line, so a retry cannot move the line tag back', () => { + expect(() => planRelease({ ref: RELEASE_2_5, tags: ['v2.5.2', 'v2.5.4'], version: '2.5.3' })).toThrow( + '2.5.3 is older than v2.5.4 on the 2.5 line; bump past it first' + ); + }); + + it('should ignore tags that are not plain versions when deciding latest', () => { + expect(planRelease({ ref: MAIN, tags: ['v2-beta', 'v9.0.0-rc.1'], version: '2.6.0' })).toMatchObject({ + latest: true + }); + }); + + it('should refuse a release branch whose version is already tagged, so a dispatch without a bump fails', () => { + expect(() => planRelease({ ref: RELEASE_2_5, tags: ['v2.5.3'], version: '2.5.3' })).toThrow( + 'v2.5.3 is already released; bump the version on refs/heads/release/2.5 first' + ); + }); + + it('should refuse a version from another line on a release branch', () => { + expect(() => planRelease({ ref: RELEASE_2_5, tags: [], version: '2.6.0' })).toThrow( + 'Version 2.6.0 does not belong on refs/heads/release/2.5' + ); + }); + + it('should refuse any other ref, so a feature branch cannot publish', () => { + expect(() => planRelease({ ref: 'refs/heads/feat/x', tags: [], version: '2.6.0' })).toThrow( + 'Releases are cut from refs/heads/main or refs/heads/release/., not refs/heads/feat/x' + ); + }); + + it('should refuse a version that cannot be an image tag', () => { + expect(() => planRelease({ ref: MAIN, tags: [], version: '2.6.0-rc.1' })).toThrow( + "The root package.json version '2.6.0-rc.1' is not .." + ); + }); +}); + +describe('release', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should expose the plan for the root version and the git tags as workflow outputs', async () => { + vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ version: '2.5.3' })); + const setOutput = vi.fn(); + await release({ + context: { ref: RELEASE_2_5 }, + core: { setOutput }, + exec: { getExecOutput: () => Promise.resolve({ stdout: 'v2.5.1\nv2.5.2\n' }) } + }); + expect(Object.fromEntries(setOutput.mock.calls)).toEqual({ + channel: '2.5', + edge: 'false', + latest: 'true', + npmTag: 'latest', + release: 'true', + version: '2.5.3' + }); + }); +}); diff --git a/.github/scripts/publish-npm.cjs b/.github/scripts/publish-npm.cjs new file mode 100644 index 000000000..94ab6447f --- /dev/null +++ b/.github/scripts/publish-npm.cjs @@ -0,0 +1,59 @@ +// @ts-check + +/** + * @typedef {object} Exec + * @property {(command: string, args: string[]) => Promise} exec + * @property {( + * command: string, + * args: string[], + * options: { ignoreReturnCode?: boolean; silent: boolean } + * ) => Promise<{ exitCode: number; stdout: string }>} getExecOutput + */ + +const PUBLISHABLE_ROW_PATTERN = /^(\S+)\t(\S+)\t/; + +/** + * @param {Exec} exec + * @param {string} spec + * @param {string} field + */ +async function npmView(exec, spec, field) { + const { exitCode, stdout } = await exec.getExecOutput('npm', ['view', spec, field], { + ignoreReturnCode: true, + silent: true + }); + return exitCode === 0 ? stdout.trim() : ''; +} + +/** + * Publish every publishable package whose version npm does not carry yet, under `npmTag`. + * + * A version npm already carries is skipped, which keeps a re-run safe — but only while its `npmTag` + * still points at it. A re-run planned after a newer release can carry another `npmTag`, and trusted + * publishing cannot move a dist-tag, so that case fails rather than finishing green with the + * channel left behind. + * + * @param {{ core: { info: (message: string) => void }; exec: Exec; npmTag: string }} args + */ +module.exports = async ({ core, exec, npmTag }) => { + const { stdout } = await exec.getExecOutput('scripts/list-publishable.sh', [], { silent: true }); + for (const row of stdout.split('\n').filter(Boolean)) { + const match = PUBLISHABLE_ROW_PATTERN.exec(row); + if (!match) { + throw new Error(`scripts/list-publishable.sh printed a row that is not \t\t: '${row}'`); + } + const [name, version] = [String(match[1]), String(match[2])]; + if (!(await npmView(exec, `${name}@${version}`, 'version'))) { + core.info(`Publishing ${name}@${version} under ${npmTag}`); + await exec.exec('pnpm', ['--filter', name, 'publish', '--no-git-checks', '--tag', npmTag]); + continue; + } + const taggedVersion = await npmView(exec, name, `dist-tags.${npmTag}`); + if (taggedVersion !== version) { + throw new Error( + `${name}@${version} is already published, but its ${npmTag} dist-tag is on '${taggedVersion}'; trusted publishing cannot move a dist-tag, so cut a fresh patch` + ); + } + core.info(`Skipping ${name}@${version} (already published)`); + } +}; diff --git a/.github/scripts/release.cjs b/.github/scripts/release.cjs index 01731b4b9..c1e1c0230 100644 --- a/.github/scripts/release.cjs +++ b/.github/scripts/release.cjs @@ -1,26 +1,104 @@ +// @ts-check const fs = require('node:fs'); /** - * Expose the release version and the decision to release as workflow outputs. + * @typedef {object} ReleasePlan + * @property {string} channel `.` — the moving image tag of this release line + * @property {boolean} edge whether to push the `edge` and `sha-` image tags + * @property {boolean} latest whether this release moves `latest` on GHCR, npm and GitHub + * @property {string} npmTag the npm dist-tag the packages publish under + * @property {boolean} release whether `v` is untagged, so this run publishes it + * @property {string} version the root `package.json` version + */ + +const RELEASE_BRANCH_PATTERN = /^refs\/heads\/release\/(\d+)\.(\d+)$/; +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +/** @param {string} version */ +function parseVersion(version) { + const match = VERSION_PATTERN.exec(version); + return match && /** @type {const} */ ([Number(match[1]), Number(match[2]), Number(match[3])]); +} + +/** + * @param {readonly [number, number, number]} left + * @param {readonly [number, number, number]} right + */ +function compareVersions(left, right) { + return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; +} + +/** + * Decide what a run of the Release workflow publishes. * - * Every push to `main` releases. This deliberately consults nothing outside the repository: - * the previous implementation asked GHCR which version carried the `latest` tag, which never - * skipped a release (`build` pushes bare tags, and it only matched `v`-prefixed ones) but did - * fail the whole workflow once a bad release was deleted and took the `latest` tag with it. + * `main` always builds `edge`; it releases only when its version has no `v*` tag yet. A + * `release/.` branch builds nothing else, so a dispatch there without a fresh bump is + * a mistake and fails. `latest` follows the highest released version, whichever branch cut it. * - * A manual dispatch is refused on any other ref, so a branch cannot publish `latest`, npm packages - * or a `v*` tag. This only stops an accidental dispatch: the dispatched ref supplies this file too. - * The `release` environment's branch policy and the npm trusted publisher are what enforce it. + * The decision reads only the repository — the version file and the git tags — never a registry: a + * deleted image or package must not be able to wedge the next release. It is only as fresh as the + * tag snapshot, which is why `release.yaml` runs one Release at a time across every ref. * - * @param {import('github-script').AsyncFunctionArguments} args + * @param {{ ref: string; tags: readonly string[]; version: string }} input + * @returns {ReleasePlan} */ -module.exports = ({ context, core }) => { - if (context.ref !== 'refs/heads/main') { - core.setFailed(`Releases are cut from refs/heads/main only, not ${context.ref}`); - return; +function planRelease({ ref, tags, version }) { + const parsedVersion = parseVersion(version); + if (!parsedVersion) { + throw new Error(`The root package.json version '${version}' is not ..`); + } + const channel = `${parsedVersion[0]}.${parsedVersion[1]}`; + const isMain = ref === 'refs/heads/main'; + const releaseBranch = RELEASE_BRANCH_PATTERN.exec(ref); + if (!isMain && !releaseBranch) { + throw new Error(`Releases are cut from refs/heads/main or refs/heads/release/., not ${ref}`); + } + if (releaseBranch && `${releaseBranch[1]}.${releaseBranch[2]}` !== channel) { + throw new Error(`Version ${version} does not belong on ${ref}`); } + const release = !tags.includes(`v${version}`); + if (releaseBranch && !release) { + throw new Error(`v${version} is already released; bump the version on ${ref} first`); + } + const newerTags = tags.filter((tag) => { + const parsedTag = parseVersion(tag.slice(1)); + return parsedTag && compareVersions(parsedTag, parsedVersion) > 0; + }); + const newerTagOnLine = newerTags.find((tag) => tag.startsWith(`v${channel}.`)); + if (release && newerTagOnLine) { + throw new Error(`${version} is older than ${newerTagOnLine} on the ${channel} line; bump past it first`); + } + const latest = release && newerTags.length === 0; + return { + channel, + edge: isMain, + latest, + npmTag: latest ? 'latest' : `release-${channel}`, + release, + version + }; +} + +/** + * Expose the release plan as workflow outputs. + * + * The dispatched ref supplies this file too, so the ref check here stops accidents only. The + * `release` environment's branch policy and the npm trusted publisher are what enforce it. + * + * @param {{ + * context: { ref: string }; + * core: { setOutput: (name: string, value: string) => void }; + * exec: { getExecOutput: (command: string, args: string[]) => Promise<{ stdout: string }> }; + * }} args + */ +module.exports = async ({ context, core, exec }) => { /** @type {string} */ const version = JSON.parse(fs.readFileSync('package.json', 'utf-8')).version; - core.setOutput('version', version); - core.setOutput('should_release', 'true'); + const { stdout } = await exec.getExecOutput('git', ['tag', '--list', 'v*']); + const plan = planRelease({ ref: context.ref, tags: stdout.split('\n').filter(Boolean), version }); + for (const [name, value] of Object.entries(plan)) { + core.setOutput(name, String(value)); + } }; + +module.exports.planRelease = planRelease; diff --git a/.github/vitest.config.ts b/.github/vitest.config.ts new file mode 100644 index 000000000..b32ee0eed --- /dev/null +++ b/.github/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import { baseConfig } from '../vitest.config'; + +export default mergeConfig( + baseConfig, + defineProject({ + test: { + name: 'github', + root: import.meta.dirname + } + }) +); diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 95e8b6fff..22752ca1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,7 @@ name: CI on: pull_request: - branches: ['main'] + branches: ['main', 'release/**'] workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9da4b3eae..1073e802c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,13 +2,16 @@ name: Release on: push: branches: ['main'] + # Release branches (release/.) release on dispatch only; release.cjs refuses any other ref. workflow_dispatch: permissions: contents: write packages: write +# One Release at a time across every ref: release.cjs plans from a tag snapshot, so a run on another +# ref must not overlap it (.agents/docs/architecture/release-channels.md). concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }} + cancel-in-progress: false defaults: run: shell: bash @@ -16,20 +19,26 @@ jobs: configure: runs-on: ubuntu-latest outputs: - should_release: ${{ steps.version.outputs.should_release }} - version: ${{ steps.version.outputs.version }} + channel: ${{ steps.plan.outputs.channel }} + edge: ${{ steps.plan.outputs.edge }} + latest: ${{ steps.plan.outputs.latest }} matrix: ${{ steps.matrix.outputs.matrix }} + npm_tag: ${{ steps.plan.outputs.npmTag }} + release: ${{ steps.plan.outputs.release }} + version: ${{ steps.plan.outputs.version }} steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + fetch-depth: 0 # release.cjs reads every v* tag - name: Derive Build Matrix id: matrix run: | matrix=$(docker compose config --no-interpolate --format json \ | jq -c '{include:[.services|to_entries[]|select(.value.build and .value.image)|{image:(.value.image|split(":")[0]),dockerfile:.value.build.dockerfile}]}') echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - - name: Determine Release Version - id: version + - name: Plan Release + id: plan uses: actions/github-script@v9 with: script: | @@ -53,7 +62,6 @@ jobs: build: runs-on: ubuntu-latest needs: [configure, validate] - if: ${{ needs.configure.outputs.should_release == 'true' }} strategy: fail-fast: true matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} @@ -75,9 +83,13 @@ jobs: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ matrix.image }} + flavor: latest=false tags: | - type=raw,value=latest - type=raw,value=${{ needs.configure.outputs.version }} + type=raw,value=edge,enable=${{ needs.configure.outputs.edge }} + type=sha,prefix=sha-,format=short,enable=${{ needs.configure.outputs.edge }} + type=raw,value=${{ needs.configure.outputs.version }},enable=${{ needs.configure.outputs.release }} + type=raw,value=${{ needs.configure.outputs.channel }},enable=${{ needs.configure.outputs.release }} + type=raw,value=latest,enable=${{ needs.configure.outputs.latest }} - name: Build and Push Docker Images uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: @@ -91,7 +103,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} publish-npm: runs-on: ubuntu-latest - # Its deployment-branch policy (main only) is what the npm trusted publisher is pinned to. + # Its deployment-branch policy (main and release/*) is what the npm trusted publisher is pinned to. environment: release permissions: contents: read @@ -99,7 +111,7 @@ jobs: needs: - build - configure - if: ${{ needs.configure.outputs.should_release == 'true' }} + if: ${{ needs.configure.outputs.release == 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -128,23 +140,19 @@ jobs: done < <(scripts/list-publishable.sh) pnpm exec turbo run build "${filters[@]}" - name: Publish Packages - run: | - # Publish each marked package whose version is not already on npm. The version - # check keeps this idempotent across workflow re-runs. - while IFS=$'\t' read -r name version pkgPath; do - if [ -n "$(npm view "${name}@${version}" version 2>/dev/null)" ]; then - echo "Skipping ${name}@${version} (already published)" - continue - fi - echo "Publishing ${name}@${version}" - pnpm --filter "${name}" publish --no-git-checks - done < <(scripts/list-publishable.sh) + uses: actions/github-script@v9 + env: + NPM_TAG: ${{ needs.configure.outputs.npm_tag }} + with: + script: | + await require('./.github/scripts/publish-npm.cjs')({ core, exec, npmTag: process.env.NPM_TAG }) release: runs-on: ubuntu-latest needs: - build - configure - publish-npm + if: ${{ needs.configure.outputs.release == 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -165,4 +173,7 @@ jobs: uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: body_path: ${{ runner.temp }}/release-notes.md + make_latest: ${{ needs.configure.outputs.latest }} tag_name: v${{ needs.configure.outputs.version }} + # The action otherwise creates a new tag on the default branch, not on the commit this run built. + target_commitish: ${{ github.sha }} diff --git a/AGENTS.md b/AGENTS.md index fcca3050c..5170a7fe0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ you write code there. ## Commands -Package manager is pnpm (>=10). Node >= v24.15.0 (see `.nvmrc`, currently `lts/krypton`). +Package manager is pnpm (>=10). Node `^24.22.1 || >=26.12.1`, the October 2026 security releases (see `.nvmrc`, currently `lts/krypton`). ```sh pnpm install # install deps @@ -123,6 +123,7 @@ isn't one. | The six similarly-named `runtime-*` things, and `vendor/` | `.agents/docs/architecture/runtime-and-vendor.md` | | CASL, `@RouteAccess`, and group scoping | `.agents/docs/architecture/auth-and-permissions.md` | | What is tested where, and what CI gates | `.agents/docs/architecture/testing-strategy.md` | +| Image tags, npm dist-tags, `latest`, and release branches | `.agents/docs/architecture/release-channels.md` | ### Step-by-step playbooks diff --git a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx index 5aedcffe5..8eac32b00 100644 --- a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx +++ b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx @@ -303,7 +303,7 @@ describe('WalkthroughProvider', () => { it('should mark the walkthrough complete and close when the user asks not to see it again', () => { renderProvider(); - fireEvent.click(screen.getByRole('button', { name: "Don't show again" })); + fireEvent.click(screen.getByRole('button', { name: "Don't Show Again" })); expect(mocks.store.setIsWalkthroughComplete).toHaveBeenCalledWith(true); expect(mocks.store.setIsWalkthroughOpen).toHaveBeenCalledWith(false); }); @@ -326,7 +326,7 @@ describe('WalkthroughProvider', () => { it('should hide the option not to show again on the last step, where done already completes it', () => { renderProvider(); clickNext(STEP_COUNT_WITHOUT_GATEWAY - 1); - expect(screen.queryByRole('button', { name: "Don't show again" })).toBeNull(); + expect(screen.queryByRole('button', { name: "Don't Show Again" })).toBeNull(); }); it('should mark the walkthrough complete and close when the user finishes the last step', () => { diff --git a/docs/en/2-tutorials/2.4-playground-deployment.md b/docs/en/2-tutorials/2.4-playground-deployment.md index 94b20e0f7..3e3e261a6 100644 --- a/docs/en/2-tutorials/2.4-playground-deployment.md +++ b/docs/en/2-tutorials/2.4-playground-deployment.md @@ -33,7 +33,7 @@ Add an A record for each of the two hostnames, both pointing to your server's IP #### Step 2: Launch the Container -The image is published to the GitHub Container Registry as `ghcr.io/douglasneuroinformatics/open-data-capture-playground`. The `latest` tag is the current stable release, and each release is also tagged with its version number (for example, `2.5.0`). Inside the container, the playground listens on port 80. +The image is published to the GitHub Container Registry as `ghcr.io/douglasneuroinformatics/open-data-capture-playground`. The `latest` tag is the newest release, and each release is also tagged with its version number (for example, `2.5.0`) and with its release line (for example, `2.5`). The `edge` tag is the newest unreleased build of the `main` branch. Inside the container, the playground listens on port 80. If you have already deployed Open Data Capture by following the [deployment guide](/en/docs/tutorials/deployment), the playground is part of the same Docker Compose stack. From the directory where you cloned the repository, add the preview address to `.env`: diff --git a/package.json b/package.json index a6e93e3b1..4c103ad8c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "packageManager": "pnpm@10.34.3", "license": "Apache-2.0", "engines": { - "node": ">=v24.15.0" + "node": "^24.22.1 || >=26.12.1" }, "scripts": { "build": "env-cmd turbo run build", @@ -23,7 +23,7 @@ "generate:env": "./scripts/generate-env.sh", "knip": "NODE_ENV=development env-cmd knip", "lint": "turbo run lint lint:root", - "lint:root": "tsc --noEmit && eslint --fix commitlint.config.ts knip.ts scripts vitest.config.ts", + "lint:root": "tsc --noEmit && eslint --fix .github/scripts/publish-npm.cjs .github/scripts/release.cjs .github/scripts/__tests__ .github/vitest.config.ts commitlint.config.ts knip.ts scripts vitest.config.ts", "postinstall": "turbo telemetry disable", "prepare": "husky", "start": "turbo run start", diff --git a/tsconfig.json b/tsconfig.json index b6d3ee388..8653cca9a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,15 @@ "resolveJsonModule": true, "types": ["node"] }, - "include": ["commitlint.config.ts", "knip.ts", "scripts/*.ts", "vitest.config.ts"], + "include": [ + ".github/scripts/__tests__/*.ts", + ".github/scripts/publish-npm.cjs", + ".github/scripts/release.cjs", + ".github/vitest.config.ts", + "commitlint.config.ts", + "knip.ts", + "scripts/*.ts", + "vitest.config.ts" + ], "exclude": ["docs/*", "**/dist", "**/node_modules", "vendor/**/*"] } diff --git a/vitest.config.ts b/vitest.config.ts index e8872ba79..00cc8919c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ import { defineConfig, mergeConfig } from 'vitest/config'; // field inside a referenced project config as a nested set of projects. export const baseConfig = defineConfig({ test: { + include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], watch: false } @@ -21,7 +22,7 @@ export default mergeConfig( '**/dist/**', '**/node_modules/**', '**/public/**', - '**/scripts/**', + '{apps,packages,runtime}/*/scripts/**', '**/*.d.?(c|m)ts', '**/*{.,-}{test,test-d,spec}.?(c|m)[jt]s?(x)', '**/*.config.?(c|m)[jt]s?(x)', @@ -33,7 +34,11 @@ export default mergeConfig( 'apps/web/src/route-tree.ts', 'apps/web/src/testing/**' ], - include: ['apps/{api,gateway,web}/src/**/*.?(c|m)[jt]s?(x)', 'packages/*/src/**/*.?(c|m)[jt]s?(x)'], + include: [ + '.github/scripts/{publish-npm,release}.cjs', + 'apps/{api,gateway,web}/src/**/*.?(c|m)[jt]s?(x)', + 'packages/*/src/**/*.?(c|m)[jt]s?(x)' + ], provider: 'v8', reportsDirectory: path.resolve(import.meta.dirname, 'coverage'), skipFull: true, @@ -41,7 +46,12 @@ export default mergeConfig( 100: true } }, - 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' + ] } }) );