diff --git a/.agents/docs/architecture/release-channels.md b/.agents/docs/architecture/release-channels.md new file mode 100644 index 000000000..38550bec1 --- /dev/null +++ b/.agents/docs/architecture/release-channels.md @@ -0,0 +1,96 @@ +# Release channels + +What each GHCR image tag, npm publish and GitHub release means, and which run moves it. The policy +these implement, written for deployers, is `docs/en/5-reference/5.10-versioning.md`; cutting a release +is `.agents/docs/playbooks/cut-a-release.md`. This file is the model both act on. + +> **A version tag is immutable, and `latest` and `` move only with a release.** Deployments +> pull `` by default (`RELEASE_CHANNEL` in `.env.template`). Anything that re-pushes a released +> tag changes what every one of them runs on its next pull, so only a run that also creates the +> `v` git tag may push ``, `` or `latest`. + +## 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 pushed or dispatched 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, and the GHCR lookup an earlier version +made did exactly that. + +| Output | Meaning | +| --------- | ------------------------------------------------------------------------- | +| `release` | `v` is not a tag yet, so this run publishes it | +| `latest` | `release` on `main`: moves `latest` on GHCR and GitHub, and publishes npm | +| `edge` | the ref is `main` | +| `major` | `` of the version, the image tag every release of that major moves | + +**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 and `cancel-in-progress: false`. GitHub +keeps one waiting run per group, so a third run cancels the waiting one. That loses nothing on `main`: +the next push finds the version still untagged and releases it. A full re-run plans again from fresh +tags; `gh run rerun --failed` reuses the stale plan, so it is never used on this workflow. + +**A release is the newest of its line.** On `main`, `planRelease` refuses an untagged version below +any tag, so `latest` and npm never move back. On a release branch it refuses one below a tag of the +same major, so a late retry cannot move `` back. + +## 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 | `v`, marked latest | +| dispatch on `release/`, new version | ``, `` | skipped, noticed | `v`, not latest | +| dispatch on `release/`, otherwise | — `configure` fails | — | — | +| any other ref | — `configure` fails | — | — | + +**npm publishes from `main` only, so every npm publish is a `latest` publish.** There is no other +dist-tag. A release from a branch skips `publish-npm`, and `configure` says so in a notice. The +`release` job's `if` lets it run past that skip but not past a failed publish. + +**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/`, and it exists only for a superseded major.** `planRelease` +refuses a dispatch on `release/` until a higher major is tagged, so a branch release can never +plan `latest`, and refuses a version from another major. It is cut from the last release of that +major once the next major ships from `main`, and released only by +`gh workflow run Release --ref release/`. A push to it publishes nothing. Changes land on it by a +pull request, which `ci.yaml` gates as it gates one to `main`. + +**A release branch takes fixes only.** `scripts/increment-version.ts` asks `release.cjs`'s +`isSupersededMajor` whether the current major is superseded; if it is, it offers and accepts a patch +bump only, and refuses a breaking or `feat` commit in range outright. On `main`, once a breaking +commit is in range, it accepts a major bump only. + +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 back is hosted configuration, not this file.** The `release` environment allows +deployments from `main` only, each npm trusted publisher names `release.yaml` and that environment, +and the `Release tags` ruleset lets only admins move or delete a `v*` tag. GHCR is not covered: any +workflow on any branch can push an image with `GITHUB_TOKEN`. + +## A major bump moves two more files + +`increment-version.ts` on a major bump rewrites `RELEASE_CHANNEL` in `.env.template` to the new major, +and edits the table between `` and `` in +`docs/en/5-reference/5.10-versioning.md`: a row for the new major, and the previous major's superseded +month and end of support, 18 months on (`SUPPORT_WINDOW_MONTHS`). It dates both by the month of the +bump, which is the month the release ships only if the PR merges that month. + +## 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 supported major 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 ef4378d98..0d7ae8bbc 100644 --- a/.agents/docs/architecture/testing-strategy.md +++ b/.agents/docs/architecture/testing-strategy.md @@ -26,20 +26,21 @@ only what it ran against the whole coverage scope, so add `--coverage.thresholds ## A test nothing collects passes silently -Root `vitest.config.ts` collects only `apps/*/vitest.config.ts`, `packages/*/vitest.config.ts` and -`runtime/*/vitest.config.ts`. A test file in a workspace with no config of its own is collected by +Root `vitest.config.ts` collects only `.github/vitest.config.ts`, `apps/*/vitest.config.ts`, +`packages/*/vitest.config.ts` and `runtime/*/vitest.config.ts`. A test file in a workspace with no config of its own is collected by nothing, reported by nothing, and passes CI green. A green run prints counts, not file names; `pnpm exec vitest list --filesOnly [--project ]` prints every collected file prefixed with its project. Before trusting a new test, see it listed and watch it fail on purpose. ## Vitest projects -Twenty-one, each named after its directory except `runtime/v1` (`runtime-v1`). Quirks of each live in +Twenty-two, each named after its directory except `runtime/v1` (`runtime-v1`) and `.github` (`github`). Quirks of each live in that package's `AGENTS.md` §Tests. Three run under happy-dom; the rest run in node, where one file opts into a DOM with a first-line `// @vitest-environment happy-dom` docblock. | Project | Package | Notes | | ------------------------ | --------------------------------- | -------------------------------------------------------- | +| `github` | `.github` | not a workspace; tests `scripts/release.cjs` | | `api` | `apps/api` | libnest SWC plugin, confined to `apps/api` sources | | `gateway` | `apps/gateway` | | | `playground` | `apps/playground` | | @@ -117,15 +118,15 @@ counts their functions twice in the merged report. ## What CI gates -`.github/workflows/ci.yaml`, one job, on `pull_request` to `main` and `workflow_dispatch` only — a -push alone runs nothing. In order: `./scripts/generate-env.sh`, `pnpm install --frozen-lockfile`, +`.github/workflows/ci.yaml`, one job, on `pull_request` to `main` or a `release/**` branch and +`workflow_dispatch` only — a push alone runs nothing. In order: `./scripts/generate-env.sh`, `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm test:coverage`, Playwright install (chromium + firefox, capped at 10 minutes, after lint and unit tests so a hung download cannot hide them), `pnpm test:e2e`. Not gated: knip (`pnpm knip` exists), and formatting — prettier runs only in the `.husky/pre-commit` hook, which formats rather than verifies. -`.github/workflows/release.yaml` (push to `main`) validates with `pnpm lint` only — **no unit or e2e +`.github/workflows/release.yaml` (push to `main`, dispatch on `release/`) validates with `pnpm lint` only — **no unit or e2e tests before a release build.** `.github/workflows/commitlint.yaml` (`pull_request` to any branch) lints the commits the PR adds against `commitlint.config.ts`. diff --git a/.agents/docs/index.md b/.agents/docs/index.md index d6c2a0d58..94381e2b3 100644 --- a/.agents/docs/index.md +++ b/.agents/docs/index.md @@ -38,6 +38,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, `latest`, release branches and the bump rules | `.agents/docs/architecture/release-channels.md` | ## Step-by-step playbooks diff --git a/.agents/docs/playbooks/cut-a-release.md b/.agents/docs/playbooks/cut-a-release.md index 7b6ed6e99..f11740591 100644 --- a/.agents/docs/playbooks/cut-a-release.md +++ b/.agents/docs/playbooks/cut-a-release.md @@ -1,9 +1,12 @@ # Cut a release -A release is one version bump merged to `main`. `.github/workflows/release.yaml` then builds and -pushes the images, publishes the npm packages, and creates the GitHub release, whose body is the new -version's section of `CHANGELOG.md`. Which packages publish is in `.agents/docs/workspace-map.md`; -the `RELEASE_VERSION` build-arg contract is in `packages/release-info/AGENTS.md`. +A release is one version bump merged to `main`, or merged to a `release/` branch and +dispatched (§From a release branch). `.github/workflows/release.yaml` then builds and pushes the +images, publishes the npm packages (from `main` only), and creates the GitHub release, whose body is +the new version's section of `CHANGELOG.md`. What each image tag means and which run moves it is +`.agents/docs/architecture/release-channels.md`; which packages publish is in +`.agents/docs/workspace-map.md`; the `RELEASE_VERSION` build-arg contract is in +`packages/release-info/AGENTS.md`. **Plan around this:** the root `package.json` version is the release identity (the image tag and the GitHub tag `v${version}`), but each npm package publishes at **its own** `package.json` version, and @@ -34,7 +37,12 @@ them. Never hand-edit a version field. `## ` section of `CHANGELOG.md` and regenerates `docs/en/6-changelog/changelog.md`, then asserts the lockstep. A commit message the changelog generator cannot parse fails the run (`Error: Commit does not follow the convention …`) and writes nothing: reword that commit. - Done when the output ends `✓ All packages report `. + The menu offers only the bumps the support policy allows, and `--bump` refuses the rest: major + alone once a breaking commit is in range, and patch alone on a superseded major, where a breaking + or `feat` commit fails the run (`release-channels.md` §Release branches). A major bump also + rewrites `.env.template` and the support table in `docs/en/5-reference/5.10-versioning.md`, + checking both before it writes any file, and `--commit` takes both. Done when the output ends `✓ All packages report `, or on a major bump + with the two lines after it. 4. **Confirm the lockstep**, which matters whenever anything bypassed the script: @@ -47,71 +55,99 @@ them. Never hand-edit a version field. `CHANGELOG.md` section: it becomes the release body verbatim, and this PR is its only review. It lists `feat`, `fix`, `perf` and breaking commits only. -5. **Commit as `chore: release v` and open the PR against `main`.** The script's own commit +5. **Commit as `chore: release v` and open the PR against the base the script names**, + `main` or `release/`. The script's own commit (`--commit`) takes the version files and both changelogs by explicit pathspec; it does not tag (the workflow tags `v${version}` on the GitHub release). `ci.yaml` runs only on a PR to `main` or a - manual dispatch, so a PR based on any other branch gets no lint, tests or e2e. + `release/**` branch, or a manual dispatch, so a PR based on any other branch gets no lint, tests or + e2e. -6. **Land one release at a time.** The workflow's concurrency group cancels an in-flight run when a - second merge lands. +6. **Land one release at a time.** One concurrency group spans every ref with + `cancel-in-progress: false`: a run waits for the one in flight, and GitHub keeps one waiting run, + so a third cancels it. A cancelled release from `main` is released by the next push, which finds + its version still untagged; a cancelled branch dispatch has to be dispatched again. 7. **Watch the run:** `gh run watch`, or `gh run list --workflow=Release --limit 1` for its id. - | Job | Does | Does not run when | - | ------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | - | `configure` | derives the image matrix from `docker compose config`; `release.cjs` reads the root version | never; **fails** when dispatched from not-`main` | - | `validate` | `pnpm lint` | never | - | `build` | buildx `linux/amd64,linux/arm64` per image, pushing `latest` and the bare version onto one manifest | `configure` or `validate` failed | - | `publish-npm` | builds each publishable package and publishes each version npm lacks, over OIDC in the `release` environment | `build` did not succeed | - | `release` | creates the GitHub release `v${version}` from `scripts/changelog.ts section` | any of its `needs` did not succeed | + | Job | Does | Does not run when | + | ------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | + | `configure` | derives the image matrix from `docker compose config`; `release.cjs` plans the run from the root version and the git tags | never; **fails** on any ref but `main` and `release/`, and on a branch dispatch the plan refuses | + | `validate` | `pnpm lint` | never | + | `build` | buildx `linux/amd64,linux/arm64` per image, pushing the tags the plan enables onto one manifest | `configure` or `validate` failed | + | `publish-npm` | builds each publishable package and publishes each version npm lacks, over OIDC in the `release` environment | `build` did not succeed, or the plan's `latest` is `false` (no release, or a release from a branch) | + | `release` | creates the GitHub release `v${version}` on the built commit from `scripts/changelog.ts section` | the plan's `release` is `false`, `build` did not succeed, or `publish-npm` ran and failed | A `build` that fails or is cancelled shows `publish-npm` and `release` as **skipped**, not failed. - Read the cause off the run: one `build` leg `failure` with its siblings `cancelled` is - `fail-fast` on an image that does not build; the whole run `cancelled` is a second merge. + On a push with no bump both are skipped by design; read `configure`'s `release` output first. + Otherwise read the cause off the run: one `build` leg `failure` with its siblings `cancelled` is + `fail-fast` on an image that does not build; the whole run `cancelled` before it started is a + third run arriving while it waited (step 6). `release` **fails** with `CHANGELOG.md has no section for ` when the bump bypassed the script, deliberately, rather than publishing an empty body. **Images:** every compose service in `docker-compose.yaml` with both `build` and `image` ships - (today api, gateway, web and playground), so a new image needs nothing in the workflow. Tags are - bare; the `v` is on the GitHub tag only. `scripts/publish.sh` is wired to nothing and is not a + (today api, gateway, web and playground), so a new image needs nothing in the workflow. Version + tags are bare; the `v` is on the GitHub tag only. `scripts/publish.sh` is wired to nothing and is not a substitute: it pushes your daemon's local `:latest` of all four over the CI-published ones. - **Only `main` publishes**, and `release.cjs` refusing other refs stops accidents only, since a - dispatched ref supplies its own workflow. Hosted configuration does the enforcing and must stay: - the `release` environment allows only `main`, each package's npm trusted publisher names - `release.yaml` and that environment, and the `Release tags` ruleset lets only admins move or - delete a `v*` tag (creation cannot be restricted, because GitHub refuses the Actions app as a - bypass actor). GHCR is not covered: any branch workflow can push an image with `GITHUB_TOKEN`. + **`release.cjs` refusing other refs stops accidents only**, since a dispatched ref supplies its + own workflow. The hosted configuration that does the enforcing is listed in + `release-channels.md` §Release branches and must stay. The `Release tags` ruleset cannot restrict + tag creation, because GitHub refuses the Actions app as a bypass actor. -8. **Every run from `main` is a full release, by design**, including a push with no bump and a - `workflow_dispatch`: it re-pushes the same image tags, updates the existing GitHub release, and - prints `Skipping` for every package. `release.cjs` deliberately consults nothing outside the repo; - do not add a gate that reads GHCR or npm back, or a deleted release can 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 add a gate that reads a + registry back, or a deleted release can wedge the next one. 9. **Confirm all three artifacts carry the new version** with `## Verify`. ## Recover -- **`build` failed or was cancelled:** fix it and `gh run rerun `. The publish step's version - guard makes a re-run safe. +- **`build` failed or was cancelled:** fix it and `gh run rerun `, the whole run. The publish + step's version guard makes a re-run safe. Never `--failed`: it skips `configure` and reuses a plan + whose tag snapshot may be stale. - **npm printed `Skipping` for every package on the first run after a bump, or `release` reported no changelog section:** the bump bypassed the script. Run it properly and merge another patch; npm never re-publishes a version it has. - **Pulling a bad release** means three independent places. Deleting the image version also deletes - `latest`, which sits on the same manifest, so every `docker pull …:latest` fails until you cut the - replacement or retag a known-good version by hand. Deleting the GitHub release and its `v` tag + `` and `latest`, which sit on the same manifest, so every pull of either fails until you cut + the replacement or retag a known-good version by hand. Deleting the `v` tag re-arms the + release: the next push to `main` at that version publishes it again. Deleting the GitHub release and its `v` tag touches neither images nor npm, and npm generally refuses to unpublish a version older than 72h. +## From a release branch + +A fix for a superseded major. The rules are `release-channels.md` §Release branches; these are only +the steps. + +1. **Cut `release/` from the last `v.*` tag, once the next major has shipped**, and push + it. `configure` refuses a dispatch on it before then. +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 would rebuild an old release on whatever Node + is newest. Find it with `docker run --rm node:lts-krypton node --version` on the day you cut it. +3. **Land each fix on `main` first, then take it onto the branch by a PR against the branch.** The + PR states which category of `docs/en/5-reference/5.10-versioning.md` §What each version receives + the fix falls under; anything else does not go onto the branch. +4. **Bump on the branch**, steps 3–5 above. `git describe` finds the major's last tag, so the + changelog range is the branch's own commits, and the script names the branch as the PR's base. +5. **Dispatch: `gh workflow run Release --ref release/`.** `configure` fails if the version is + already tagged or is not on the branch's major. npm is skipped, with a notice. +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, support + table and floating `FROM node:lts-krypton` lines on a conflict. + ## Verify ```sh node -p "require('./package.json').version" && scripts/list-publishable.sh # root first, then that same version on every row 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 --json jobs -q '.jobs[]|"\(.name)\t\(.conclusion)"' # every build leg, publish-npm and release: success (publish-npm skipped from a branch) gh run view --log | grep -E 'Publishing|Skipping' # Publishing @ for every package -npm view @opendatacapture/runtime-v1 version # the version you just cut +npm view @opendatacapture/runtime-v1 version # the version you just cut, on a release from main pnpm exec tsx scripts/changelog.ts section # the release body's source; 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 true from main, false from a branch +git ls-remote origin "refs/tags/v" # the sha the run built ``` The images have no read-only check: `gh api /orgs/DouglasNeuroinformatics/packages/container/...` diff --git a/.env.template b/.env.template index 4d9984798..072d5e14e 100644 --- a/.env.template +++ b/.env.template @@ -19,8 +19,10 @@ GATEWAY_PORT=3500 # another machine. BIND_ADDRESS=127.0.0.1 -# The Docker release to use (latest = stable) -RELEASE_CHANNEL=latest +# The Docker release to use: (the newest release of that major, including its fixes; the +# default), .. (one exact release), latest (the newest release, across majors), or +# edge (unreleased main). See https://opendatacapture.org/en/docs/reference/versioning +RELEASE_CHANNEL=2 # The MongoDB version to use MONGODB_VERSION=7.0 # The RustFS (S3-compatible storage) version to use diff --git a/.github/scripts/__tests__/release.test.ts b/.github/scripts/__tests__/release.test.ts new file mode 100644 index 000000000..00079aa96 --- /dev/null +++ b/.github/scripts/__tests__/release.test.ts @@ -0,0 +1,135 @@ +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 = 'refs/heads/release/2'; + +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' })).toEqual({ + edge: true, + latest: false, + major: '2', + release: false, + version: '2.5.2' + }); + }); + + it('should release main as latest when its version is new and the highest', () => { + expect(planRelease({ ref: MAIN, tags: ['v2.5.2'], version: '2.6.0' })).toEqual({ + edge: true, + latest: true, + major: '2', + release: true, + version: '2.6.0' + }); + }); + + it('should refuse a release from main below any tag, so latest never moves back', () => { + expect(() => planRelease({ ref: MAIN, tags: ['v3.0.0'], version: '2.6.0' })).toThrow( + '2.6.0 is older than v3.0.0; bump past it first' + ); + }); + + it('should release a superseded major from its branch without moving latest', () => { + expect(planRelease({ ref: RELEASE_2, tags: ['v2.6.0', 'v3.0.0'], version: '2.6.1' })).toEqual({ + edge: false, + latest: false, + major: '2', + release: true, + version: '2.6.1' + }); + }); + + it('should refuse a release branch whose major is still the newest, so it cannot plan latest', () => { + expect(() => planRelease({ ref: RELEASE_2, tags: ['v2.6.0', 'v3.0.0-rc.1'], version: '2.6.1' })).toThrow( + 'No major above 2 is tagged, so 2.x is still released from refs/heads/main' + ); + }); + + it('should refuse an untagged patch older than its line, so a retry cannot move the major tag back', () => { + expect(() => planRelease({ ref: RELEASE_2, tags: ['v2.6.2', 'v3.0.0'], version: '2.6.1' })).toThrow( + '2.6.1 is older than v2.6.2; bump past it first' + ); + }); + + it('should not count a two-digit major as part of a one-digit line', () => { + expect(() => planRelease({ ref: RELEASE_2, tags: ['v20.0.0'], version: '2.6.1' })).not.toThrow(); + }); + + it('should ignore tags that are not plain versions', () => { + expect(planRelease({ ref: MAIN, tags: ['v9-beta', 'v9.0.0-rc.1', '9.0.0'], 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, tags: ['v2.6.1', 'v3.0.0'], version: '2.6.1' })).toThrow( + 'v2.6.1 is already released; bump the version on refs/heads/release/2 first' + ); + }); + + it('should refuse a version from another major on a release branch', () => { + expect(() => planRelease({ ref: RELEASE_2, tags: ['v4.0.0'], version: '3.0.0' })).toThrow( + 'Version 3.0.0 does not belong on refs/heads/release/2' + ); + }); + + it('should refuse any other ref, so a feature branch cannot publish', () => { + expect(() => planRelease({ ref: 'refs/heads/release/2.5', tags: [], version: '2.5.3' })).toThrow( + 'Releases are cut from refs/heads/main or refs/heads/release/, not refs/heads/release/2.5' + ); + }); + + 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(); + }); + + function run(ref: string, version: string, tags: string) { + const readFileSync = vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ version })); + const core = { notice: vi.fn(), setOutput: vi.fn() }; + const exec = { getExecOutput: vi.fn(() => Promise.resolve({ stdout: tags })) }; + return { core, done: release({ context: { ref }, core, exec }), exec, readFileSync }; + } + + it('should plan from the root package.json and every v* tag in the repository', async () => { + const { done, exec, readFileSync } = run(MAIN, '2.6.0', 'v2.5.2\n'); + await done; + expect(readFileSync).toHaveBeenCalledWith('package.json', 'utf-8'); + expect(exec.getExecOutput).toHaveBeenCalledWith('git', ['tag', '--list', 'v*']); + }); + + it('should expose the plan for the root version and the git tags as workflow outputs', async () => { + const { core, done } = run(MAIN, '2.6.0', 'v2.5.1\nv2.5.2\n'); + await done; + expect(Object.fromEntries(core.setOutput.mock.calls)).toEqual({ + edge: 'true', + latest: 'true', + major: '2', + release: 'true', + version: '2.6.0' + }); + expect(core.notice).not.toHaveBeenCalled(); + }); + + it('should say that a release of a superseded major skips npm, so the missing publish is not a surprise', async () => { + const { core, done } = run(RELEASE_2, '2.6.1', 'v2.6.0\nv3.0.0\n'); + await done; + expect(core.notice).toHaveBeenCalledWith( + 'Skipping npm: 2.6.1 releases the superseded 2 line, and npm publishes from main only' + ); + }); +}); diff --git a/.github/scripts/release.cjs b/.github/scripts/release.cjs index 01731b4b9..aac29c9ce 100644 --- a/.github/scripts/release.cjs +++ b/.github/scripts/release.cjs @@ -1,26 +1,138 @@ +// @ts-check const fs = require('node:fs'); +// What each field makes the workflow do: .agents/docs/architecture/release-channels.md. /** - * Expose the release version and the decision to release as workflow outputs. + * @typedef {object} ReleasePlan + * @property {boolean} edge + * @property {boolean} latest + * @property {string} major + * @property {boolean} release + * @property {string} version + */ + +/** @typedef {readonly [number, number, number]} ParsedVersion */ + +const RELEASE_BRANCH_PATTERN = /^refs\/heads\/release\/(\d+)$/; +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +/** + * @param {string} version + * @returns {ParsedVersion | null} + */ +function parseVersion(version) { + const match = VERSION_PATTERN.exec(version); + return match && [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** + * @param {string} tag + * @returns {ParsedVersion | null} + */ +function parseTag(tag) { + return tag.startsWith('v') ? parseVersion(tag.slice(1)) : null; +} + +/** + * A major with a higher major tagged is superseded: it takes fixes only, from its `release/` + * branch. `scripts/increment-version.ts` asks the same question, so it imports this. * - * 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. + * @param {number} major + * @param {readonly string[]} tags + */ +function isSupersededMajor(major, tags) { + return tags.some((tag) => { + const parsedTag = parseTag(tag); + return parsedTag !== null && parsedTag[0] > major; + }); +} + +/** + * @param {ParsedVersion} left + * @param {ParsedVersion} 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. * - * 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. + * `main` always builds `edge`, and releases only when its version has no `v*` tag yet. A + * `release/` branch exists only for a superseded major, so it can never move `latest`, and + * npm, whose trusted publisher trusts `main` alone, skips it. * - * @param {import('github-script').AsyncFunctionArguments} args + * 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 {{ 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 major = String(parsedVersion[0]); + 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}`); } + const release = !tags.includes(`v${version}`); + if (releaseBranch) { + if (releaseBranch[1] !== major) { + throw new Error(`Version ${version} does not belong on ${ref}`); + } + if (!isSupersededMajor(parsedVersion[0], tags)) { + throw new Error(`No major above ${major} is tagged, so ${major}.x is still released from refs/heads/main`); + } + if (!release) { + throw new Error(`v${version} is already released; bump the version on ${ref} first`); + } + } + const blockingTag = tags.find((tag) => { + const parsedTag = parseTag(tag); + return ( + parsedTag !== null && + compareVersions(parsedTag, parsedVersion) > 0 && + (isMain || parsedTag[0] === parsedVersion[0]) + ); + }); + if (release && blockingTag) { + throw new Error(`${version} is older than ${blockingTag}; bump past it first`); + } + return { edge: isMain, latest: isMain && release, major, 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: { notice: (message: string) => void; 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 }); + if (plan.release && !plan.latest) { + core.notice( + `Skipping npm: ${version} releases the superseded ${plan.major} line, and npm publishes from main only` + ); + } + for (const [name, value] of Object.entries(plan)) { + core.setOutput(name, String(value)); + } }; + +module.exports.isSupersededMajor = isSupersededMajor; +module.exports.parseVersion = parseVersion; +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 e4863ccf0..ca3de53f8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,13 +2,16 @@ name: Release on: push: branches: ['main'] + # A release/ branch releases 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,25 @@ jobs: configure: runs-on: ubuntu-latest outputs: - should_release: ${{ steps.version.outputs.should_release }} - version: ${{ steps.version.outputs.version }} + edge: ${{ steps.plan.outputs.edge }} + latest: ${{ steps.plan.outputs.latest }} + major: ${{ steps.plan.outputs.major }} matrix: ${{ steps.matrix.outputs.matrix }} + 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 +61,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 +82,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.major }},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: @@ -99,7 +110,9 @@ jobs: needs: - build - configure - if: ${{ needs.configure.outputs.should_release == 'true' }} + # npm publishes from main only, so every publish is a latest publish; a release of a superseded + # major skips this job, and configure says so. + if: ${{ needs.configure.outputs.latest == 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -145,6 +158,11 @@ jobs: - build - configure - publish-npm + # publish-npm is skipped, not failed, on a release of a superseded major; the default success() + # gate would skip this job with it. + if: >- + ${{ !cancelled() && needs.configure.outputs.release == 'true' && needs.build.result == 'success' + && (needs.publish-npm.result == 'success' || needs.configure.outputs.latest == 'false') }} steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -165,4 +183,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 6b25dfd65..f7ed0973f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ working in before 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` (see `.nvmrc`, currently `lts/krypton`). ```sh pnpm install # install deps diff --git a/SECURITY.md b/SECURITY.md index 3e6da58f4..c3c219e50 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported Versions -The most recent version of the platform will receive security updates. +Security fixes reach the newest release of the current major version, and the last minor version of every major superseded in the past 18 months. Which versions those are, and what else each one receives, is on the [Versioning and Support](https://opendatacapture.org/en/docs/reference/versioning/) page. ## Reporting a Vulnerability -We take security and data privacy very seriously. If you believe you have found a vulnerability, or have any type of security concern with our application, please contact us at support@douglasneuroinformatics.ca. +We take security and data privacy very seriously. Please report a vulnerability privately, through [GitHub's private vulnerability reporting](https://github.com/DouglasNeuroInformatics/OpenDataCapture/security/advisories/new) (the **Report a vulnerability** button on this repository's Security tab), rather than in a public issue. You can also contact us at support@douglasneuroinformatics.ca. diff --git a/docs/en/2-tutorials/2.1-deployment.md b/docs/en/2-tutorials/2.1-deployment.md index 62c7a37c4..9a431a8dc 100644 --- a/docs/en/2-tutorials/2.1-deployment.md +++ b/docs/en/2-tutorials/2.1-deployment.md @@ -58,12 +58,14 @@ With your web server up and running, the next step is to download and launch Ope cd /opt ``` -Then, clone the Open Data Capture repository: +Then, clone the Open Data Capture repository at its newest release. Replace `` with the newest version on the [releases page](https://github.com/DouglasNeuroInformatics/OpenDataCapture/releases/latest), for example `2.6.0`: ```sh -git clone https://github.com/DouglasNeuroInformatics/OpenDataCapture.git +git clone --branch v https://github.com/DouglasNeuroInformatics/OpenDataCapture.git ``` +Always install from a release like this, rather than from the `main` branch, which holds unreleased code. [Versioning and Support](/en/docs/reference/versioning) explains how to upgrade later, and which versions receive fixes. + Navigate into the newly created directory: ```sh @@ -94,6 +96,8 @@ GATEWAY_PORT=3500 The generated file also sets `BIND_ADDRESS=127.0.0.1`, so these ports accept connections from this server only, and the web server you configure in step 6 is the only way in. If your `.env` was generated before this setting existed, add that line yourself: without it, the ports are published on every network interface, where a client can reach the application without going through HTTPS. +The generated file also sets `RELEASE_CHANNEL` to the current major version, such as `2`, so your instance receives every fix in that major and never a breaking change. For the other choices, see [Choosing an image tag](/en/docs/reference/versioning#choosing-an-image-tag). + For the purposes of this guide, we will leave the other settings as the default values. However, for an actual production server, refer to the comments in the generated `.env` file to learn about the available options. #### Step 5: Launch Application diff --git a/docs/en/2-tutorials/2.4-playground-deployment.md b/docs/en/2-tutorials/2.4-playground-deployment.md index 94b20e0f7..64b8d66bb 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`. Each release is tagged with its version number (for example, `2.6.0`) and with its major version (for example, `2`), which always points to the newest release of that major. The `latest` tag is the newest release of the newest major, and `edge` is the newest unreleased build of the `main` branch. [Choosing an image tag](/en/docs/reference/versioning#choosing-an-image-tag) explains which to use. 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/docs/en/5-reference/5.10-versioning.md b/docs/en/5-reference/5.10-versioning.md new file mode 100644 index 000000000..6447c65e5 --- /dev/null +++ b/docs/en/5-reference/5.10-versioning.md @@ -0,0 +1,109 @@ +--- +title: Versioning and Support +slug: en/docs/reference/versioning +sidebar: + order: 10 +--- + +This page is for the people who deploy and maintain an instance of Open Data Capture. It says what a version number promises, which versions receive fixes, and how to install and upgrade so that your instance runs a release rather than unreleased code. + +### What a version number promises + +A version is `..`, for example `2.6.0`. Within one major, upgrading to a newer release is always safe: it keeps every promise below. A release that breaks any of them is a new major, and its changelog lists each break under **Breaking Changes**, with what you have to do. + +1. **Stored data.** An upgrade starts against your existing database and file storage and reads every existing record, with no manual step. +2. **Deployment configuration.** This covers the variables in `.env` and the services, volumes and ports in the Docker Compose stack. A new variable has a default that keeps the old behaviour. Renaming or removing a variable, or making it required, is a breaking change. So the newest images of a major work with the compose file from any earlier release of that major. +3. **Stored instruments.** Every instrument already in your database keeps rendering, and its records keep exporting. +4. **The documented HTTP API**, as the API's OpenAPI document (`spec.json`) describes it. Additions are allowed; removing or changing anything is a breaking change. +5. **Export formats.** Column and field names stay the same. +6. **Access.** An upgrade never widens or narrows what an existing user can see or do, unless an administrator acts. The one exception is the fix for a [security issue](#what-each-version-receives) that gave users access they were never meant to have: that fix is not a breaking change, and its advisory says so. +7. **The public API and command-line interface of each package published to npm.** + +The promise does not cover the layout and workflows of the user interface, undocumented API endpoints, internal packages, or the protocol between the gateway and the API. The gateway and the API must always run the same version. + +You can upgrade directly from any release of a major to the newest release of the same major. You do not need to install the releases in between. + +### Which versions are supported + +- **The current major is supported in its newest release only.** To receive a fix, upgrade to the newest release of the current major, which the promise above makes safe. +- **A superseded major keeps its last minor version supported for 18 months** after the next major is released. +- Versions before 2.0.0 are not covered by this policy. + + + +| Major | Released | Superseded | Supported until | +| ----- | -------- | ---------- | --------------- | +| 2 | 2026-06 | — | — | + + + +A major with no superseded date is the current one, and it stays supported until 18 months after its successor is released. Each date is a month: a major superseded in 2026-11 is supported until the end of 2028-05. + +### What each version receives + +| Fix | Current major (newest release) | Newest superseded major | Older superseded majors | +| --------------- | ------------------------------ | ----------------------- | ----------------------- | +| Security | ✓ | ✓ | ✓ | +| Data integrity | ✓ | ✓ | ✓ | +| Critical bug | ✓ | ✓ | — | +| Everything else | ✓ | — | — | + +- **Security** means either of two things: + - A vulnerability that lets someone, in a documented deployment configuration (the Docker Compose stack and its documented options, including optional ones), do any of the following: + 1. read data they are not authorized to see; + 2. create, change or delete data they are not authorized to; + 3. act as another user, or gain permissions they were not given; + 4. run code on the server or in another user's browser. + - **Or** a vulnerability rated Critical in anything a deployment runs: the base image, its operating system packages, the npm dependencies the API, gateway and web app run with, and the third-party images the Docker Compose stack uses (MongoDB, RustFS and Caddy). + + A severity score on its own does not make an issue a security issue. + +- **Data integrity** means a bug that silently loses, corrupts or misattributes recorded data, or exports it incorrectly. +- **Critical bug** means the stack fails to start, or a core workflow is unusable with no workaround. The core workflows are administering an instrument, collecting a remote assignment, managing subjects and users, and exporting data. An availability problem, such as a crash or resource exhaustion, counts as a critical bug when a user can trigger it and it makes a core workflow unusable. Any other availability problem is fixed in the next ordinary release of the current major. + +The maintainers judge whether an issue can be reached and whether a workaround exists. If a fix cannot be applied to a supported version without breaking it, for example because the fix exists only in the next major of a dependency, we publish an advisory saying which versions are affected and recommending an upgrade. + +### Choosing an image tag + +`RELEASE_CHANNEL` in `.env` chooses the tag of the Open Data Capture images that Docker Compose pulls. + +| Tag | Moves when | Use it for | +| ----------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `` | every release of that major, including fixes | **the default**: production deployments that want every fix and never a breaking change | +| `` | never | production deployments that upgrade by hand, one release at a time | +| `latest` | every release of the newest major | demonstrations and quick starts only: it crosses majors, so it can bring a breaking change | +| `edge` | every change to the `main` branch | development and testing; it is unreleased code | +| `sha-<7>` | never | pinning one exact build of the `main` branch | + +A generated `.env` tracks the current major, for example `RELEASE_CHANNEL=2`. Moving to the next major is always a choice you make: read the **Breaking Changes** of its first release in the [changelog](/en/docs/changelog), then set `RELEASE_CHANNEL` to the new major. + +### Installing and upgrading + +Install from a release tag, never from the `main` branch, so that the compose file you run belongs to a release. Replace `` with the newest version on the [releases page](https://github.com/DouglasNeuroInformatics/OpenDataCapture/releases/latest): + +```sh +git clone --branch v https://github.com/DouglasNeuroInformatics/OpenDataCapture.git +``` + +To upgrade, check out the newer release and restart the stack: + +```sh +git fetch --tags +git checkout v +docker compose pull +docker compose up -d +``` + +`.env` and the `mongo`, `rustfs` and `sqlite` directories are ignored by git, so switching between releases never touches your configuration or your data. + +### Hearing about new releases + +- **Releases:** on the [repository page](https://github.com/DouglasNeuroInformatics/OpenDataCapture), choose **Watch → Custom → Releases**. +- **Security advisories:** choose **Watch → Custom → Security alerts** as well. Every security fix gets a [GitHub Security Advisory](https://github.com/DouglasNeuroInformatics/OpenDataCapture/security/advisories) that lists the affected and the fixed versions of each supported major. +- **Data integrity fixes** are not vulnerabilities, so they get no advisory. The release notes say which versions were affected. + +To report a vulnerability, follow the [security policy](https://github.com/DouglasNeuroInformatics/OpenDataCapture/security/policy). + +### npm packages + +The packages published to npm carry the same version numbers as the platform, but they are published only from the newest major. A fix to a superseded major ships as images and a GitHub release, not to npm. To stay on one major in your own project, depend on `^`, for example `^2`. diff --git a/docs/en/5-reference/5.6-docker.md b/docs/en/5-reference/5.6-docker.md index 76ff72708..5c893b19f 100644 --- a/docs/en/5-reference/5.6-docker.md +++ b/docs/en/5-reference/5.6-docker.md @@ -37,7 +37,7 @@ To use a profile without passing `--profile` to every command, set `COMPOSE_PROF ## Minimal stacks -Leave `docker-compose.yaml` unchanged, so that updating Open Data Capture with `git pull` never conflicts with your changes. Choose services with a profile, and put any other change in a `docker-compose.override.yaml` next to it, which Docker Compose merges into `docker-compose.yaml` automatically. +Leave `docker-compose.yaml` unchanged, so that [upgrading](/en/docs/reference/versioning#installing-and-upgrading) Open Data Capture with `git checkout` never conflicts with your changes. Choose services with a profile, and put any other change in a `docker-compose.override.yaml` next to it, which Docker Compose merges into `docker-compose.yaml` automatically. ### Without the gateway diff --git a/package.json b/package.json index 3bf6e5206..ba495af06 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", @@ -24,7 +24,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 .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/scripts/increment-version.ts b/scripts/increment-version.ts index 4b0692eb3..4fbfcc4e0 100755 --- a/scripts/increment-version.ts +++ b/scripts/increment-version.ts @@ -6,6 +6,10 @@ import path from 'node:path'; import readline from 'node:readline/promises'; import { parseArgs, styleText } from 'node:util'; +import { format } from 'prettier'; + +import release from '../.github/scripts/release.cjs'; + // The menu offers the bumps in this order, so they live in a tuple: perfectionist/sort-objects // alphabetizes object literals and would silently reorder the prompt. const BUMPS = ['major', 'minor', 'patch'] as const; @@ -24,7 +28,15 @@ type PackageFile = { relativePath: string; }; +type FileEdit = { + contents: string; + relativePath: string; + summary: string; +}; + type Plan = { + base: string; + bump: Bump; commit: boolean; newVersion: string; }; @@ -42,6 +54,16 @@ type Streams = { // scripts/changelog.ts writes these; they are committed alongside the version files. const CHANGELOG_FILES = ['CHANGELOG.md', 'docs/en/6-changelog/changelog.md']; +// A major bump rewrites these too: the channel a fresh .env tracks, and the end-of-support table. +const ENV_TEMPLATE_FILE = '.env.template'; +const SUPPORT_POLICY_FILE = 'docs/en/5-reference/5.10-versioning.md'; + +const SUPPORT_TABLE_START = ''; +const SUPPORT_TABLE_END = ''; + +// A superseded major stays supported for this long after its successor's release. +const SUPPORT_WINDOW_MONTHS = 18; + const USAGE = `Usage: ./scripts/increment-version.ts [options] Sets the root package.json and every publishable package to one new version, writes that @@ -130,12 +152,16 @@ function collectPackageFiles(): PackageFile[] { })); } -function bumpVersion(current: string, bump: Bump): string { - const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(current); +function parseVersion(version: string): readonly [number, number, number] { + const parsed = release.parseVersion(version); if (!parsed) { - throw new Error(`Expected a version such as 2.3.0 in package.json, got '${current}'`); + throw new Error(`Expected a version such as 2.3.0 in package.json, got '${version}'`); } - const [major, minor, patch] = parsed.slice(1).map(Number) as [number, number, number]; + return parsed; +} + +function bumpVersion(current: string, bump: Bump): string { + const [major, minor, patch] = parseVersion(current); switch (bump) { case 'major': return `${major + 1}.0.0`; @@ -168,11 +194,40 @@ function recommendBump(): Bump { return recommended; } -function renderMenu(currentVersion: string, recommended: Bump): void { +// A breaking commit makes the next release from main a major. A superseded major, released from its +// release/ branch, takes fixes only (docs/en/5-reference/5.10-versioning.md), so its next +// version is a patch and a breaking or feat commit in range is refused outright. +function allowBumps( + currentVersion: string, + recommended: Bump, + requested: Bump | null +): { base: string; bumps: readonly Bump[] } { + const [major] = parseVersion(currentVersion); + const restrict = (base: string, bump: Bump, rule: string) => { + if (requested && requested !== bump) { + throw new Error(`Refusing --bump ${requested}: ${rule}`); + } + return { base, bumps: [bump] }; + }; + const tags = run('git', ['tag', '--list', 'v*'], { stderr: 'inherit', stdout: 'capture' }); + if (release.isSupersededMajor(major, tags.split('\n').filter(Boolean))) { + if (recommended !== 'patch') { + const commit = recommended === 'major' ? 'breaking' : 'feat'; + throw new Error(`A ${commit} commit is in range, but ${major}.x is a superseded major, which takes fixes only`); + } + return restrict(`release/${major}`, 'patch', `${major}.x is a superseded major, which takes fixes only`); + } + if (recommended === 'major') { + return restrict('main', 'major', 'a breaking commit is in range, so the bump must be major'); + } + return { base: 'main', bumps: BUMPS }; +} + +function renderMenu(currentVersion: string, bumps: readonly Bump[], recommended: Bump): void { print(); print(styleText('bold', 'Select a version bump')); print(); - BUMPS.forEach((bump, index) => { + bumps.forEach((bump, index) => { const isRecommended = bump === recommended; const marker = isRecommended ? styleText('green', '❯') : ' '; const suffix = isRecommended ? styleText('green', ' recommended') : ''; @@ -180,7 +235,7 @@ function renderMenu(currentVersion: string, recommended: Bump): void { const version = styleText('cyan', bumpVersion(currentVersion, bump).padEnd(9)); print(`${marker} ${index + 1} ${bump.padEnd(7)}${version}${suffix}`); }); - print(` ${BUMPS.length + 1} quit`); + print(` ${bumps.length + 1} quit`); print(); } @@ -211,10 +266,15 @@ function createPrompt(): Prompt { }; } -async function askBump(prompt: Prompt, currentVersion: string, recommended: Bump): Promise { - renderMenu(currentVersion, recommended); - const choices: ('quit' | Bump)[] = [...BUMPS, 'quit']; - const defaultChoice = String(BUMPS.indexOf(recommended) + 1); +async function askBump( + prompt: Prompt, + currentVersion: string, + bumps: readonly Bump[], + recommended: Bump +): Promise { + renderMenu(currentVersion, bumps, recommended); + const choices: ('quit' | Bump)[] = [...bumps, 'quit']; + const defaultChoice = String(bumps.indexOf(recommended) + 1); for (;;) { const input = await prompt.ask(`Selection [${defaultChoice}]: `); if (input === null) { @@ -236,6 +296,72 @@ async function askConfirmation(prompt: Prompt, question: string): Promise cell.trim()); +} + +// The bump month stands in for the release month: the release ships when the bump's PR merges. +async function editSupportTable(previousMajor: number, newMajor: number): Promise { + const absolutePath = path.join(projectRoot, SUPPORT_POLICY_FILE); + const contents = fs.readFileSync(absolutePath, 'utf8'); + const start = contents.indexOf(SUPPORT_TABLE_START); + const end = contents.indexOf(SUPPORT_TABLE_END); + if (start === -1 || end < start) { + throw new Error(`No table between ${SUPPORT_TABLE_START} and ${SUPPORT_TABLE_END} in ${SUPPORT_POLICY_FILE}`); + } + const [header, separator, ...rows] = contents + .slice(start + SUPPORT_TABLE_START.length, end) + .trim() + .split('\n'); + const index = rows.findIndex((row) => cellsOf(row)[0] === String(previousMajor)); + const previousRow = rows[index]; + const previousReleased = previousRow && cellsOf(previousRow)[1]; + if (!header || !separator || !previousReleased) { + throw new Error(`The support table in ${SUPPORT_POLICY_FILE} has no row for ${previousMajor}`); + } + const now = new Date(); + const released = formatMonth(now.getFullYear(), now.getMonth()); + const supportedUntil = formatMonth(now.getFullYear(), now.getMonth() + SUPPORT_WINDOW_MONTHS); + rows[index] = `| ${previousMajor} | ${previousReleased} | ${released} | ${supportedUntil} |`; + const table = [header, separator, `| ${newMajor} | ${released} | — | — |`, ...rows].join('\n'); + const updated = `${contents.slice(0, start + SUPPORT_TABLE_START.length)}\n\n${table}\n\n${contents.slice(end)}`; + return { + contents: await format(updated, { filepath: absolutePath }), + relativePath: SUPPORT_POLICY_FILE, + summary: `${previousMajor}.x supported until ${supportedUntil}` + }; +} + +// Computed before any version field is written, so a missing RELEASE_CHANNEL line or table row +// fails the run with the tree untouched rather than half-bumped. +async function editsForNewMajor(currentVersion: string, newVersion: string): Promise { + const [previousMajor] = parseVersion(currentVersion); + const [newMajor] = parseVersion(newVersion); + return [editReleaseChannel(newMajor), await editSupportTable(previousMajor, newMajor)]; +} + function commitRelease(files: string[], version: string): void { // Explicit pathspecs, so anything else already staged stays staged rather than riding along. const unchanged = files.filter((file) => { @@ -290,9 +416,11 @@ function requireTty(flag: string): void { // Both answers can arrive as flags, which is what makes a run without a terminal possible: --commit // answers the commit prompt, --yes answers the rest with their defaults, and the commit default is no. async function planRelease(options: Options, currentVersion: string): Promise { + const recommended = recommendBump(); + const { base, bumps } = allowBumps(currentVersion, recommended, options.bump); const prompt = createPrompt(); try { - const bump = options.bump ?? (await askBump(prompt, currentVersion, recommendBump())); + const bump = options.bump ?? (await askBump(prompt, currentVersion, bumps, recommended)); if (!bump) { return null; } @@ -306,7 +434,7 @@ async function planRelease(options: Options, currentVersion: string): Promise { print(styleText('dim', 'Aborted.')); return; } - const { commit, newVersion } = plan; + const { base, bump, commit, newVersion } = plan; + const majorEdits = bump === 'major' ? await editsForNewMajor(currentVersion, newVersion) : []; print(); for (const file of packageFiles) { @@ -364,14 +493,21 @@ async function main(argv: string[]): Promise { print(); print(`${styleText('green', '✓')} All ${packageFiles.length} packages report ${styleText('cyan', newVersion)}`); + for (const edit of majorEdits) { + fs.writeFileSync(path.join(projectRoot, edit.relativePath), edit.contents); + print(`${styleText('green', '✓')} ${edit.relativePath} ${styleText('dim', `→ ${edit.summary}`)}`); + } + if (commit) { - commitRelease([...packageFiles.map((file) => file.relativePath), ...CHANGELOG_FILES], newVersion); + const majorFiles = majorEdits.map((edit) => edit.relativePath); + commitRelease([...packageFiles.map((file) => file.relativePath), ...CHANGELOG_FILES, ...majorFiles], newVersion); print(`${styleText('green', '✓')} Committed as ${styleText('bold', `chore: release v${newVersion}`)}`); print(); - print(styleText('dim', 'Next: review the commit, then open a pull request against main.')); + print(styleText('dim', `Next: review the commit, then open a pull request against ${base}.`)); } else { print(); print(`Next: review CHANGELOG.md, then commit as: ${styleText('bold', `chore: release v${newVersion}`)}`); + print(styleText('dim', `Then open a pull request against ${base}.`)); print(styleText('dim', 'Pass --commit to have this script make that commit for you.')); } print(); 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..dac228108 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,6 @@ export default mergeConfig( '**/dist/**', '**/node_modules/**', '**/public/**', - '**/scripts/**', '**/*.d.?(c|m)ts', '**/*{.,-}{test,test-d,spec}.?(c|m)[jt]s?(x)', '**/*.config.?(c|m)[jt]s?(x)', @@ -33,7 +32,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 +44,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' + ] } }) );