Branch & Release-Tag Protection
This document describes the main-only release policy for the Paladin Framework and the three layers that enforce it. It also gives administrators step-by-step instructions for applying the committed GitHub ruleset definitions.
Policy in one sentence: release tags (
v*.*.*) may only be created from commits that are contained in themainbranch.mainis the single source of truth for released code.
Why this policy exists
Milestone 10 Epic 3 made releases fully tag-driven: pushing a v*.*.* tag triggers
.github/workflows/release.yml, which runs the test suite,
publishes crates to crates.io, builds Docker images and binaries, and generates an SBOM.
When the first release (v0.4.0, Epic 4) was cut, the tag was pushed from a feature branch that
had not yet been merged into main. The pipeline only keyed off the tag, not the branch, so it would
have published code that never passed through the reviewed main branch. Epic 5 closes that gap.
The three enforcement layers
| Layer | Where | What it enforces | Authoritative? |
|---|---|---|---|
| 1. CI guard | verify-tag-source job in release.yml | The tagged commit is an ancestor of origin/main; otherwise the whole pipeline fails before publishing. | Yes |
| 2. Local guard | make release target in Makefile | Refuses to bump/tag unless on an up-to-date main. Fast feedback before any push. | No (advisory) |
| 3. Platform rulesets | .github/rulesets/*.json (applied by an admin) | PR + passing checks required to land on main; only authorized actors may create v* tags. | Defense in depth |
Layer 1 — CI guard (verify-tag-source)
The release workflow's first job resolves the release commit (github.sha for a tag push, or the
commit the dispatched inputs.tag points to) and runs:
git merge-base --is-ancestor "$RELEASE_SHA" origin/main
If the commit is not contained in main, the job emits a ::error:: annotation and exits
non-zero. The test and create-release jobs declare needs: verify-tag-source, so a failed guard
prevents publishing, Docker, binaries, and SBOM from running. This layer is authoritative because it
cannot be bypassed locally.
Layer 2 — Local guard (make release)
Before bumping versions or tagging, make release:
- Checks the current branch is
main. - Fetches
origin/mainand fails if localHEADis behind it.
Both checks run before any destructive action, so a wrong-branch release stops immediately with no version bump, commit, or tag.
Emergency override (hotfix branches only):
RELEASE_ALLOW_ANY_BRANCH=1 make release VERSION=0.4.1
This bypasses only the branch-name check (the up-to-date check still runs). The CI guard (Layer 1) remains authoritative — an override here does not let an unmerged commit publish from CI.
Layer 3 — GitHub rulesets
Two importable ruleset definitions live in .github/rulesets/:
protect-main-branch.json— requires a pull request and passing status checks (Code Quality,Security Audit,License & Dependency Policy) to merge intomain, and blocks force-pushes and branch deletion.protect-release-tags.json— restricts creation and deletion ofrefs/tags/v*to bypass actors (repository admins), so arbitrary contributors cannot cut releases.
GitHub tag rulesets govern who may create a tag matching a pattern — they cannot express "the tag must come from main". The branch-source rule is therefore enforced by Layer 1; the tag ruleset is complementary who-can-tag protection.
Applying the rulesets (administrators)
Rulesets require repository-admin scope and are applied manually (they are intentionally not self-applied from CI).
Option A — GitHub UI
- Go to Settings → Rules → Rulesets → New ruleset → Import a ruleset.
- Upload
.github/rulesets/protect-main-branch.json. Review the targets and status-check contexts, then Create. - Repeat for
.github/rulesets/protect-release-tags.json.
Option B — gh CLI
# Requires admin scope on the repository.
gh api --method POST \
-H "Accept: application/vnd.github+json" \
/repos/DF3NDR/paladin-dev-env/rulesets \
--input .github/rulesets/protect-main-branch.json
gh api --method POST \
-H "Accept: application/vnd.github+json" \
/repos/DF3NDR/paladin-dev-env/rulesets \
--input .github/rulesets/protect-release-tags.json
Verify the active rulesets:
gh api /repos/DF3NDR/paladin-dev-env/rulesets
The
bypass_actorsentry usesactor_id: 5(RepositoryRole= Admin). Adjust the role id or add team/app actors to match your organization before importing.
The correct release flow under this policy
# 1. Open a PR for your changes and get it merged into main (checks must pass).
# 2. Update your local main.
git checkout main
git pull --ff-only origin main
# 3. Cut the release from main.
make release VERSION=0.4.1
Pushing the resulting v0.4.1 tag triggers release.yml; verify-tag-source confirms the tagged
commit is in main, and the pipeline proceeds to publish.
Reconciling the existing v0.4.0 tag
v0.4.0 was cut from feature/milestone_10-epic_4-finalization before this policy existed. To make
main reflect the released code, a maintainer should merge that branch (and the subsequent Epic 5
work) into main via PR. This is a one-time reconciliation and is not performed automatically by the
Epic 5 changes.
Related documents
- docs/RELEASE_AUTOMATION.md — release tooling decision and operator guide.
- docs/RELEASE_CHECKLIST.md — manual release checklist.
- CONTRIBUTING.md —
## Releasingsection.