From 60a94b59691ea1bea3dca5dd889fdbb85a57910c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 21:49:41 +0000 Subject: [PATCH] docs: add unstable qualification branch to promotion policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document topic branch → dev → unstable → stable and move the fast integration gate protections from dev to unstable in policy, CI contracts, and generated adapters. Co-authored-by: michael berry --- .claude/policy-brief.md | 4 +- .cursor/agent-policy.md | 4 +- .github/workflows/ci.yml | 2 + .github/workflows/codeql.yml | 4 +- .github/workflows/documentation.yml | 2 + AGENTS.md | 4 +- agent-policy.json | 4 +- changes/cursor-branch-policy-unstable-3de3.md | 6 ++ docs/BRANCH_POLICY.md | 29 +++++---- docs/CI.md | 31 +++++----- docs/REPO_MAP.md | 8 ++- docs/branch-policy.json | 8 ++- docs/generated/architecture-catalog.json | 17 ++++-- docs/schemas/agent-policy.schema.json | 8 ++- scripts/agent/check-change.py | 2 +- scripts/agent/generate-adapters.py | 27 +++++---- scripts/agent/test_check_change.py | 4 +- scripts/ci/check_branch_policy.py | 32 ++++++++-- scripts/ci/test_check_branch_policy.py | 59 ++++++++++++++----- scripts/generate-architecture-catalogs.py | 6 ++ scripts/hooks/pre-push.sh | 2 +- 21 files changed, 181 insertions(+), 82 deletions(-) create mode 100644 changes/cursor-branch-policy-unstable-3de3.md diff --git a/.claude/policy-brief.md b/.claude/policy-brief.md index e92d5b0d7..a33d79c99 100644 --- a/.claude/policy-brief.md +++ b/.claude/policy-brief.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.cursor/agent-policy.md b/.cursor/agent-policy.md index dcbc8791c..bf03ca581 100644 --- a/.cursor/agent-policy.md +++ b/.cursor/agent-policy.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 744d5ebb2..d8364912b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - dev + - unstable - stable pull_request: branches: - dev + - unstable workflow_dispatch: concurrency: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 017f98aea..9db8512d3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,9 +13,9 @@ name: "CodeQL Advanced" on: push: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] pull_request: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] schedule: - cron: '31 16 * * 4' diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 2327647a0..42ab62328 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -5,10 +5,12 @@ on: branches: - stable - dev + - unstable pull_request: branches: - stable - dev + - unstable workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index ca6d9476c..4afabac67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/agent-policy.json b/agent-policy.json index 5465e6843..f881002b7 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -7,10 +7,12 @@ "branches": { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", "topic_source": "dev", "topic_branch_patterns": ["gh-*", "feature/*", "fix/*", "chore/*", "docs/*"], - "protected": ["dev", "stable"] + "promotion_chain": ["dev", "unstable", "stable"], + "protected": ["unstable", "stable"] }, "autonomy": { "allowed": [ diff --git a/changes/cursor-branch-policy-unstable-3de3.md b/changes/cursor-branch-policy-unstable-3de3.md new file mode 100644 index 000000000..d05e6b37b --- /dev/null +++ b/changes/cursor-branch-policy-unstable-3de3.md @@ -0,0 +1,6 @@ +# Unstable qualification branch policy + +Category: internal +Audience: developers and release operators +Breaking-Change: no +Summary: Document the four-stage promotion chain (topic branch → dev → unstable → stable) and move the fast integration gate protections from dev to unstable. diff --git a/docs/BRANCH_POLICY.md b/docs/BRANCH_POLICY.md index 0c45d0df8..87613e09b 100644 --- a/docs/BRANCH_POLICY.md +++ b/docs/BRANCH_POLICY.md @@ -1,24 +1,26 @@ # Loupe branch policy `stable` is the release line and the repository default branch. `dev` is the -integration line. Short-lived topic branches are created from `dev` and merge -back into `dev`; releases promote reviewed commits from `dev` into `stable`. +first integration line. `unstable` is the qualification line. Short-lived topic +branches are created from `dev` and merge back into `dev`. Reviewed commits +promote along `dev` → `unstable` → `stable`. The full build and CodeQL workflows run for release qualification. `stable` is the protected release branch and requires the `release_ok` GitHub Actions status before merging. That check is produced by the dedicated Release Gate workflow, which always reports: failed, cancelled, skipped, and missing -dependencies reduce to an explicit terminal failure. `dev` is also protected -and requires the fast `agent-fast / build` status before merging. The fast gate +dependencies reduce to an explicit terminal failure. `unstable` is protected +and requires the fast `agent-fast / build` status before merging. That gate checks source integrity, contracts, affected-target compilation, focused tests, and the required PR changelog; expensive cross-platform/package qualification -remains on the release-candidate path. Direct pushes and force-pushes are -disabled by the corresponding GitHub branch rules. +remains on the release-candidate path. `dev` is an integration branch without +branch-rule status requirements; direct pushes and force-pushes are disabled on +the protected branches by the corresponding GitHub branch rules. The Release Gate workflow listens for `pull_request` targeting `stable` and for `merge_group` so an optional merge queue cannot wait on a check that never -runs. It has no path filters. Integration PRs targeting `dev` run `ci.yml` and -must pass `agent-fast / build`. +runs. It has no path filters. Integration PRs targeting `dev` or `unstable` run +`ci.yml`. Merges into `unstable` must pass `agent-fast / build`. The declarations below are intentionally machine-readable by `scripts/ci/check_branch_policy.py`. That check runs in CI, so a workflow @@ -26,8 +28,9 @@ trigger edited away from this policy fails before the build can be cited as release evidence. Pass `--live` to also compare these declarations with GitHub branch protection when a token can read it. -- CI branches: `dev`, `stable` -- Protected branches: `dev`, `stable` +- CI branches: `dev`, `unstable`, `stable` +- Protected branches: `unstable`, `stable` +- Promotion chain: `dev`, `unstable`, `stable` - Required check: `release_ok` - Required check app: GitHub Actions - Required integration check: `agent-fast / build` @@ -35,12 +38,12 @@ branch protection when a token can read it. - Release gate events: `pull_request`, `merge_group` - Release gate pull_request branches: `stable` - Integration workflow: `.github/workflows/ci.yml` -- Integration pull_request branches: `dev` +- Integration pull_request branches: `dev`, `unstable` `master` is not part of the Loupe branch policy. It is retained only in older historical documents or upstream references; new workflow triggers must not target it. -Ensure `stable` requires `release_ok` and `dev` requires `agent-fast / build`, +Ensure `stable` requires `release_ok` and `unstable` requires `agent-fast / build`, both bound to the GitHub Actions app (id 15368). The live policy check verifies -both protections. +both protections and rejects status-check requirements on `dev`. diff --git a/docs/CI.md b/docs/CI.md index 9888da5b8..d003455a4 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,24 +1,25 @@ # CI and diagnostic artifacts -Pull requests to `dev` run the Linux `agent-fast / build` workflow as the -required integration gate. It classifies the diff, runs source and contract -checks, compiles affected targets, and runs focused tests. Pull requests -require one structured changelog fragment under `changes/` named after the -head branch. Subsequent `dev` pushes skip that PR-only check so a merged -topic fragment is not rejected for not being `changes/dev.md`. Stacked topic -branches may carry their parent fragments, but every added fragment is -validated. Format and clang-tidy run on added, modified, renamed, or copied -C/C++ files only; deleted paths still classify modules. These are the fast -checks for -the shared integration baseline. The full Linux and Windows build-and-test -jobs run for release qualification. These are the two platforms Loupe V1 -supports; **macOS** CI is a **post-V1** track under +Pull requests to `dev` or `unstable` run the Linux `agent-fast / build` workflow. +Merges into `unstable` require that check. Topic PRs into `dev` still run the +workflow for signal, but `dev` no longer carries branch-rule status requirements. +The workflow classifies the diff, runs source and contract checks, compiles +affected targets, and runs focused tests. Pull requests require one structured +changelog fragment under `changes/` named after the head branch. Subsequent +integration-branch pushes skip that PR-only check so a merged topic fragment is +not rejected for not being `changes/dev.md`. Stacked topic branches may carry +their parent fragments, but every added fragment is validated. Format and +clang-tidy run on added, modified, renamed, or copied C/C++ files only; deleted +paths still classify modules. These are the fast checks for the shared +integration baseline. The full Linux and Windows build-and-test jobs run for +release qualification. These are the two platforms Loupe V1 supports; **macOS** +CI is a **post-V1** track under [MIC-336](https://linear.app/mbx2/issue/MIC-336) / [docs/PLATFORM_SUPPORT.md](PLATFORM_SUPPORT.md). Packaging artifacts are produced only for `stable` pushes and manual workflow runs. The standalone `Documentation truth` workflow runs for the policy branches -`dev` and `stable` pull requests and pushes. It checks every ADR's verification +`dev`, `unstable`, and `stable` pull requests and pushes. It checks every ADR's verification header and fails when [`docs/generated/architecture-catalog.json`](generated/architecture-catalog.json) is stale. Product versioning is SemVer 2.0 (`0.2.0-alpha`); CI also runs @@ -30,7 +31,7 @@ runs and fails when any required dependency failed, was cancelled, was skipped, or did not report. Requiring the platform-specific jobs directly would create multiple checks for the same gate. The release-gate workflow runs for every pull request targeting `stable` and for `merge_group` events; -it has no path filters. `dev` requires `agent-fast / build` for merging; +it has no path filters. `unstable` requires `agent-fast / build` for merging; `stable` requires `release_ok`. Hosted fuzzing (`.github/workflows/fuzz.yml`) validates the manifested diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 09ea93956..5d3704d4f 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -7,7 +7,7 @@ tracking policy. | Role | Repository | Branch | |------|------------|--------| -| Loupe canonical repository | [studio-berry/loupe](https://github.com/studio-berry/loupe) | `stable` (default/release), `dev` (integration) | +| Loupe canonical repository | [studio-berry/loupe](https://github.com/studio-berry/loupe) | `stable` (default/release), `unstable` (qualification), `dev` (integration) | | Upstream PDF engine source | [JakubMelka/PDF4QT](https://github.com/JakubMelka/PDF4QT) | `master` (upstream only) | Loupe owns the product decisions, branding, release policy, and downstream @@ -16,10 +16,12 @@ tooling. Do not infer Loupe branch policy from upstream's `master` branch. ## Branch policy -- `dev` is the integration branch. +- `dev` is the first integration branch. +- `unstable` is the qualification branch; it carries the fast integration gate + formerly required on `dev`. - `stable` is the release branch and repository default. - Topic branches start from `dev`, stay focused, and merge back to `dev`. -- Releases promote a verified `dev` state to `stable`. +- Reviewed commits promote along `dev` → `unstable` → `stable`. - `master` is not an active Loupe branch. The reviewed machine-readable policy is diff --git a/docs/branch-policy.json b/docs/branch-policy.json index e3626be9b..a2cdc8565 100644 --- a/docs/branch-policy.json +++ b/docs/branch-policy.json @@ -3,6 +3,7 @@ "default_branch": "stable", "release_branch": "stable", "integration_branch": "dev", + "qualification_branch": "unstable", "topic_branch_source": "dev", "topic_branch_patterns": [ "gh-*", @@ -11,8 +12,13 @@ "chore/*", "docs/*" ], - "protected_branches": [ + "promotion_chain": [ "dev", + "unstable", + "stable" + ], + "protected_branches": [ + "unstable", "stable" ] } diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index cfe09a3da..96c52f23a 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -210,10 +210,16 @@ "branch_policy": { "default": "stable", "integration": "dev", - "protected": [ + "promotion_chain": [ "dev", + "unstable", "stable" ], + "protected": [ + "stable", + "unstable" + ], + "qualification": "unstable", "release": "stable", "topic_patterns": [ "chore/*", @@ -453,15 +459,18 @@ "workflow_branches": { ".github/workflows/ci.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/codeql.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/documentation.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/release-gate.yml": [ "stable" diff --git a/docs/schemas/agent-policy.schema.json b/docs/schemas/agent-policy.schema.json index 56973a7c7..ef93723c2 100644 --- a/docs/schemas/agent-policy.schema.json +++ b/docs/schemas/agent-policy.schema.json @@ -9,13 +9,19 @@ "qt_minimum": {"type": "string"}, "branches": { "type": "object", - "required": ["default", "integration", "release", "topic_source", "protected"], + "required": ["default", "integration", "qualification", "release", "topic_source", "promotion_chain", "protected"], "properties": { "default": {"type": "string"}, "integration": {"type": "string"}, + "qualification": {"type": "string"}, "release": {"type": "string"}, "topic_source": {"type": "string"}, "topic_branch_patterns": {"type": "array", "items": {"type": "string"}}, + "promotion_chain": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2 + }, "protected": {"type": "array", "items": {"type": "string"}, "minItems": 1} } }, diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index b70003e63..cfa8fe2cc 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -121,7 +121,7 @@ def current_branch(override: str | None) -> str: def policy_integration_branches(policy: dict) -> set[str]: branches = policy.get("branches", {}) names: set[str] = set(branches.get("protected") or []) - for key in ("integration", "release", "default"): + for key in ("integration", "qualification", "release", "default"): value = branches.get(key) if isinstance(value, str) and value: names.add(value) diff --git a/scripts/agent/generate-adapters.py b/scripts/agent/generate-adapters.py index e01dedad4..8e6201d8d 100644 --- a/scripts/agent/generate-adapters.py +++ b/scripts/agent/generate-adapters.py @@ -56,23 +56,24 @@ def load_policy() -> dict: def render(policy: dict, adapter: str) -> str: if adapter == "docs/branch-policy.json": branches = policy["branches"] - return json.dumps( - { - "generated_by": "scripts/agent/generate-adapters.py", - "default_branch": branches["default"], - "release_branch": branches["release"], - "integration_branch": branches["integration"], - "topic_branch_source": branches["topic_source"], - "topic_branch_patterns": branches["topic_branch_patterns"], - "protected_branches": branches["protected"] - }, - indent=2, - ) + "\n" + payload = { + "generated_by": "scripts/agent/generate-adapters.py", + "default_branch": branches["default"], + "release_branch": branches["release"], + "integration_branch": branches["integration"], + "qualification_branch": branches["qualification"], + "topic_branch_source": branches["topic_source"], + "topic_branch_patterns": branches["topic_branch_patterns"], + "promotion_chain": branches["promotion_chain"], + "protected_branches": branches["protected"], + } + return json.dumps(payload, indent=2) + "\n" branches = policy["branches"] autonomy = policy["autonomy"] changelog = policy["changelog"] version, prerelease = load_version_policy() display_version = format_product_version(version, prerelease) + promotion = " → ".join(f"`{branch}`" for branch in branches["promotion_chain"]) lines = [ "", "# Loupe agent policy adapter", @@ -81,7 +82,7 @@ def render(policy: dict, adapter: str) -> str: "", "## Branches and safety", "", - f"- Integration: `{branches['integration']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`.", + f"- Integration: `{branches['integration']}`; qualification: `{branches['qualification']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`. Promotion: {promotion}.", f"- Protected branches: {', '.join(f'`{branch}`' for branch in branches['protected'])}. Do not commit, push, merge, force-push, or rewrite history without approval.", "- Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped.", "", diff --git a/scripts/agent/test_check_change.py b/scripts/agent/test_check_change.py index 98824a73c..bb995aa95 100644 --- a/scripts/agent/test_check_change.py +++ b/scripts/agent/test_check_change.py @@ -22,8 +22,9 @@ POLICY_BRANCHES = { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", - "protected": ["dev", "stable"], + "protected": ["unstable", "stable"], } @@ -67,6 +68,7 @@ def test_skip_changelog_on_integration_branch(self) -> None: policy = {"branches": POLICY_BRANCHES} with patch.dict(os.environ, {"GITHUB_EVENT_NAME": ""}, clear=False): self.assertEqual(MODULE.skip_changelog_reason("dev", policy, False), "integration branch") + self.assertEqual(MODULE.skip_changelog_reason("unstable", policy, False), "integration branch") self.assertEqual(MODULE.skip_changelog_reason("stable", policy, False), "integration branch") self.assertIsNone(MODULE.skip_changelog_reason("cdx/foo", policy, False)) self.assertEqual(MODULE.skip_changelog_reason("cdx/foo", policy, True), "non-PR event") diff --git a/scripts/ci/check_branch_policy.py b/scripts/ci/check_branch_policy.py index 0e0be9dd9..2c22b44d1 100644 --- a/scripts/ci/check_branch_policy.py +++ b/scripts/ci/check_branch_policy.py @@ -22,6 +22,7 @@ DOCUMENTED_CI_BRANCHES = re.compile(r"^[-*]\s+CI branches:\s*(.+)$", re.MULTILINE) DOCUMENTED_PROTECTED_BRANCHES = re.compile(r"^[-*]\s+Protected branches:\s*(.+)$", re.MULTILINE) +DOCUMENTED_PROMOTION_CHAIN = re.compile(r"^[-*]\s+Promotion chain:\s*(.+)$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_INTEGRATION_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required integration check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK_APP = re.compile(r"^[-*]\s+Required check app:\s*(.+)$", re.MULTILINE) @@ -46,6 +47,7 @@ class DocumentedPolicy: ci_branches: tuple[str, ...] protected_branches: tuple[str, ...] + promotion_chain: tuple[str, ...] required_check: str integration_required_check: str required_check_app: str @@ -90,6 +92,9 @@ def required(pattern: re.Pattern[str], label: str) -> str: protected = _branch_names(required(DOCUMENTED_PROTECTED_BRANCHES, "Protected branches:")) if not protected: raise ValueError("policy declares no protected branches") + promotion_chain = _branch_names(required(DOCUMENTED_PROMOTION_CHAIN, "Promotion chain:")) + if not promotion_chain: + raise ValueError("policy declares no promotion chain") required_check = required(DOCUMENTED_REQUIRED_CHECK, "Required check:") integration_required_check = required( DOCUMENTED_INTEGRATION_REQUIRED_CHECK, "Required integration check:" @@ -113,6 +118,7 @@ def required(pattern: re.Pattern[str], label: str) -> str: return DocumentedPolicy( ci_branches=ci_branches, protected_branches=protected, + promotion_chain=promotion_chain, required_check=required_check, integration_required_check=integration_required_check, required_check_app=required_app, @@ -376,6 +382,7 @@ def _validate_required_check( def validate_live_protection( *, stable_protection: dict[str, Any] | None, + unstable_protection: dict[str, Any] | None, dev_protection: dict[str, Any] | None, policy: DocumentedPolicy, ) -> list[str]: @@ -390,16 +397,23 @@ def validate_live_protection( required_check_app=policy.required_check_app, ) ) - if "dev" in policy.protected_branches: + if "unstable" in policy.protected_branches: violations.extend( _validate_required_check( - branch="dev", - protection=dev_protection, + branch="unstable", + protection=unstable_protection, expected=policy.integration_required_check, required_check_app=policy.required_check_app, ) ) - elif isinstance(dev_protection, dict): + elif isinstance(unstable_protection, dict): + contexts = [str(item.get("context")) for item in _required_check_entries(unstable_protection)] + if contexts: + violations.append( + "live protection: unstable must not require status checks, " + f"got {contexts}" + ) + if "dev" not in policy.protected_branches and isinstance(dev_protection, dict): contexts = [str(item.get("context")) for item in _required_check_entries(dev_protection)] if contexts: violations.append( @@ -496,8 +510,9 @@ def validate_repository( ) else: stable, stable_error = fetch_branch_protection(repo_name, "stable", auth) + unstable, unstable_error = fetch_branch_protection(repo_name, "unstable", auth) dev, dev_error = fetch_branch_protection(repo_name, "dev", auth) - if stable_error == "403" or dev_error == "403": + if stable_error == "403" or unstable_error == "403" or dev_error == "403": print( "WARNING: live branch protection is not readable with this token; " "file-based policy checks still ran.", @@ -508,6 +523,10 @@ def validate_repository( violations.append( f"live protection: failed to read stable rules ({stable_error})" ) + if unstable_error and unstable_error != "404": + violations.append( + f"live protection: failed to read unstable rules ({unstable_error})" + ) if dev_error and dev_error != "404": violations.append( f"live protection: failed to read dev rules ({dev_error})" @@ -516,6 +535,7 @@ def validate_repository( violations.extend( validate_live_protection( stable_protection=stable, + unstable_protection=unstable if unstable_error != "404" else {}, dev_protection=dev if dev_error != "404" else {}, policy=policy, ) @@ -538,7 +558,7 @@ def main() -> int: return 1 print( "Branch policy passed: workflow triggers match the documented " - "dev/stable contract." + "dev/unstable/stable contract." ) return 0 diff --git a/scripts/ci/test_check_branch_policy.py b/scripts/ci/test_check_branch_policy.py index 077efa759..e531b4bce 100644 --- a/scripts/ci/test_check_branch_policy.py +++ b/scripts/ci/test_check_branch_policy.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] -EXPECTED_BRANCHES = ("dev", "stable") +EXPECTED_BRANCHES = ("dev", "unstable", "stable") class BranchPolicyTests(unittest.TestCase): @@ -33,13 +33,14 @@ def test_documented_policy_declares_ci_branches_and_required_check(self): self.assertEqual(required_check, "release_ok") self.assertEqual(policy.required_check, "release_ok") self.assertEqual(policy.required_check_app.lower(), "github actions") - self.assertEqual(policy.protected_branches, ("dev", "stable")) + self.assertEqual(policy.protected_branches, ("unstable", "stable")) + self.assertEqual(policy.promotion_chain, ("dev", "unstable", "stable")) self.assertEqual(policy.integration_required_check, "agent-fast / build") self.assertEqual(policy.release_gate_workflow, ".github/workflows/release-gate.yml") self.assertEqual(policy.release_gate_events, ("pull_request", "merge_group")) self.assertEqual(policy.release_gate_pull_request_branches, ("stable",)) self.assertEqual(policy.integration_workflow, ".github/workflows/ci.yml") - self.assertEqual(policy.integration_pull_request_branches, ("dev",)) + self.assertEqual(policy.integration_pull_request_branches, ("dev", "unstable")) def test_current_ci_workflow_matches_policy(self): policy = parse_documented_policy_full( @@ -47,7 +48,7 @@ def test_current_ci_workflow_matches_policy(self): ) workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertEqual(parse_workflow_branch_triggers(workflow)["push"], EXPECTED_BRANCHES) - self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev",)) + self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev", "unstable")) self.assertEqual(validate_integration_workflow(Path("ci.yml"), workflow, policy), []) def test_current_release_gate_matches_policy(self): @@ -76,6 +77,7 @@ def test_rejects_deliberately_stale_master_trigger(self): pull_request: branches: - dev + - unstable - stable """ violations = validate_workflow_branches(Path("stale.yml"), stale_workflow, EXPECTED_BRANCHES) @@ -144,9 +146,9 @@ def test_rejects_obsolete_ci_ok_aggregate(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] jobs: ci_ok: @@ -161,9 +163,9 @@ def test_rejects_manual_dispatch_without_full_platform_jobs(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] workflow_dispatch: jobs: @@ -193,11 +195,12 @@ def test_live_protection_rejects_ci_ok_and_unbound_app(self): } violations = validate_live_protection( stable_protection=stale_stable, - dev_protection={ + unstable_protection={ "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } }, + dev_protection={}, policy=policy, ) self.assertTrue(any("ci_ok" in item for item in violations)) @@ -213,7 +216,7 @@ def test_live_protection_accepts_github_actions_release_ok(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } @@ -221,13 +224,14 @@ def test_live_protection_accepts_github_actions_release_ok(self): self.assertEqual( validate_live_protection( stable_protection=stable, - dev_protection=dev, + unstable_protection=unstable, + dev_protection={}, policy=policy, ), [], ) - def test_live_protection_rejects_mismatched_dev_checks(self): + def test_live_protection_rejects_mismatched_unstable_checks(self): policy = parse_documented_policy_full( (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") ) @@ -236,17 +240,44 @@ def test_live_protection_rejects_mismatched_dev_checks(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { + "required_status_checks": { + "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } + violations = validate_live_protection( + stable_protection=stable, + unstable_protection=unstable, + dev_protection={}, + policy=policy, + ) + self.assertTrue(any("unstable required checks" in item for item in violations)) + + def test_live_protection_rejects_dev_checks(self): + policy = parse_documented_policy_full( + (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") + ) + stable = { "required_status_checks": { "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } + dev = { + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } violations = validate_live_protection( stable_protection=stable, + unstable_protection={ + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + }, dev_protection=dev, policy=policy, ) - self.assertTrue(any("dev required checks" in item for item in violations)) + self.assertTrue(any("dev must not require status checks" in item for item in violations)) if __name__ == "__main__": diff --git a/scripts/generate-architecture-catalogs.py b/scripts/generate-architecture-catalogs.py index 7b2c4878b..a35857ac4 100644 --- a/scripts/generate-architecture-catalogs.py +++ b/scripts/generate-architecture-catalogs.py @@ -46,8 +46,10 @@ def parse_branch_policy() -> dict[str, Any]: "default_branch", "release_branch", "integration_branch", + "qualification_branch", "topic_branch_source", "topic_branch_patterns", + "promotion_chain", "protected_branches", } missing = sorted(required - policy.keys()) @@ -57,8 +59,10 @@ def parse_branch_policy() -> dict[str, Any]: policy["default_branch"], policy["release_branch"], policy["integration_branch"], + policy["qualification_branch"], policy["topic_branch_source"], *policy["protected_branches"], + *policy["promotion_chain"], } if any(not isinstance(branch, str) or not branch for branch in branches): raise ValueError("branch policy contains an empty branch name") @@ -70,7 +74,9 @@ def parse_branch_policy() -> dict[str, Any]: "default": policy["default_branch"], "release": policy["release_branch"], "integration": policy["integration_branch"], + "qualification": policy["qualification_branch"], "topic_source": policy["topic_branch_source"], + "promotion_chain": policy["promotion_chain"], "protected": sorted(policy["protected_branches"]), "topic_patterns": sorted(policy["topic_branch_patterns"]), } diff --git a/scripts/hooks/pre-push.sh b/scripts/hooks/pre-push.sh index 660f0b58c..8770eb53c 100644 --- a/scripts/hooks/pre-push.sh +++ b/scripts/hooks/pre-push.sh @@ -2,7 +2,7 @@ # BSP-002 §3.3, §3.4, §3.2; BSP-006 §3.4, §5.1 — pre-push policy set -euo pipefail -protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|dev|release/.*)$}" +protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|unstable|dev|release/.*)$}" while read -r local_ref local_sha remote_ref remote_sha; do [[ -z "$remote_ref" ]] && continue