This document is the single source of truth for J2KSwift's branching model and release process. Every release from v6.0.1 onward follows it. Updates to the process land via a PR that edits this file.
J2KSwift uses a trunk-based model anchored on main:
| branch | role | lifetime | who owns |
|---|---|---|---|
main |
Trunk. Always releasable. Tags are cut from here. | Permanent | Everyone (PR-only) |
feature/<scope>-<topic> |
New work (multi-tile, GPU, codec features, etc.) | Until merged to main |
Author |
fix/<scope>-<topic> |
Bug fixes | Until merged to main |
Author |
vX.Y.Z-release-candidate |
Release prep (notes + final review) | Until merged to main and tagged |
Releaser |
release/vX.Y.Z |
Post-release frozen mirror of the tag, used for hotfixes | Permanent (one per release) | Auto-created by release.yml |
hotfix/vX.Y.(Z+1) |
Patch on top of an existing release/vX.Y.Z |
Until tagged + cherry-picked back to main |
Releaser |
There is no develop branch. The stale develop reference in earlier CONTRIBUTING.md is removed in the same PR that lands this file.
- Single maintainer pattern works without an integration branch
release/vX.Y.Zmirrors the tag — it gives hotfixes a stable base without rewindingmain- PRs to
mainare the only way new work lands; the CI gate enforces that
J2KSwift follows SemVer 2.0. The project applies these rules concretely:
| change | bump | example |
|---|---|---|
| Public API removed, signature changed, or default behaviour flipped | MAJOR | v5.38.0 → v6.0.0 was tested as MAJOR-eligible because of .auto becoming production default |
| New public type / function / config option, default unchanged, codestream bytes byte-identical on default config | MINOR | v5.37 → v5.38 (M-series) added new test harnesses without changing defaults |
| Bug fix, perf improvement, doc-only change, CI fix | PATCH | this PR (release.yml fix) is patch-eligible |
Special rules for J2KSwift
- Codestream bytes are part of the public contract. A bytes-changing default flip is automatically MAJOR even if no Swift API changed.
- Opt-in flags do not count as default changes.
J2K_GPU_FORWARD_53=1shipping default-off in v6.0.0 is MINOR-eligible. The MAJOR bump came from.autoflipping default tile-mode policy. - Lossy changes are out of scope as of 2026-05-05 (see
feedback_lossless_only_v5_38.md). No version may ship behaviour that affects.lossless = falsecodestreams unless the scope is explicitly re-opened.
Every minor / major release should include performance work spanning both HTJ2K (Part-15 high-throughput) and general J2K (Part-1 / codestream / tier-2 / file format) where actionable. The codec library's product target is bit-exact lossless medical archive performance; sustained measurable wins are the user-visible value of each release. Planning a release that ships only one side (e.g., HTJ2K-only or codestream-only) is allowed when no actionable lever exists on the other side, but the release notes must say so explicitly.
Concretely, when planning a release:
- Open with the stage breakdown from the previous release's hot path (
EncodeStageProfileLosslessCorpusTestsfor the lossless 5/3 path; the equivalent decode breakdown when relevant). The biggest unaccelerated stage is the natural HTJ2K target. - Identify at least one non-HTJ2K J2K lever per release — sub-stage profiling of preprocess / colour / codestream marker writes / tier-2 algorithmic batch writes / decoder-side hot paths / etc. Even a "we measured this stage and it's not the lever" finding (cf. PRs #307, #308) counts as J2K perf work because it informs the next release's planning.
- A release with no measurable wall-time win (perf wash, like the v6-alpha6 entropy arc) is acceptable when the empirical data is the deliverable. Document the wash in the release notes; don't pretend a wash is a win.
Patch releases (hotfixes off release/vX.Y.(Z-1)) are exempt from this expectation — they ship targeted bug fixes only.
Every step below has a command. None of them are interactive.
git checkout main && git pull --ff-only
git status # working tree clean
git log --oneline v$(git describe --tags --abbrev=0)..HEAD | wc -l # count commits since last tagPer feedback_commit_gate.md. Required for every release. No exceptions.
swift test -c release \
--filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'
# Must show: Executed N tests, with 0 failuresIf any test fails, stop. Diagnose, fix on a fix/... branch, PR, merge, restart from step 1.
Mirror RELEASE_NOTES_v6.0.0.md structure. Mandatory sections:
- Summary (2-3 paragraphs, lead with the headline)
- What's New — production-default
- What's New — opt-in (if any)
- Backward compatibility (codestream byte equality vs prior tag)
- Cross-codec parity matrix — fresh measurement on the medical
corpus across at least three external decoders (OpenJPH 0.27.0,
Grok 20.3.0, Kakadu 8.4.1 demo); cell count and bit-exactness
must be in the release notes, not just cited from a companion
doc. Re-run
HTTileParityMatrixTests.testTileParityMatrixOnLargeFixturesHTGPUForward53CrossCodecTestsfor the data.
- Medical-corpus benchmarks — fresh wall-time table for
encode + decode across the 6 corpus fixtures (MR-small, CT,
MR 886², XA, PX, DX). Re-run
J2KMedicalCorpusEncodePerformanceTestsEncodeStageProfileLosslessCorpusTests(or equivalents) for the data; the release-mode median should be inline in the notes.
- Test Suite Results (gate + new validation suites + cell counts)
- API surface (additions only, no breaks)
- Known limitations
- Reproducing the headline numbers (commands)
- Companion documents (BENCHMARK_REPORT_*.md, etc.)
TAG=v6.0.1 # whatever you're releasing
git checkout -b ${TAG}-release-candidate
git add RELEASE_NOTES_${TAG}.md
git commit -m "release: ${TAG} — <one-line headline>"
git push -u origin ${TAG}-release-candidategh pr create --base main --head ${TAG}-release-candidate \
--title "release: ${TAG} — <one-line headline>" \
--body-file <(cat <<EOF
## Summary
<3 bullets — what's the headline?>
## Correctness gate (release mode)
<paste J2KMedicalCorpus*PerformanceTests + J2KStrictCrossCodecValidationTests pass-counts>
## Test plan
- [x] Mandatory pre-release gate
- [x] Cross-codec parity matrix vs OpenJPH/Grok/Kakadu
- [x] CrossVersionDeltaBenchmark vs previous tag (codestream MD5 equality where promised)
EOF
)PR must be reviewed (or self-reviewed if solo) and merged with a merge commit (preserves the per-feature commit history that release notes reference). Squash merges are forbidden for release PRs.
gh pr merge <number> --merge --delete-branch=falsegit checkout main && git pull --ff-only
git tag -a ${TAG} -m "${TAG}: <headline>
<one-paragraph summary copied from RELEASE_NOTES top-of-file>"
git push origin ${TAG}The tag push triggers release.yml. It:
- Creates the GitHub release using
RELEASE_NOTES_vX.Y.Z.mdas the body - Creates the
release/vX.Y.Zbranch from the tag
Watch it:
gh run list --workflow=release.yml --limit 1
gh run watch $(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')If the workflow fails, fall back to the manual publish path:
gh release create ${TAG} --title "${TAG} — <headline>" \
--notes-file RELEASE_NOTES_${TAG}.md
git push origin ${TAG}:refs/heads/release/${TAG} \
|| (git checkout -b release/${TAG} ${TAG} \
&& git push -u origin release/${TAG} \
&& git checkout main)(v6.0.0 used the manual fallback because the validate job was wedged on a sibling-package dep. Fixed in v6.0.1.)
For a defect that ships in vX.Y.Z and needs a patch release without picking up everything that's accumulated on main since:
TAG=v6.0.1 # the patch you're cutting
PRIOR=v6.0.0 # the release you're patching
# 1. Branch from the frozen release branch (NOT main).
git fetch origin
git checkout -b hotfix/${TAG} origin/release/${PRIOR}
# 2. Apply the fix. Keep the diff minimal — no opportunistic refactors.
$EDITOR <fix>
git commit -m "fix: <one-line description>"
# 3. Run the full mandatory gate.
swift test -c release \
--filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'
# 4. Author RELEASE_NOTES_${TAG}.md (short — one section: "Fixed").
git add RELEASE_NOTES_${TAG}.md && git commit -m "release: ${TAG} — <headline>"
# 5. Push the hotfix branch and PR into release/${PRIOR} (NOT main).
git push -u origin hotfix/${TAG}
gh pr create --base release/${PRIOR} --head hotfix/${TAG} \
--title "release: ${TAG} — <headline>"
gh pr merge <num> --merge --delete-branch=false
# 6. Tag from release/${PRIOR} and push (triggers release.yml).
git checkout release/${PRIOR} && git pull --ff-only
git tag -a ${TAG} -m "${TAG}: <headline>"
git push origin ${TAG}
# 7. Cherry-pick the fix back to main so it doesn't regress.
git checkout main && git pull --ff-only
git cherry-pick <hotfix-sha>
git push origin mainThe hotfix tag never goes through main first — main may carry incompatible work-in-progress that the hotfix recipient explicitly doesn't want. Cherry-pick afterwards to keep history aligned.
Per feedback_commit_gate.md:
swift test -c release \
--filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'If you're committing on a feature/... or fix/... branch the gate is strongly recommended. If you're committing on a vX.Y.Z-release-candidate or hotfix/... branch the gate is mandatory. CI does not currently enforce this; the discipline is human.
| workflow | trigger | what it does | release-blocking? |
|---|---|---|---|
release.yml |
push of tag matching v*.*.* |
Creates GitHub release + release/vX.Y.Z branch |
No (we have a manual fallback) |
ci.yml |
every PR | Build + general tests | Yes — PRs to main should pass |
conformance.yml |
every PR | HTJ2K Part-15 conformance | Yes for codec-touching PRs |
performance.yml |
every PR | Perf benchmark with regression budget | Advisory — review numbers, doesn't auto-block |
release.yml previously had a Validate Release job that built the package on macos-15. It was removed in v6.0.1 because the workflow can't resolve the sibling-path dep ../CompressionFamily — that dep isn't on a remote, so CI can never resolve it. The mandatory commit gate (run locally before tagging) is stricter than swift build -c release and serves the same purpose.
If CompressionFamily is ever pushed to a private GitHub repo, restore the Validate job and add a sibling-checkout step:
- name: Checkout CompressionFamily sibling
uses: actions/checkout@v4
with:
repository: Raster-Lab/CompressionFamily
path: ../CompressionFamily
ssh-key: ${{ secrets.COMPRESSION_FAMILY_DEPLOY_KEY }}Before pushing the tag, every release should have on main:
-
RELEASE_NOTES_vX.Y.Z.mdat repo root -
README.mdupdated —Current Versionline bumped toX.Y.Z,Previous Releaseline moved to the prior tag, AND a new paragraph appended to the## 📦 Release Statussection summarising the release headline + a link toRELEASE_NOTES_vX.Y.Z.md. Mandatory for every release type (patch, minor, major, hotfix). Skipping this leaves the public-facing README stale and silently misleads consumers about the project's current state. - All work in the release explicitly tested by the mandatory commit gate (release mode, 0 failures)
- If codestream bytes change vs previous tag, a
CROSS_VERSION_DELTA_REPORT.mdupdated and committed (or a section appended) showing what's the same and what's not - If a new validation suite was added, listed in the Test Suite Results table of the release notes with cell count
- Cross-codec parity matrix measured fresh and inline in the release notes — at minimum 7 medical fixtures × {OpenJPH, Grok, Kakadu} = 21 cells, with the bit-exactness count visible in the notes
- Medical-corpus benchmarks measured fresh and inline — encode + decode wall time (median of 5, release mode) for the 6 corpus fixtures, and a stage breakdown when relevant to the release's headline (e.g., when a stage's contribution shifted)
- Canonical warm cross-codec benchmark run —
python3 Scripts/benchmarks/cross_codec_warm_bench.py --output benchmark-results-$(uname -m)-vX.Y.Z-$(date +%Y%m%d).json. The output JSON file is committed underDocumentation/Benchmarks/data/and the markdown tables are pasted intoRELEASE_NOTES_vX.Y.Z.md. Mandatory from v9.6 onwards for any release that quotes encode/decode wall times — this is the authoritative apples-to-apples warm methodology backing public performance claims. SeeDocumentation/BENCHMARK.md"Canonical warm cross-codec benchmark" section for the rationale. Runs in ~5-10 minutes on M-series silicon. Releases that change ONLY documentation (e.g. v9.5.2) may skip this if no perf number is asserted.
After pushing the tag, every release should have on origin:
- Tag
vX.Y.Zannotated, onmain(orrelease/vX.Y.(Z-1)for hotfixes) - GitHub release at
https://github.com/Raster-Lab/J2KSwift/releases/tag/vX.Y.Z, body = release notes - Branch
release/vX.Y.Zfrom the tag
If a release ships and is found broken, don't delete the tag. Tags are immutable contracts; consumers may already have pulled them. Instead:
- Mark the GitHub release as a pre-release so it stops appearing as latest:
gh release edit vX.Y.Z --prerelease
- Cut a hotfix
vX.Y.(Z+1)that fixes the regression (see Hotfix flow above). - In
RELEASE_NOTES_vX.Y.(Z+1).md, lead with: "This release supersedes vX.Y.Z, which contained . Users on vX.Y.Z should upgrade."
Force-deleting a published tag is forbidden.
Q: When do I bump MAJOR?
A: When the default config produces different codestream bytes than the prior MAJOR.MINOR.PATCH, OR when public API is removed / signature-changed / behaviour-flipped. v6.0.0 was MAJOR because .auto became the default tile mode and that changes bytes for any user who didn't explicitly specify a tile mode.
Q: Can I tag from a feature branch?
A: No. Tags come from main (releases) or release/vX.Y.Z (hotfixes). Always.
Q: Can I push directly to main?
A: Only the auto-created release merge commit (from gh pr merge) and the auto-created tag bumps. Code changes go through PR every time.
Q: Where does the v5.38.0 → v6.0.0 jump come from?
A: 26 commits accumulated as the v6-alpha[1-5] phases over a few weeks; once .auto flipped to the production default and the GPU forward path landed behind an env gate, the next tag was MAJOR-eligible. The v5.39.x slot was effectively reserved by the v5.39 M1-M3 SIMD experiments which were parked (committed, but not in the production hot path).
Q: What if release.yml fails like it did for v6.0.0?
A: Use the manual publish commands in step 8 above. The release artefact is what matters; the workflow is convenience.