Release Patch — back-port a fix to an existing release line
Cut a patch release on an existing release line (a shipped x.y.z a consumer still depends on)
by back-porting one or more already-merged fixes, without dragging in later main-line changes.
Use this when, e.g., an older SDK release pins @qvac/<pkg> ^0.36.3 and needs a fix that only landed
on main/a newer minor. The output is a new patch (e.g. 0.36.4) published under the line's
maintenance dist-tag — latest is left pointing at the newest version.
<package> is the directory name under packages/ (e.g. llm-llamacpp). <fix-PR-or-commit> is one
or more merged fix PRs (or their squash-merge SHAs). [base-release-branch] is the release line to
patch (e.g. release-llm-0.36.3); if omitted, discover it in Step 1.
Golden rule
Never commit or git push directly onto a release-* branch. The release-* ruleset requires all
changes to arrive via a merged PR (a bare branch creation push is allowed; subsequent commits are
rejected — GH013 … Changes must be made through a pull request). So: create the release branch as an
exact copy of the base, push it once, then land every change (fix cherry-picks, version bump) via
PRs whose base is that release branch.
Workflow
Step 1 — Identify the target release line + next version
- Determine the version the consumer needs — read its dependency spec, e.g. the SDK's
packages/sdk/package.json:"@qvac/<pkg>": "^0.36.3"→ the0.36line, and the patch must be>= 0.36.3 < 0.37.0. - Find the actual release-line ref. ⚠ Naming is not uniform. The
releaseskill's default isrelease-<package>-<version>/ tag<package>-v<version>, but real patch lines have used a short prefix: branchrelease-llm-<version>+ tagllamacpp-llm-v<version>. Do not assume — verify:
Pick the branch/tag that actually holds the target version (confirm withgit ls-remote origin 'release-*' | grep -i <pkg> # candidate branches git ls-remote --tags origin | grep -iE '<pkg>|llm' # candidate tagsgit show <ref>:packages/<pkg>/package.json | grep '"version"'). That commit is the base. - Next patch version = base patch + 1 (must satisfy the consumer range and stay below the next
minor). Record it as
<newver>and the base branch as<base>.
Step 2 — Resolve the source commits (use the SQUASH-merge SHAs on main)
For each fix PR, take the squash-merge commit on main, not the PR's individual branch commits:
gh pr view <N> --repo tetherto/qvac --json number,title,mergeCommit --jq '{n:.number,title,sha:.mergeCommit.oid}'
Do not cherry-pick a version/changelog-only PR (e.g. a prior x.y.z bump) — that bump is redone
for this line in Step 6.
Step 3 — Assess cherry-pick cleanliness BEFORE touching anything
For each source commit, diff its touched files between the base and the commit's parent:
git diff --numstat <base> <squash-sha>~1 -- <touched-paths>
Empty output ⇒ that file is identical at the base ⇒ the hunk applies cleanly. Non-empty ⇒ that file diverged ⇒ expect a conflict there (typically test files). If the core source files are identical, the back-port is safe; a few diverged test files are resolved by grafting in Step 5. If everything is heavily diverged, stop and reconsider (the fix may need manual porting, not a cherry-pick).
Step 4 — Create the release branch from the base — NO changes — and push it
git -C <repo> fetch origin <base>
git -C <repo> worktree add <wt> origin/<base>
git -C <wt> checkout -b release-<pkg>-<newver> # exact copy of the base, no edits
git -C <wt> config user.email "<you>"; git -C <wt> config user.name "<you>"
git -C <wt> push origin release-<pkg>-<newver> # branch-creation push (allowed)
Commit identity = the releaser; never add an AI signature / Co-Authored-By. Confirm HEAD == base
commit. Do not bump the version here — leave it at the base value (publish-safe).
Step 5 — PR #1: the fix(es), into the new release branch
Work on a normal-named branch (not release-*, so it isn't push-protected):
git -C <wt> checkout -b <TICKET>/backport-<pkg>-<newver>
git -C <wt> cherry-pick <squash-sha>...
Resolve only the expected diverged-file conflicts by grafting the commit's added block into the base file (keep the base file's structure; do not reformat unrelated lines). All identical-at-base files apply automatically.
Validate completeness (that the cherry-pick captured the source, nothing missed):
- Cleanly-applied files — resulting blob byte-identical to the source commit's post-image:
git rev-parse <squash-sha>:<file>==git rev-parse HEAD:<file>for each. - Manually-resolved files — the added lines equal the commit's added lines
(
git show <squash-sha> -- <file>+-lines vsgit diff <base> HEAD -- <file>+-lines).
Push the helper branch; open PR #1 with base = release-<pkg>-<newver> (four-emoji body; link the
source PR(s)).
CI: add the matrix labels — prebuilds, run-cpp-addon-tests, run-desktop-addon-tests,
run-mobile-addon-tests — to run the fix-validating jobs. Do not add
verified: ci-router reads only those four names, so verified selects no stage and fails
silently, costing a CI round. (The label still exists and its siblings' descriptions still read
"requires verified" — that text is stale; the label gate was retired, and
ci-trust-policy.test.mjs asserts it stays retired.) Auto-approve the release
environment deployment when it appears. Always read failures from full logs via
gh api repos/tetherto/qvac/actions/jobs/{id}/logs — gh run view --job --log silently truncates
(~1.1 MB of ~9 MB) and can fake a "hang". Triage flaky-vs-real; pre-existing environment failures
(e.g. test-darwin-x64 macOS-x64-VM timeouts) are non-gating and not blockers — confirm the fix's own
tests pass. Merge PR #1 into the release branch.
Step 6 — PR #2: version + CHANGELOG bump, into the release branch
On another helper branch off the (now fix-carrying) release branch:
packages/<pkg>/package.json: base version →<newver>.packages/<pkg>/CHANGELOG.md: add## [<newver>] - <YYYY-MM-DD>(bracketed heading — the release workflow requires this format) above the base entry; reuse the original fix's changelog wording, crediting the fix PR(s).package.jsonversion MUST equal the new heading.- Keep the version at the base value until this PR so nothing publishes early.
- Lint gate varies by line era — run
npm run lintinpackages/<pkg>(older release lines usestandard, newer useprettier); match whatever that branch enforces.
Open PR #2 (base = release-<pkg>-<newver>).
Step 7 — Release (merging PR #2 auto-triggers publish; use the release skill to verify)
Merging the bump PR into the release-* branch is a trusted push → on-merge-<pkg>.yml runs and
publishes automatically. No labels are needed on a merge/push: push is a trusted event in
ci-router, which short-circuits label parsing and enables every stage on its own. Publication is
gated by version>npm + release-merge-guard. Follow the release skill for the monitor/verify mechanics (its Steps 4–6),
with these nuances:
release-skill latest-guard caveat. Thereleaseskill's Step 1 compares the local version to npmlatestand stops if not higher. A back-port (e.g.0.36.4<latest0.38.0) fails that check — so the publish path for a maintenance patch is the on-merge auto-trigger from the PR merge, and you verify against the line's maintenance dist-tag, notlatest. Use thereleaseskill for its monitor/verify steps; don't let the latest-guard block a legitimate back-port.- dist-tag. The
npm-dist-tag-determinationaction publishes an older-than-latestpatch under a maintenance tag (e.g.release-<major.minor>) and must NOT movelatest. This is correct — consumer semver ranges (^0.36.3) resolve by version, so they still pick up the new patch. create-tagjob fails (HTTP 403/422). It usessecrets.GITHUB_TOKEN, which is not authorized to create the protected release tag (no repo tag-ruleset; a user/PAT can). So the git tag is NOT created automatically — create it manually with a user/PAT credential:
(Longer-term fix: changegit tag <pkg-tag-prefix>-v<newver> <release-branch-sha> # e.g. llamacpp-llm-v0.36.4 git push origin <pkg-tag-prefix>-v<newver>create-release-tag.ymlto usesecrets.PAT_TOKEN, likelabel-gatedoes.)
Step 8 — Verify
npm view @qvac/<pkg>@<newver> version→ the new patch exists.npm view @qvac/<pkg> dist-tags→latestunchanged; the new patch under its maintenance tag.- The release git tag exists.
- The consumer's range now resolves to the new patch (
npm view @qvac/<pkg>@'<range>' version).
Nuances & gotchas (all hit for real; do not relearn them)
- PR-only changes to
release-*— the ruleset blocks direct commits after branch creation. Land everything via PRs whose base is the release branch (helper branches are normal-named). - Cherry-pick the squash-merge SHA, not PR-branch commits.
gh run view --logtruncates — usegh api …/actions/jobs/{id}/logs; sanity-check job duration vs timeout before believing a "hang". (Seereference_gh_job_log_truncation.)- Merging into
release-*auto-publishes (on-merge, trusted push) — the version bump is the point-of-no-return; keep version at base until then. - Back-port dist-tag goes to a maintenance tag, never clobbering
latest. create-tag403/422 → create the tag manually (GITHUB_TOKEN can't; PAT/user can).release-skill Step 1 latest-guard doesn't fit back-ports — verify against the maintenance tag.- No AI signatures /
Co-Authored-Byin commits or PRs. - Lint gate era —
standardon older release lines,prettieron newer; runnpm run lint.
Worked example — @qvac/llm-llamacpp 0.36.4 (QVAC-22472)
SDK 0.15 pinned ^0.36.3; the n_predict-inside-reasoning fix was only on main/0.37+.
- Base: branch
release-llm-0.36.3= tagllamacpp-llm-v0.36.3(84be415c, v0.36.3). - Source: PR #3318 squash
0d782b12c(the fix). PR #3327 (0.37.1 bump) NOT cherry-picked. - Cleanliness: all C++/unit files identical at base; only
qwen3-5.test.jsdiverged → grafted the one new test block; blob-identity confirmed for the rest. - Branch
release-llm-0.36.4created from base (no changes) → PR #3337 (fix) → PR #3342 (0.36.4 bump). - Merge auto-published: run 29749326488; npm
0.36.4under dist-tagrelease-0.36;lateststayed0.38.0.create-tagfailed 403/422 → tagllamacpp-llm-v0.36.4created manually via SSH.
Error handling
- Heavy cherry-pick conflicts across core source (not just tests) → the fix predates too much divergence; stop and port it manually or reconsider the base.
release-merge-guardfails on merge → version not bumped or CHANGELOG heading missing/mis-formatted.- Publish didn't run after merge → confirm the merge was a push to
release-*andpackages/<pkg>/**changed; checkon-merge-<pkg>.ymlruns. - Publish ran but
latestmoved to the patch → the dist-tag logic mis-fired; restore withnpm dist-tag add @qvac/<pkg>@<real-latest> latest.