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 the main branch. main is 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

LayerWhereWhat it enforcesAuthoritative?
1. CI guardverify-tag-source job in release.ymlThe tagged commit is an ancestor of origin/main; otherwise the whole pipeline fails before publishing.Yes
2. Local guardmake release target in MakefileRefuses 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:

  1. Checks the current branch is main.
  2. Fetches origin/main and fails if local HEAD is 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 into main, and blocks force-pushes and branch deletion.
  • protect-release-tags.json — restricts creation and deletion of refs/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

  1. Go to Settings → Rules → Rulesets → New ruleset → Import a ruleset.
  2. Upload .github/rulesets/protect-main-branch.json. Review the targets and status-check contexts, then Create.
  3. 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_actors entry uses actor_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.