CPN Release Patch (tag → hotfix branch)
Backport the gap between two release tags onto a hotfix/<milestone> branch so
release-please opens the patch release PR. The authoritative backport set is the
target patch milestone's own merged PRs — NOT a BASE_TAG..main diff. The
commits are duplicated onto BASE_TAG with jj, and the branch is pushed to
origin.
The repo is jj-backed (.jj/ present; never git commit). Tags are
lightweight git tags exported via jj colocation; release-please consumes the
branch name, not a tag — see Verification.
Available scripts
Run from the skill directory; each validates its args and exits non-zero with a message on bad input.
scripts/next-milestone.sh BASE_TAG→ next patch milestone (v9.24.4→9.24.5).scripts/milestone-number.sh REPO MILESTONE_TITLE→ milestone number, or exit 1 when absent/closed.scripts/fetch-backport-set.sh REPO MILE_NUM [OUTFILE]→ ordered merge-commit SHAs of the milestone's merged PRs (default/tmp/cpn_ms_ids.txt).scripts/verify-backport.sh BASE_TAG TIP EXPECTED_COUNT→ count / conflict / tree-parity check, exit 0 when clean.
When to Use
"Backport v9.24.4 to v9.24.5", "duplicate the diff of main onto the v9.24.4 tag
as a hotfix", "make a patch hotfix branch from v9.24.4", or any request to take
the commits between a tag and main and land them on a hotfix/<x.y.z>
branch for release-please to cut.
Inputs
BASE_TAG(required): a published tag, e.g.v9.24.4. The patch milestone is derived from it (see Step 1), the hotfix branch is named after that milestone, and the duplicated commits are placed on top ofBASE_TAG.
Always confirm BASE_TAG with the user if ambiguous; never assume main is the
base.
Procedure
0. Preconditions (verify, block if unmet)
jj status # .jj/ present, no surprise working-copy churn
gh api repos/cloud-pi-native/console --jq .viewerPermission # need write/admin
git rev-parse -q --verify BASE_TAG # tag must exist; replace BASE_TAG
jj git fetch # sync remote tags + bookmarks
If BASE_TAG is missing or you lack write: report BLOCKED: <requirement> — <evidence> — <recovery>, do not proceed.
1. Resolve the patch milestone from the base tag
v9.24.4 → next patch milestone is 9.24.5. Derive mechanically, do not
guess:
NEXT=$(bash scripts/next-milestone.sh "$BASE_TAG")
MILE_NUM=$(bash scripts/milestone-number.sh cloud-pi-native/console "$NEXT")
If the milestone is absent or already closed, the script exits non-zero — surface it before pushing; a duplicate patch branch would collide with an already-cut release.
2. Get the EXACT backport set from the milestone (authoritative)
The milestone's merged PRs ARE the backport set. Do not derive it from a
BASE_TAG..main patch-id diff — that over-counts, because main carries the
next minor's dev commits (9.25.0 prerelease work) whose patch-ids are not on
the tag either, so they leak in. For v9.24.4 the milestone had 16 commits;
a v9.24.4..main patch-id diff returned 35 (16 in-milestone + 19 from
9.25.0 dev). The milestone is the precise source of truth.
bash scripts/fetch-backport-set.sh cloud-pi-native/console "$MILE_NUM"
wc -l /tmp/cpn_ms_ids.txt # expect the milestone size (16 for v9.24.5)
/tmp/cpn_ms_ids.txt is the ordered (oldest→newest) list of the exact commits
to duplicate. Order matters: jj duplicate replays them in argument order, and
each must parent onto the previous so the chain re-roots cleanly on the tag.
Optional sanity check (not the selector): confirm none are already an ancestor
of BASE_TAG — a hit makes jj duplicate produce a harmless no-op commit:
while read c; do git merge-base --is-ancestor "$c" BASE_TAG && \
echo "already-on-tag: $c"; done < /tmp/cpn_ms_ids.txt
3. Rebuild the chain directly on the tag (NO empty scaffold)
Critical: do not jj new BASE_TAG -m "...". That creates an empty commit
as a child of the tag; when you duplicate --onto @ it stays an ancestor of the
tip, shows up in BASE_TAG..hotfix/$NEXT, and blocks the push ("Won't push
commit … has no description"). Point the working copy at the tag itself so the
duplicated chain's root parent is the tag, with no scaffold:
jj goto BASE_TAG # @ becomes the tag commit (no new empty commit)
IDS=$(tr '\n' ' ' < /tmp/cpn_ms_ids.txt)
jj duplicate $IDS --onto @ # duplicated commits are children of @ (= BASE_TAG)
TIP=$(jj log -r 'heads(@)' --no-graph -T commit_id | head -1) # newest duplicate
heads(@) after the duplicate is exactly the tip — no empty commit to drop.
If you already used jj new BASE_TAG -m, recover by rebasing the chain root
onto the tag and abandoning the empty commit:
ROOT=$(jj log -r '(<empty_commit_id>::)' --no-graph -T 'commit_id' | tail -1)
jj rebase -r "$ROOT" -d BASE_TAG
jj abandon <empty_commit_id>
4. Verify the backport is complete and clean
bash scripts/verify-backport.sh "$BASE_TAG" "$TIP" \
"$(wc -l < /tmp/cpn_ms_ids.txt)"
The script checks exact commit count (milestone size, no scaffold), zero
conflict markers in BASE_TAG..$TIP, and a tree that reconstructs
main's source (release-please files may differ). Also compare chain subjects
against the milestone PR titles — zero extras (no 9.25.0 commits).
5. Create the hotfix bookmark and push
Path A — jj duplicate (preferred):
jj bookmark set hotfix/$NEXT -r "$TIP"
jj git push --bookmark hotfix/$NEXT --remote origin
Path B — jj colocation escape hatch (when jj duplicate fails): see
references/jj-colocation-escape-hatch.md for jj-native recovery and a
last-resort git cherry-pick fallback.
If the push fails with unexpectedly moved on the remote / stale info, clear
the stale remote-tracking bookmark, re-point, and push:
jj git fetch # clears stale remote-tracking
jj bookmark set hotfix/$NEXT -r "$TIP" --allow-backwards # resolves any conflict
jj git push --bookmark hotfix/$NEXT --remote origin
hotfix/$NEXT is the branch release-please watches (see Verification). Do not
create a v$NEXT tag — release-please cuts it from the branch.
6. Report
Return the pushed branch (hotfix/$NEXT), the duplicate commit count, and the
commit range (BASE_TAG..hotfix/$NEXT). Tell the user release-please will open
a chore: Release v$NEXT PR against hotfix/$NEXT with always-bump-patch.
Verification (release-please mechanics — why this works)
release-please-config.json:release-type: node, single package.=console; next version comes from.release-please-manifest.json(currently9.24.4)..github/workflows/job-release-please.yml: on ahotfix/*branch it usesversioning-strategy: always-bump-patch, so the manifest9.24.4becomes9.24.5— the milestoneNEXTderived in Step 1. The branch namehotfix/<x.y.z>is the only trigger; no tag needed.- Therefore: name the branch
hotfix/$NEXT, push it, let release-please open the release PR. Do not hand-cutv$NEXT.
Pitfalls
- The backport set is the MILESTONE, not
BASE_TAG..main. Av9.24.4..mainpatch-id diff returns 35 commits forv9.24.4: 16 in the9.24.5milestone + 19 from9.25.0dev work whose patch-ids are also absent from the tag. Duplicating all 35 leaks9.25.0features into the hotfix. Always source the set from the milestone's merged PRs (Step 2). - The issues API hides merge_commit_sha.
repos/R/issues?milestone=Nreturnspull_request.merge_commit_sha: null; only the pulls endpoint (repos/R/pulls/<n>) exposes the real SHA.fetch-backport-set.shfetches PR numbers from issues, then SHAs from pulls — never writenullSHAs into the duplicate list. - NEVER
jj new BASE_TAG -m "..."as the duplicate base. It creates an empty commit that becomes a permanent ancestor of the tip, shows up inBASE_TAG..hotfix/$NEXT, and blocksjj git push("Won't push commit … has no description"). Usejj goto BASE_TAG(Step 3) so the chain roots directly on the tag with no scaffold. Recovery:jj rebase -r <chain_root> -d BASE_TAG && jj abandon <empty_commit_id>. jj duplicate --onto @fails with colocation state (e.g. a priorjj bookmark setmoved the internal ref, leaving jj's working copy on a stale scaffold). Tryjj abandon @,jj goto BASE_TAG, thenjj duplicate --onto @. If jj colocation is irrecoverable, fall back togit cherry-pick(seereferences/jj-colocation-escape-hatch.md); sync jj withjj git fetchthenjj bookmark set hotfix/$NEXTafter the git push — neverjj bookmark setbefore it, or the git ref points at the old (possibly empty) commit.- Tip revset: after
duplicate --onto @, bookmarkheads(@), never a manually guessed commit. - Stale remote-tracking blocks push: after any prior force-push of
hotfix/$NEXT, local jj believes the remote is at the old SHA.jj git fetchclears it; thenjj bookmark set … --allow-backwards+ push. A..Bin jj = set difference, not git's "exclusive range with merge base".BASE_TAG..mainnames the commits, but content overlap / next-minor leakage makes it wrong as a backport source — hence the milestone in Step 2.- Tag is not an ancestor of main: expected for CPN release tags (they carry
hotfix-only commits). Do not try to branch from
main; branch from the tag. - Verify by tree, not count: after duplicate,
git diff --name-only <tip> mainshould show onlypackage.json/CHANGELOG.md/.release-please-manifest.json. Any other differing file means a milestone commit was missed or mis-ordered — re-run Step 2, do not push. - No
jj git tagin 0.43: tags are git objects synced through colocation. Create branches withjj bookmark create/set, not tags. jj op undodoes not exist in this jj version — to roll back a dry run or a bad duplicate,jj abandon '<revset>'orjj op restore --to <op-id>(jj op logto find it). Restoring to the pre-duplicate checkpoint and rebuilding from Step 3 is the safe reset path.- Conflict during duplicate: rare with the milestone set (commits are
independent fixes). If one appears, resolve in the working copy,
jj squash, continue. Do not re-scope the set. - Branch already exists on origin (
hotfix/$NEXT): the patch was already started.jj git fetch, rebase your duplicate onto the existing bookmark, and push — do not force a second branch.
See also
cpn-dev-workflow— console contribution workflow, jj conventions, PR rules.cpn-pr— open the release PR if release-please does not auto-open.cpn-commit— commit message shape (conventional, SSH-signed).references/jj-colocation-escape-hatch.md— jj-native recovery and a last-resortgit cherry-pickescape hatch whenjj duplicatefails.