Branch and Tag Protection¶
The canonical API repository relies on two layers of protection to ensure that only reviewed, validated changes reach main and only CI creates release tags. APX can configure both automatically via apx init canonical --setup-github, or you can set them up manually through the GitHub UI.
Why Protection Matters¶
Without protection rules, any contributor with write access could:
- Push directly to
main, bypassing lint and breaking-change checks - Create release tags manually, skipping the validated CI pipeline
- Delete or overwrite existing tags, corrupting consumer dependency resolution
APX's protection model prevents all three:
| Threat | Protection Layer |
|---|---|
Unreviewed code on main | Branch protection with required PR reviews |
| Invalid schemas merged | Required status checks (validate job) |
| Bypassing CODEOWNERS | Required Code Owner reviews |
| Manual tag creation | Tag protection ruleset (apx-tag-protection) |
| Tag deletion/overwrite | Tag protection ruleset (restrict deletions) |
Branch Protection on main¶
What APX Configures¶
When you run --setup-github, APX calls the GitHub Branch Protection API (PUT repos/{owner}/{repo}/branches/main/protection) with these settings:
{
"required_status_checks": {
"strict": true,
"contexts": ["validate"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"require_code_owner_reviews": true,
"dismiss_stale_reviews": true
},
"restrictions": null
}
Settings Breakdown¶
| Setting | Value | Purpose |
|---|---|---|
| Required status checks | validate (strict) | The ci.yml workflow's validate job must pass. "Strict" means the branch must be up to date with main before merging. |
| Required approving reviews | 1 | At least one team member must approve the PR. |
| Require Code Owner reviews | true | If the changed path has a CODEOWNERS entry, a member of that team must approve. |
| Dismiss stale reviews | true | Pushing new commits to the PR dismisses previous approvals, requiring re-review. |
| Enforce admins | false | Org admins can bypass in emergencies (e.g., hotfixes). Set to true for stricter governance. |
| Restrictions | null | No push restrictions beyond the PR requirement — anyone with write access can open a PR. |
The validate Status Check¶
The validate job comes from the generated .github/workflows/ci.yml:
name: APX Schema CI
on:
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: infobloxopen/apx@main
- name: Lint schemas
run: apx lint
- name: Check for breaking changes
run: apx breaking --against origin/main
This ensures every PR is linted and checked for breaking changes before it can be merged.
Idempotent Behavior¶
EnsureBranchProtection is idempotent — it checks whether protection already exists before creating it:
- Already exists: Reported as
✓ Already configured: branch protection on main - Created: Reported as
✓ Created: branch protection on main - Permission denied (403): Reported as
⚠ Requires admin: branch protection on main
Tag Protection Ruleset¶
Why Rulesets (Not Legacy Tag Protection)¶
GitHub offers two tag protection mechanisms:
- Legacy tag protection rules — Simple pattern matching, limited control
- Repository rulesets — Fine-grained control with bypass actors, enforcement levels, and audit logging
APX uses repository rulesets because they support bypass actors, allowing the GitHub App (via CI) and organization admins to create tags while blocking everyone else.
What APX Configures¶
APX creates a ruleset named apx-tag-protection via the GitHub Rulesets API (POST repos/{owner}/{repo}/rulesets):
{
"name": "apx-tag-protection",
"target": "tag",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~ALL"],
"exclude": []
}
},
"rules": [
{ "type": "creation" },
{ "type": "deletion" }
],
"bypass_actors": [
{
"actor_type": "OrganizationAdmin",
"actor_id": 1,
"bypass_mode": "always"
}
]
}
Settings Breakdown¶
| Setting | Value | Purpose |
|---|---|---|
| Target | tag | Applies to git tags, not branches |
| Enforcement | active | Rules are enforced (not in "evaluate" audit-only mode) |
| Ref pattern | ~ALL | Protects all tags — not just APX-formatted ones |
| Restrict creation | Enabled | Prevents direct git tag + git push --tags |
| Restrict deletion | Enabled | Prevents git push --delete origin <tag> |
| Bypass: OrgAdmin | Always | Organization admins can bypass for emergencies |
How CI Creates Tags¶
The on-merge.yml workflow runs with a GitHub App token that has contents:write permission. Since the App is installed at the organization level, it can bypass the tag ruleset:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APX_APP_ID }}
private-key: ${{ secrets.APX_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
The App token carries the contents:write permission required to push tags.
Tag Patterns¶
APX's tagging convention uses subdirectory-scoped tags:
Examples:
proto/payments/ledger/v1/v1.0.0
proto/payments/ledger/v1/v1.2.3
openapi/users/v1/v1.0.0
avro/events/v1/v1.5.0
The ~ALL pattern protects every tag, which is the safest default. If you need to allow non-APX tags (e.g., application release tags), you can edit the ruleset conditions to target specific patterns:
"conditions": {
"ref_name": {
"include": [
"refs/tags/proto/**",
"refs/tags/openapi/**",
"refs/tags/avro/**",
"refs/tags/jsonschema/**",
"refs/tags/parquet/**"
],
"exclude": []
}
}
Idempotent Behavior¶
EnsureTagProtection checks for an existing ruleset named apx-tag-protection before creating:
- Already exists: Reported as
✓ Already configured: tag protection ruleset - Name collision: Detected via
Name must be uniqueerror, reported as skipped - Created: Reported as
✓ Created: tag protection ruleset - Permission denied: Reported as
⚠ Requires admin: tag protection ruleset: <detail>
GitHub App & Org Secrets¶
The protection model depends on a GitHub App providing CI with elevated permissions without sharing personal access tokens.
Required Org Secrets¶
| Secret | Purpose | Visibility |
|---|---|---|
APX_APP_ID | GitHub App numeric ID | All repositories |
APX_APP_PRIVATE_KEY | GitHub App private key (PEM) | All repositories |
These secrets are consumed by actions/create-github-app-token@v1 in the CI workflows to generate short-lived installation tokens.
App Permissions¶
The GitHub App created by --setup-github requests these permissions:
| Permission | Level | Purpose |
|---|---|---|
contents | write | Push tags, commit catalog updates |
pull_requests | write | Create release PRs |
metadata | read | Basic repo metadata access |
Why a GitHub App (Not a PAT)¶
| Aspect | Personal Access Token | GitHub App |
|---|---|---|
| Scope | Tied to a user account | Org-level installation |
| Audit | Actions appear as the user | Actions appear as apx-<repo>-<org>[bot] |
| Rotation | Manual | Automatic (short-lived tokens) |
| Revocation | Affects all repos using that PAT | Per-installation control |
| Offboarding | Token invalid when user leaves | Survives personnel changes |
Automated Setup¶
Run the full automated setup:
This performs all four steps in sequence:
- Creates (or reuses) the GitHub App
- Sets
APX_APP_IDandAPX_APP_PRIVATE_KEYas org secrets - Configures branch protection on
main - Creates the
apx-tag-protectiontag ruleset
See Canonical Repository Setup for the complete walkthrough.
Manual Setup¶
If you lack org admin access or prefer manual configuration:
Branch Protection (GitHub UI)¶
- Go to Settings → Branches → Add branch protection rule
- Branch name pattern:
main - Enable:
- Require a pull request before merging
- Required approvals: 1
- Require review from Code Owners
- Dismiss stale pull request approvals when new commits are pushed
- Require status checks to pass before merging
- Require branches to be up to date before merging
- Status checks: add
validate
- Require signed commits (optional, recommended)
- Click Create
Tag Ruleset (GitHub UI)¶
- Go to Settings → Rules → Rulesets → New ruleset → New tag ruleset
- Configure:
- Ruleset name:
apx-tag-protection - Enforcement status: Active
- Bypass list: Add "Organization admin" with "Always" bypass mode
- Target tags: All tags
- Rules: Enable "Restrict creations" and "Restrict deletions"
- Click Create
Org Secrets (GitHub UI or CLI)¶
If someone else created the GitHub App, get the App ID and PEM from them and set:
# Via gh CLI (requires admin:org scope)
gh secret set APX_APP_ID --org <org> --visibility all --body "<app-id>"
gh secret set APX_APP_PRIVATE_KEY --org <org> --visibility all < private-key.pem
Or via Organization Settings → Secrets and variables → Actions → New organization secret.
Verifying Protection¶
Branch Protection¶
# Check branch protection exists
gh api repos/<org>/apis/branches/main/protection \
--jq '{
reviews: .required_pull_request_reviews.required_approving_review_count,
codeowners: .required_pull_request_reviews.require_code_owner_reviews,
status_checks: .required_status_checks.contexts
}'
Expected output:
Tag Ruleset¶
# List rulesets
gh api repos/<org>/apis/rulesets --jq '.[].name'
# → apx-tag-protection
# Verify tag push is blocked (should fail for non-admin users)
git tag test-protection && git push origin test-protection
# → remote: error: GH013: Repository rule violations found ...
Org Secrets¶
gh secret list --org <org> | grep APX_
# → APX_APP_ID Updated 2026-03-08
# → APX_APP_PRIVATE_KEY Updated 2026-03-08
Customization¶
Stricter Branch Protection¶
To also enforce rules on admins:
gh api repos/<org>/apis/branches/main/protection \
--method PUT --input - <<'EOF'
{
"required_status_checks": { "strict": true, "contexts": ["validate"] },
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 2,
"require_code_owner_reviews": true,
"dismiss_stale_reviews": true
},
"restrictions": null
}
EOF
Adding More Status Checks¶
If you add custom CI jobs (e.g., integration tests), add them to the required contexts:
gh api repos/<org>/apis/branches/main/protection \
--method PUT --input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["validate", "integration-tests", "security-scan"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"require_code_owner_reviews": true,
"dismiss_stale_reviews": true
},
"restrictions": null
}
EOF
Scoped Tag Protection¶
To protect only APX tag patterns while allowing other tags:
- Go to Settings → Rules → Rulesets → apx-tag-protection → Edit
- Change target from "All tags" to "Include by pattern"
- Add patterns:
proto/**,openapi/**,avro/**,jsonschema/**,parquet/**
Troubleshooting¶
"403 Forbidden" on Branch Protection¶
You need admin access to the repository. Ask an org admin to run:
Or configure manually in the GitHub UI under Settings → Branches.
"admin:org scope" Error¶
Your gh token needs the admin:org scope for org-level secrets:
Tag Push Rejected¶
If git push --tags fails with a ruleset violation, the tag protection is working correctly. Tags should only be created by CI via the on-merge.yml workflow. If you need to create a tag manually in an emergency, use an org admin account.
Stale Review Dismissal Confusion¶
If approvals keep getting dismissed, it's because dismiss_stale_reviews is enabled. Any new push to the PR requires re-approval. This is intentional — it prevents approving a PR and then pushing additional unreviewed changes.
Related Pages¶
- Canonical Repository Setup — Full setup walkthrough including
--setup-github - CI Templates — The workflows that depend on these protections
- Repository Structure — Directory layout and CODEOWNERS patterns
- Tagging Strategy — How tags are formatted and versioned