|
| 1 | +# Cut a release |
| 2 | + |
| 3 | +A release is one version bump merged to `main`. Everything after the merge is automatic: |
| 4 | +`.github/workflows/release.yaml` builds and pushes the container images, publishes the npm packages, and |
| 5 | +creates the GitHub release. Which packages are publishable is in `.agents/docs/workspace-map.md`; what CI |
| 6 | +gates is in `.agents/docs/architecture/testing-strategy.md`; the `RELEASE_VERSION` build-arg contract is in |
| 7 | +`packages/release-info/AGENTS.md`. This file is only the order of operations. |
| 8 | + |
| 9 | +**Plan around this:** the root `package.json` version _is_ the release identity — |
| 10 | +`.github/scripts/release.cjs` reads it and it becomes both the container tag and the GitHub tag |
| 11 | +`v${version}` — but every npm package publishes at **its own** `package.json` version, and the publish |
| 12 | +step skips any version already on the registry (`release.yaml:132-139`): |
| 13 | + |
| 14 | +``` |
| 15 | +Skipping @opendatacapture/runtime-v1@<version> (already published) |
| 16 | +``` |
| 17 | + |
| 18 | +So a bump that touches only the root leaves the packages behind and `publish-npm` reports **success while |
| 19 | +publishing nothing**. `scripts/increment-version.sh` rewrites the root plus every path |
| 20 | +`scripts/list-publishable.sh` returns in one run, and it is the only thing holding those files in |
| 21 | +agreement — nothing in CI compares them, and hand edits have moved the root alone before. |
| 22 | + |
| 23 | +## Steps |
| 24 | + |
| 25 | +1. **Get the branch green before you bump.** `pnpm lint`, `pnpm test` and `pnpm test:e2e` from the repo |
| 26 | + root — nothing after the PR runs a test against this code |
| 27 | + (`.agents/docs/architecture/testing-strategy.md` has the gating). All three need `.env`; root |
| 28 | + `pnpm test` is bare vitest with no turbo `^build` behind it, so `pnpm build` must have run once |
| 29 | + (`.agents/docs/playbooks/run-locally.md`), and the e2e browsers are in |
| 30 | + `.agents/docs/playbooks/add-e2e-test.md`. `pnpm lint` is `tsc && eslint --fix src` per package and |
| 31 | + rewrites files as it checks them: read `git status` after it and land any churn as its own commit, |
| 32 | + before the bump. |
| 33 | + |
| 34 | +2. **Bump from a branch that already contains `main`.** `git fetch origin && git merge origin/main`. |
| 35 | + `increment-version.sh` derives the next version from the root `package.json` in your working tree |
| 36 | + alone, so a branch trailing `main` computes a version that is already released — the run then re-pushes |
| 37 | + the same image tags, updates the same GitHub release, and prints `Skipping` for every package. Done |
| 38 | + when `git merge-base --is-ancestor origin/main HEAD` exits 0. |
| 39 | + |
| 40 | +3. **Run `./scripts/increment-version.sh` from the repo root.** There is no `pnpm` script for it — invoke |
| 41 | + the path; prerequisites for anything under `scripts/` are in `.agents/docs/playbooks/run-locally.md`. |
| 42 | + Its `select` prompt offers `major`/`minor`/`patch`/`quit`, then a `y/N` confirmation, then it rewrites |
| 43 | + the root `package.json` plus every path `scripts/list-publishable.sh` returns. Done when its output |
| 44 | + carries one `Updated …` line per file and ends `Done! All packages set to <version>`. |
| 45 | + |
| 46 | +4. **Confirm the lockstep before you commit.** |
| 47 | + |
| 48 | + ```sh |
| 49 | + node -p "require('./package.json').version" && scripts/list-publishable.sh |
| 50 | + ``` |
| 51 | + |
| 52 | + `list-publishable.sh` never prints the root, so a uniform version column proves nothing on its own — |
| 53 | + that is exactly what a root-only bump looks like. Done when the root version on the first line equals |
| 54 | + the second tab-separated field of every row below it. No check enforces that equality. |
| 55 | + |
| 56 | +5. **Commit the version files in one commit and open the PR with `main` as its base.** `ci.yaml` fires on |
| 57 | + `pull_request` to `main` and on `workflow_dispatch`, never on a push, so a PR based on `dev` or any |
| 58 | + other branch runs no lint, no unit tests and no e2e — silently. In-repo work branches on origin and |
| 59 | + merges into `main`; `CONTRIBUTING.md` describes a fork path, which addresses outside contributors. |
| 60 | + There is no changeset and no changelog file: the generated GitHub release is the whole record. |
| 61 | + |
| 62 | +6. **Land one release at a time.** The workflow's concurrency group is per-workflow-per-ref with |
| 63 | + `cancel-in-progress: true` (`release.yaml:9-11`), so a second merge cancels the release in flight. A |
| 64 | + `build` that is cancelled or fails leaves `publish-npm` and `release` reported as **skipped** rather |
| 65 | + than failed — `.agents/skills/odc-release/SKILL.md` reads that job tree; runs `30389796062` and |
| 66 | + `30378779338` show that tree. |
| 67 | + |
| 68 | +7. **Watch the run:** `gh run watch`, or `gh run list --workflow=Release --limit 1` for its id. |
| 69 | + |
| 70 | + | Job | What it does | Skips when | |
| 71 | + | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | |
| 72 | + | `configure` | derives the build matrix from `docker compose config` through the jq filter described below; runs `release.cjs` for `version` and `should_release` | never | |
| 73 | + | `validate` | `pnpm lint` | never | |
| 74 | + | `build` | buildx `linux/amd64,linux/arm64` per matrix leg, pushing `latest` and the bare version, with `RELEASE_VERSION` as a build arg | `should_release != 'true'` | |
| 75 | + | `publish-npm` | turbo-builds each publishable package and its closure, then publishes each version not already on npm, over OIDC (no `NPM_TOKEN`) | `should_release != 'true'` | |
| 76 | + | `release` | creates the GitHub release tagged `v${version}` | any of its `needs` skipped or failed | |
| 77 | + |
| 78 | + **No playground image ships.** The filter keeps only compose services declaring **both** `build` and |
| 79 | + `image`, and `playground` declares no `image:` key. `scripts/publish.sh` is not the way to add it back: |
| 80 | + it `docker push`es the local `:latest` tag of all four images, builds nothing and pushes no version |
| 81 | + tag, so it replaces the three CI-published `latest` tags with whatever is in your daemon. It is wired |
| 82 | + to no workflow. |
| 83 | + |
| 84 | + **The `v` belongs to GitHub only.** Image tags are pushed bare (`type=raw,value=${version}`); the |
| 85 | + leading `v` appears on the GitHub tag alone, and `RELEASE_VERSION` cannot carry one |
| 86 | + (`packages/release-info/AGENTS.md`). |
| 87 | + |
| 88 | +8. **Expect a full build even when nothing changed.** `release.cjs` matches GHCR tags against |
| 89 | + `/^v(\d+\.\d+\.\d+(-(alpha|beta)\.\d+)?)$/` while `build` pushes unprefixed tags, so |
| 90 | + `extractPackageVersionTag` returns `null` and `should_release` is `'true'` on every push to `main`. |
| 91 | + Pushing without a bump re-pushes the same image tags and updates the existing GitHub release in place |
| 92 | + rather than skipping. Report that as a finding; a release is not the moment to change the release |
| 93 | + script. |
| 94 | + |
| 95 | +9. **Confirm all three artifacts carry the new version** — the images, the npm packages, the GitHub |
| 96 | + release. Done when the `## Verify` block below is clean for each. |
| 97 | + |
| 98 | +10. **If npm did not move, cut another patch.** A `Skipping` line for every package means the bump |
| 99 | + bypassed `scripts/increment-version.sh`. Run the script properly, commit, merge again — re-running a |
| 100 | + version the registry already has publishes nothing, so the fix is always forward. |
| 101 | + |
| 102 | +## Verify |
| 103 | + |
| 104 | +```sh |
| 105 | +node -p "require('./package.json').version" && scripts/list-publishable.sh # root first, then that same version on every row |
| 106 | +gh run list --workflow=Release --limit 1 # the run for your merge commit |
| 107 | +gh run view <run-id> --json jobs -q '.jobs[]|"\(.name)\t\(.conclusion)"' # every build leg, publish-npm and release: success |
| 108 | +gh run view <run-id> --log | grep -E 'Publishing|Skipping' |
| 109 | +npm view @opendatacapture/runtime-v1 version # the version you just cut |
| 110 | +gh release view v<version> # exists, tagged with the leading v |
| 111 | +``` |
| 112 | + |
| 113 | +`Publishing <name>@<version>` for every package is the first run after a bump. `Skipping <name>@<version> |
| 114 | +(already published)` is the **correct** output when re-running a release that already published — that |
| 115 | +version guard is what makes a re-run safe — and is a defect only on the first run after a bump. |
| 116 | + |
| 117 | +The images have no read-only command of their own: |
| 118 | +`gh api /orgs/DouglasNeuroinformatics/packages/container/open-data-capture-api/versions` answers |
| 119 | +`403 … read:packages scope` on a token without that scope, which is the token and not a missing image. The |
| 120 | +`build` conclusions in the job list above are the check. |
| 121 | + |
| 122 | +Independent of any one release: each app Dockerfile installs its own global `turbo@<version>` for the |
| 123 | +image build, separate from the root `turbo` devDependency — move all four pins together so the images |
| 124 | +build on one turbo. |
0 commit comments