From 6cba0bf03b2c58a8414c4fbb32d65b39131c3d07 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Thu, 22 Oct 2026 07:46:37 -0400 Subject: [PATCH 1/6] ci: release from release branches and keep latest on tagged releases The release decision moves into a tested planRelease function in .github/scripts/release.cjs. A run releases only when v is not a git tag yet. latest (GHCR, npm, GitHub) moves only with a release whose version is higher than every tag. Every push to main builds edge and sha-. A release/. branch releases on dispatch, publishes and ., and uses the release-. npm dist-tag when it is not the newest line. The GitHub tag now goes on the built commit. CI also runs on pull requests to release branches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G73CpTPk1EjKYeBAKPacit --- .agents/docs/architecture/release-channels.md | 71 ++++++++++++ .agents/docs/architecture/testing-strategy.md | 9 +- .agents/docs/playbooks/cut-a-release.md | 92 +++++++++------- .agents/skills/odc-release/SKILL.md | 36 +++--- .agents/skills/odc-testing/SKILL.md | 2 +- .env.template | 3 +- .github/scripts/__tests__/release.test.ts | 104 ++++++++++++++++++ .github/scripts/release.cjs | 104 +++++++++++++++--- .github/vitest.config.ts | 13 +++ .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 38 +++++-- AGENTS.md | 1 + .../2-tutorials/2.4-playground-deployment.md | 2 +- package.json | 2 +- tsconfig.json | 10 +- vitest.config.ts | 15 ++- 16 files changed, 409 insertions(+), 95 deletions(-) create mode 100644 .agents/docs/architecture/release-channels.md create mode 100644 .github/scripts/__tests__/release.test.ts create mode 100644 .github/vitest.config.ts diff --git a/.agents/docs/architecture/release-channels.md b/.agents/docs/architecture/release-channels.md new file mode 100644 index 000000000..f3ee01c18 --- /dev/null +++ b/.agents/docs/architecture/release-channels.md @@ -0,0 +1,71 @@ +# 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). + +| 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..eae665506 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 `release.yaml` acts on; it is the one `.github` file in the coverage `include`, so `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..cf7344198 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 (`release.yaml:145-153`): ``` 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,38 @@ 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 + `cancel-in-progress: true` (`release.yaml:10-12`), so a second merge cancels the release in flight. 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`, 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 +124,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 +137,28 @@ 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. +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/.`.** 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,9 +166,11 @@ 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 @ diff --git a/.agents/skills/odc-release/SKILL.md b/.agents/skills/odc-release/SKILL.md index 751783ea7..beacfbdce 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 the `edge` image alone, 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:44-59`): 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,13 +36,14 @@ 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:60-62,109-112,156-160`). +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:63-64`): 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. + (`release.yaml:10-12`): a second merge landed while it was in flight. 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. @@ -53,10 +56,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 +68,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__/release.test.ts b/.github/scripts/__tests__/release.test.ts new file mode 100644 index 000000000..19921ed84 --- /dev/null +++ b/.github/scripts/__tests__/release.test.ts @@ -0,0 +1,104 @@ +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 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/release.cjs b/.github/scripts/release.cjs index 01731b4b9..3534b1cb4 100644 --- a/.github/scripts/release.cjs +++ b/.github/scripts/release.cjs @@ -1,26 +1,100 @@ +// @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. * - * @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 latest = + release && + tags.every((tag) => { + const parsedTag = parseVersion(tag.slice(1)); + return !parsedTag || compareVersions(parsedTag, parsedVersion) < 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..a6c5d6ee7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,6 +2,7 @@ name: Release on: push: branches: ['main'] + # Release branches (release/.) release on dispatch only; release.cjs refuses any other ref. workflow_dispatch: permissions: contents: write @@ -16,20 +17,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 +60,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 +81,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 +101,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 +109,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,6 +138,8 @@ jobs: done < <(scripts/list-publishable.sh) pnpm exec turbo run build "${filters[@]}" - name: Publish Packages + env: + NPM_TAG: ${{ needs.configure.outputs.npm_tag }} run: | # Publish each marked package whose version is not already on npm. The version # check keeps this idempotent across workflow re-runs. @@ -136,8 +148,8 @@ jobs: echo "Skipping ${name}@${version} (already published)" continue fi - echo "Publishing ${name}@${version}" - pnpm --filter "${name}" publish --no-git-checks + echo "Publishing ${name}@${version} under ${NPM_TAG}" + pnpm --filter "${name}" publish --no-git-checks --tag "${NPM_TAG}" done < <(scripts/list-publishable.sh) release: runs-on: ubuntu-latest @@ -145,6 +157,7 @@ jobs: - build - configure - publish-npm + if: ${{ needs.configure.outputs.release == 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -165,4 +178,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..91a3d59bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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..9b57e1348 100644 --- a/package.json +++ b/package.json @@ -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/release.cjs .github/scripts/__tests__ 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..1a3196143 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,14 @@ "resolveJsonModule": true, "types": ["node"] }, - "include": ["commitlint.config.ts", "knip.ts", "scripts/*.ts", "vitest.config.ts"], + "include": [ + ".github/scripts/__tests__/*.ts", + ".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..50181f534 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,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 +33,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/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 +45,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' + ] } }) ); From 1bb1e34984dcef436732e8f017736caf5f645daf Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Thu, 22 Oct 2026 07:56:02 -0400 Subject: [PATCH 2/6] fix: require node 24.22.1 for the october 2026 security releases Node v24.22.1 fixes CVE-2026-105482, CVE-2026-105483, CVE-2026-106069 and CVE-2026-111497 (High) and four Medium and Low CVEs: TLS hostname checks, the Brotli decoder, dns names and keep-alive sockets. engines.node now accepts only the patched 24 and 26 lines. The images build from the floating node:lts-krypton tag, which already resolves to 24.22.1. https://nodejs.org/en/blog/vulnerability/october-2026-security-releases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G73CpTPk1EjKYeBAKPacit --- .agents/docs/playbooks/run-locally.md | 4 ++-- AGENTS.md | 2 +- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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.md b/AGENTS.md index 91a3d59bc..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 diff --git a/package.json b/package.json index 9b57e1348..716ac121b 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", From 29f0a0c2074052993a1cea4a322ff9a8f6b51d04 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Thu, 22 Oct 2026 08:42:56 -0400 Subject: [PATCH 3/6] ci: lint the github vitest config and carry release machinery onto old branches A release branch cut from a commit before release-branch support has the old configure and ci.yaml, so the cut-a-release playbook now takes them from main in the branch's first commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G73CpTPk1EjKYeBAKPacit --- .agents/docs/playbooks/cut-a-release.md | 8 ++++++-- .agents/skills/odc-release/SKILL.md | 2 +- package.json | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agents/docs/playbooks/cut-a-release.md b/.agents/docs/playbooks/cut-a-release.md index cf7344198..e088bb5d5 100644 --- a/.agents/docs/playbooks/cut-a-release.md +++ b/.agents/docs/playbooks/cut-a-release.md @@ -144,6 +144,10 @@ A patch to a line `main` has moved past. The decision rules are in 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` @@ -153,8 +157,8 @@ A patch to a line `main` has moved past. The decision rules are in 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/.`.** A push to the branch releases - nothing. `configure` fails if the version is already tagged or is not on the branch's line. +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. diff --git a/.agents/skills/odc-release/SKILL.md b/.agents/skills/odc-release/SKILL.md index beacfbdce..69838478c 100644 --- a/.agents/skills/odc-release/SKILL.md +++ b/.agents/skills/odc-release/SKILL.md @@ -6,7 +6,7 @@ description: Ship a release of Open Data Capture — bump the version, publish t 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 the `edge` image alone, and a dispatch from any other ref fails +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; diff --git a/package.json b/package.json index 716ac121b..6bb86463f 100644 --- a/package.json +++ b/package.json @@ -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 .github/scripts/release.cjs .github/scripts/__tests__ commitlint.config.ts knip.ts scripts vitest.config.ts", + "lint:root": "tsc --noEmit && eslint --fix .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", From a0a3a9246f32736057c70a9241bd9d64aafcc226 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Thu, 22 Oct 2026 12:10:48 -0400 Subject: [PATCH 4/6] ci: run one release at a time and refuse a patch older than its line planRelease decides from a tag snapshot, so overlapping runs on two refs could both plan latest, and the one that finished last owned it, or they could release one version from two commits. The concurrency group now spans every ref and never cancels a running release. planRelease also refuses an untagged version lower than a tag on its own line, so a late retry cannot move the line tag back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G73CpTPk1EjKYeBAKPacit --- .agents/docs/architecture/release-channels.md | 10 ++++++++++ .agents/docs/playbooks/cut-a-release.md | 7 ++++--- .agents/skills/odc-release/SKILL.md | 15 ++++++++------- .github/scripts/__tests__/release.test.ts | 6 ++++++ .github/scripts/release.cjs | 18 +++++++++++------- .github/workflows/release.yaml | 6 ++++-- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.agents/docs/architecture/release-channels.md b/.agents/docs/architecture/release-channels.md index f3ee01c18..4f8b603c9 100644 --- a/.agents/docs/architecture/release-channels.md +++ b/.agents/docs/architecture/release-channels.md @@ -15,6 +15,16 @@ release is `.agents/docs/playbooks/cut-a-release.md`; this file is the model it 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. + | Output | Meaning | | --------- | ------------------------------------------------------------------------------------------ | | `release` | `v` is not a tag yet, so this run publishes it | diff --git a/.agents/docs/playbooks/cut-a-release.md b/.agents/docs/playbooks/cut-a-release.md index e088bb5d5..98c5de318 100644 --- a/.agents/docs/playbooks/cut-a-release.md +++ b/.agents/docs/playbooks/cut-a-release.md @@ -11,7 +11,7 @@ gates is in `.agents/docs/architecture/testing-strategy.md`; the `RELEASE_VERSIO **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:145-153`): +step skips any version already on the registry (`release.yaml:147-155`): ``` Skipping @opendatacapture/runtime-v1@ (already published) @@ -87,8 +87,9 @@ agreement — nothing in CI compares them, and hand edits have moved the root al 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:10-12`), 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. diff --git a/.agents/skills/odc-release/SKILL.md b/.agents/skills/odc-release/SKILL.md index 69838478c..8c5428b18 100644 --- a/.agents/skills/odc-release/SKILL.md +++ b/.agents/skills/odc-release/SKILL.md @@ -27,7 +27,7 @@ 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:44-59`): 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 @@ -36,17 +36,18 @@ 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:60-62,109-112,156-160`). +both of them down and GitHub reports them **skipped** rather than failed (`release.yaml:62-64,111-114,158-162`). 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:63-64`): 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:10-12`): 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. **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. diff --git a/.github/scripts/__tests__/release.test.ts b/.github/scripts/__tests__/release.test.ts index 19921ed84..0f58d840c 100644 --- a/.github/scripts/__tests__/release.test.ts +++ b/.github/scripts/__tests__/release.test.ts @@ -48,6 +48,12 @@ describe('planRelease', () => { }); }); + 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 diff --git a/.github/scripts/release.cjs b/.github/scripts/release.cjs index 3534b1cb4..c1e1c0230 100644 --- a/.github/scripts/release.cjs +++ b/.github/scripts/release.cjs @@ -36,7 +36,8 @@ function compareVersions(left, right) { * a mistake and fails. `latest` follows the highest released version, whichever branch cut 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. + * 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 {{ ref: string; tags: readonly string[]; version: string }} input * @returns {ReleasePlan} @@ -59,12 +60,15 @@ function planRelease({ ref, tags, version }) { if (releaseBranch && !release) { throw new Error(`v${version} is already released; bump the version on ${ref} first`); } - const latest = - release && - tags.every((tag) => { - const parsedTag = parseVersion(tag.slice(1)); - return !parsedTag || compareVersions(parsedTag, parsedVersion) < 0; - }); + 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, diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a6c5d6ee7..526db189e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,9 +7,11 @@ on: 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 From ee4327c4c454b1dd58571dbafb3f368d589e04f9 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Thu, 22 Oct 2026 13:57:40 -0400 Subject: [PATCH 5/6] ci: fail a re-run whose npm dist-tag moved instead of skipping it A re-run planned after a newer release can carry another npmTag than the run that published. The publish loop skipped every published version, so the release-. channel stayed on an older version while the run finished green. Trusted publishing cannot move a dist-tag, so the new tested .github/scripts/publish-npm.cjs skips a published version only while npmTag points at it, and otherwise fails with a request for a fresh patch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G73CpTPk1EjKYeBAKPacit --- .agents/docs/architecture/release-channels.md | 5 ++ .agents/docs/architecture/testing-strategy.md | 2 +- .agents/docs/playbooks/cut-a-release.md | 19 +++--- .agents/skills/odc-release/SKILL.md | 6 +- .github/scripts/__tests__/publish-npm.test.ts | 56 ++++++++++++++++++ .github/scripts/publish-npm.cjs | 59 +++++++++++++++++++ .github/workflows/release.yaml | 15 ++--- package.json | 2 +- tsconfig.json | 1 + vitest.config.ts | 3 +- 10 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/__tests__/publish-npm.test.ts create mode 100644 .github/scripts/publish-npm.cjs diff --git a/.agents/docs/architecture/release-channels.md b/.agents/docs/architecture/release-channels.md index 4f8b603c9..9205d1380 100644 --- a/.agents/docs/architecture/release-channels.md +++ b/.agents/docs/architecture/release-channels.md @@ -25,6 +25,11 @@ again from fresh tags; `gh run rerun --failed` does not, so it is never used on 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 | diff --git a/.agents/docs/architecture/testing-strategy.md b/.agents/docs/architecture/testing-strategy.md index eae665506..39c4bd48a 100644 --- a/.agents/docs/architecture/testing-strategy.md +++ b/.agents/docs/architecture/testing-strategy.md @@ -33,7 +33,7 @@ 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 `release.yaml` acts on; it is the one `.github` file in the coverage `include`, so `scripts/issues.cjs` stays untested | +| `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 | diff --git a/.agents/docs/playbooks/cut-a-release.md b/.agents/docs/playbooks/cut-a-release.md index 98c5de318..e464177f6 100644 --- a/.agents/docs/playbooks/cut-a-release.md +++ b/.agents/docs/playbooks/cut-a-release.md @@ -11,7 +11,7 @@ gates is in `.agents/docs/architecture/testing-strategy.md`; the `RELEASE_VERSIO **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:147-155`): +step skips any version already on the registry (`.github/scripts/publish-npm.cjs`): ``` Skipping @opendatacapture/runtime-v1@ (already published) @@ -96,13 +96,13 @@ agreement — nothing in CI compares them, and hand edits have moved the root al 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 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`, 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 | + | 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 @@ -180,7 +180,8 @@ npm dist-tag ls @opendatacapture/runtime-v1 # la `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/skills/odc-release/SKILL.md b/.agents/skills/odc-release/SKILL.md index 8c5428b18..56732686f 100644 --- a/.agents/skills/odc-release/SKILL.md +++ b/.agents/skills/odc-release/SKILL.md @@ -36,7 +36,7 @@ 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:62-64,111-114,158-162`). +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: @@ -46,7 +46,9 @@ before anything else. Otherwise read the cause off the run, not off the missing 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 whole run (`gh run rerun `); the publish step's version -guard makes that safe. **Never `gh run rerun --failed` here:** it skips `configure` and reuses the plan from the +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 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/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/workflows/release.yaml b/.github/workflows/release.yaml index 526db189e..1073e802c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -140,19 +140,12 @@ jobs: done < <(scripts/list-publishable.sh) pnpm exec turbo run build "${filters[@]}" - name: Publish Packages + uses: actions/github-script@v9 env: NPM_TAG: ${{ needs.configure.outputs.npm_tag }} - 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} under ${NPM_TAG}" - pnpm --filter "${name}" publish --no-git-checks --tag "${NPM_TAG}" - done < <(scripts/list-publishable.sh) + with: + script: | + await require('./.github/scripts/publish-npm.cjs')({ core, exec, npmTag: process.env.NPM_TAG }) release: runs-on: ubuntu-latest needs: diff --git a/package.json b/package.json index 6bb86463f..4c103ad8c 100644 --- a/package.json +++ b/package.json @@ -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 .github/scripts/release.cjs .github/scripts/__tests__ .github/vitest.config.ts 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 1a3196143..8653cca9a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ }, "include": [ ".github/scripts/__tests__/*.ts", + ".github/scripts/publish-npm.cjs", ".github/scripts/release.cjs", ".github/vitest.config.ts", "commitlint.config.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 50181f534..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 } @@ -34,7 +35,7 @@ export default mergeConfig( 'apps/web/src/testing/**' ], include: [ - '.github/scripts/release.cjs', + '.github/scripts/{publish-npm,release}.cjs', 'apps/{api,gateway,web}/src/**/*.?(c|m)[jt]s?(x)', 'packages/*/src/**/*.?(c|m)[jt]s?(x)' ], From 0f5a6def533bcc24a9b1ef8dbb938a44c2a61bb9 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Wed, 4 Nov 2026 15:48:59 -0500 Subject: [PATCH 6/6] test(web): match walkthrough button label casing Co-Authored-By: GLM 5.3 Flash --- apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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', () => {