Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 7 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches:
- main
- v2
- v2.1
- v2.2

permissions:
contents: read
Expand All @@ -23,33 +23,18 @@ jobs:
with:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24

- name: Enforce pull request branch policy
if: github.event_name == 'pull_request'
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
if [[ "$BASE_REF" == "v2.1" ]]; then
if [[ "$HEAD_REF" =~ ^(feat|fix|test|chore)/.+$ ]]; then
exit 0
fi
echo "::error::Pull requests into v2.1 must come from feat/*, fix/*, test/*, or chore/* branches."
exit 1
fi

if [[ "$BASE_REF" == "main" ]]; then
if [[ "$HEAD_REF" == "v2.1" || "$HEAD_REF" =~ ^hotfix/.+$ ]]; then
exit 0
fi
echo "::error::Only v2.1 release promotion or an explicit hotfix/* branch may target main."
exit 1
fi

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
run: node scripts/validate-branch-policy.mjs "$BASE_REF" "$HEAD_REF"

- name: Enforce commit and pull request title conventions
if: github.event_name == 'pull_request'
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

## Protected branches

- `main` is production-only. `v2.1` is the active integration branch for the v2.1.0 development cycle.
- Never commit feature, fix, test, or chore work directly to `main` or `v2.1`.
- `main` is production-only. `v2.2` is the active integration branch for the v2.2.0 development cycle.
- Never commit feature, fix, test, or chore work directly to `main` or `v2.2`.

## Development work

- Create short-lived `feat/*`, `fix/*`, `test/*`, or `chore/*` branches from `v2.1`.
- Sprint and routine development pull requests target `v2.1`, not `main`.
- Create short-lived `feat/*`, `fix/*`, `test/*`, or `chore/*` branches from `v2.2`.
- Sprint and routine development pull requests target `v2.2`, not `main`.
- After a successful merge, delete only the merged short-lived branch when cleanup is authorized.

## Commit messages
Expand All @@ -30,8 +30,8 @@

## Production promotion

- Normal development reaches `main` only through a dedicated release or hardening pull request from `v2.1`.
- Creating the v2.1.0 tag or release requires separate explicit authorization after final verification.
- Normal development reaches `main` only through a dedicated release or hardening pull request from `v2.2`.
- Creating the v2.2.0 tag or release requires separate explicit authorization after final verification.

## Hotfixes

Expand Down
18 changes: 9 additions & 9 deletions docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ Secure Tools separates product versions from Sprint numbers. A Sprint is a bound
## Branch roles

- `main` is the production branch. Routine development does not target it.
- `v2.1` is the integration branch for the v2.1.0 cycle.
- Short-lived `feat/*`, `fix/*`, `test/*`, and `chore/*` branches start from `v2.1` and return through pull requests into `v2.1`.
- Direct feature or fix commits to `main` or `v2.1` are prohibited.
- `v2.2` is the active integration branch for the v2.2.0 cycle.
- Short-lived `feat/*`, `fix/*`, `test/*`, and `chore/*` branches start from `v2.2` and return through pull requests into `v2.2`.
- Direct feature or fix commits to `main` or `v2.2` are prohibited.

```text
main (production)
↑
release PR after hardening
release PR after v2.2 hardening
↑
v2.1 (integration)
v2.2 (active integration)
↑
Sprint PRs
↑
Expand All @@ -23,10 +23,10 @@ feat/* fix/* test/* chore/*

## Sprint delivery

1. Update local `v2.1` from `origin/v2.1`.
1. Update local `v2.2` from `origin/v2.2`.
2. Create a short-lived branch from that exact integration state.
3. Commit and validate only the Sprint’s intended changes.
4. Open a pull request into `v2.1` and wait for required CI.
4. Open a pull request into `v2.2` and wait for required CI.
5. Treat review and merge as a separate step. An agent does not merge its own pull request or enable auto-merge unless the user explicitly authorizes that specific action.
6. After a successful merge and verification, remove the merged short-lived branch when branch cleanup is authorized.

Expand All @@ -40,12 +40,12 @@ CI validates non-merge commits introduced by the pull request’s actual base-to

## Production release

After the v2.1.0 scope is integrated, complete release hardening and final verification on `v2.1`. Promote it through a dedicated `v2.1` → `main` pull request. Only after that pull request is explicitly reviewed and merged may a separately authorized task create the v2.1.0 tag and release.
The v2.1.0 cycle is released. The v2.2.0 cycle is active development. After the v2.2.0 scope is integrated, complete release hardening and final verification on `v2.2`. Promote it through a dedicated `v2.2` → `main` pull request. Only after that pull request is explicitly reviewed and merged may a separately authorized task create the v2.2.0 tag and release.

## Hotfixes

Urgent production fixes use a dedicated `hotfix/*` branch and pull request into `main`. They are never pushed directly. After production verification, carry the correction back into the active integration line as needed through an appropriate pull request.

## Enforced pull request policy

CI permits routine `feat/*`, `fix/*`, `test/*`, and `chore/*` pull requests into `v2.1`. Pull requests into `main` pass the branch-policy gate only when the head is exactly `v2.1` or a dedicated `hotfix/*` branch. The repository protects both long-lived branches with required pull requests, the existing `Validate static tools` check, resolved review conversations, blocked force pushes, and blocked deletion. Because the repository currently has one maintainer, an approving-review count is not required; explicit merge authorization remains mandatory.
CI permits routine `feat/*`, `fix/*`, `test/*`, and `chore/*` pull requests into `v2.2`. Pull requests into `main` pass the branch-policy gate only when the head is exactly `v2.2` or a dedicated `hotfix/*` branch. The repository protects both long-lived branches with required pull requests, the existing `Validate static tools` check, resolved review conversations, blocked force pushes, and blocked deletion. Because the repository currently has one maintainer, an approving-review count is not required; explicit merge authorization remains mandatory.
45 changes: 45 additions & 0 deletions scripts/validate-branch-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { fileURLToPath } from "node:url";

export const ACTIVE_INTEGRATION_BRANCH = "v2.2";

const routineBranch = /^(?:feat|fix|test|chore)\/.+$/;
const hotfixBranch = /^hotfix\/.+$/;

export function validateBranchPolicy(baseRef, headRef) {
if (baseRef === ACTIVE_INTEGRATION_BRANCH) {
return routineBranch.test(headRef)
? { valid: true, reason: null }
: {
valid: false,
reason: `Pull requests into ${ACTIVE_INTEGRATION_BRANCH} must come from feat/*, fix/*, test/*, or chore/* branches.`,
};
}

if (baseRef === "main") {
return headRef === ACTIVE_INTEGRATION_BRANCH || hotfixBranch.test(headRef)
? { valid: true, reason: null }
: {
valid: false,
reason: `Only ${ACTIVE_INTEGRATION_BRANCH} release promotion or an explicit hotfix/* branch may target main.`,
};
}

return { valid: true, reason: null };
}

export function run([baseRef, headRef] = process.argv.slice(2)) {
if (!baseRef || !headRef) {
throw new Error("Usage: node scripts/validate-branch-policy.mjs <base-ref> <head-ref>");
}
const result = validateBranchPolicy(baseRef, headRef);
if (!result.valid) throw new Error(result.reason);
console.log(`Branch policy accepted ${headRef} → ${baseRef}.`);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try { run(); }
catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
44 changes: 44 additions & 0 deletions tests/branch-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";

import {
ACTIVE_INTEGRATION_BRANCH,
run,
validateBranchPolicy,
} from "../scripts/validate-branch-policy.mjs";

assert.equal(ACTIVE_INTEGRATION_BRANCH, "v2.2");

for (const [headRef, baseRef] of [
["feat/example", "v2.2"],
["fix/example", "v2.2"],
["test/example", "v2.2"],
["chore/example", "v2.2"],
["v2.2", "main"],
["hotfix/example", "main"],
]) {
assert.deepEqual(validateBranchPolicy(baseRef, headRef), { valid: true, reason: null }, `${headRef} → ${baseRef}`);
assert.doesNotThrow(() => run([baseRef, headRef]));
}

for (const [headRef, baseRef] of [
["feat/example", "main"],
["fix/example", "main"],
["test/example", "main"],
["chore/example", "main"],
["v2.1", "main"],
]) {
const result = validateBranchPolicy(baseRef, headRef);
assert.equal(result.valid, false, `${headRef} → ${baseRef}`);
assert.match(result.reason, /Only v2\.2 release promotion or an explicit hotfix/);
assert.throws(() => run([baseRef, headRef]), /Only v2\.2 release promotion or an explicit hotfix/);
}

for (const headRef of ["v2.1", "main", "hotfix/example", "feature/example"]) {
const result = validateBranchPolicy("v2.2", headRef);
assert.equal(result.valid, false, `${headRef} → v2.2`);
assert.match(result.reason, /Pull requests into v2\.2 must come from feat\/\*, fix\/\*, test\/\*, or chore\/\*/);
}

assert.throws(() => run([]), /Usage:/);

console.log("v2.2 integration, production promotion, hotfix, and rejection branch-policy checks passed.");
8 changes: 3 additions & 5 deletions tests/ci-foundation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fs from "node:fs";
const workflow = fs.readFileSync(".github/workflows/ci.yml", "utf8");
assert.match(workflow, /^name: CI$/m);
assert.match(workflow, /^\s{2}pull_request:$/m);
assert.match(workflow, /^\s{2}push:\s*$[\s\S]*?^\s{6}- main$[\s\S]*?^\s{6}- v2$[\s\S]*?^\s{6}- v2\.1$/m);
assert.match(workflow, /^\s{2}push:\s*$[\s\S]*?^\s{6}- main$[\s\S]*?^\s{6}- v2$[\s\S]*?^\s{6}- v2\.2$/m);
assert.match(workflow, /uses: actions\/checkout@v4/);
assert.match(workflow, /uses: actions\/setup-node@v4/);
assert.match(workflow, /node-version: 24/);
Expand All @@ -13,10 +13,8 @@ assert.match(workflow, /name: Enforce pull request branch policy/);
assert.match(workflow, /if: github\.event_name == 'pull_request'/);
assert.match(workflow, /BASE_REF: \$\{\{ github\.base_ref \}\}/);
assert.match(workflow, /HEAD_REF: \$\{\{ github\.head_ref \}\}/);
assert.match(workflow, /\^\(feat\|fix\|test\|chore\)\/\.\+\$/);
assert.match(workflow, /HEAD_REF" == "v2\.1"/);
assert.match(workflow, /\^hotfix\/\.\+\$/);
assert.match(workflow, /Only v2\.1 release promotion or an explicit hotfix/);
assert.match(workflow, /run: node scripts\/validate-branch-policy\.mjs "\$BASE_REF" "\$HEAD_REF"/);
assert.doesNotMatch(workflow, /v2\.1/);
assert.match(workflow, /name: Enforce commit and pull request title conventions/);
assert.match(workflow, /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/);
assert.match(workflow, /HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
Expand Down
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ for (const test of [
"tests/pdf-metadata.test.mjs",
"tests/i18n-quality.test.mjs",
"tests/ux-consistency.test.mjs",
"tests/branch-policy.test.mjs",
"tests/ci-foundation.test.mjs",
"tests/commit-message.test.mjs",
"tests/cloudflare-bridge.test.mjs",
Expand Down
Loading