diff --git a/.github/scripts/policy_tool.py b/.github/scripts/policy_tool.py
new file mode 100644
index 0000000..01d043a
--- /dev/null
+++ b/.github/scripts/policy_tool.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""
+policy_tool.py - lint, render, hash and stage policy/policy.json.
+
+policy.json is the source of truth. Its sha256 is taken over the exact file
+bytes, so it equals the digest GitHub shows for the release asset. Lint
+requires the file to already be in canonical form (indent=2, sorted keys,
+trailing newline) so the same policy always hashes the same.
+
+POLICY.md is generated from policy.json; `check-md` fails on drift.
+
+Subcommands: lint | fmt | render-md | check-md | hash | version | stage
+Pure stdlib. Prints ::error:: lines so failures show up in Actions logs.
+"""
+import argparse
+import hashlib
+import json
+import re
+import shutil
+import sys
+from pathlib import Path
+
+VERSION_RE = re.compile(r"^v[1-9][0-9]*$")
+RULE_ID_RE = re.compile(r"^[A-Z]{3,4}-[0-9]+$")
+MODES = ("descriptive", "enforced")
+STATUSES = ("performed-at-build", "verified-at-release", "declared", "enforced")
+RULE_FIELDS = {"id", "title", "statement", "status", "implemented_by"}
+TOP_FIELDS = {"schema", "policy_version", "enforcement_mode", "legacy_aliases",
+ "summary", "rules", "not_guaranteed"}
+
+
+def die(msg: str) -> None:
+ print(f"::error::{msg}")
+ sys.exit(1)
+
+
+def canonical(obj) -> bytes:
+ return (json.dumps(obj, indent=2, sort_keys=True) + "\n").encode("utf-8")
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def load(path: Path):
+ if not path.exists():
+ die(f"{path} not found")
+ raw = path.read_bytes()
+ try:
+ return raw, json.loads(raw)
+ except json.JSONDecodeError as e:
+ die(f"{path} is not valid JSON: {e}")
+
+
+def validate(obj) -> list[str]:
+ errs: list[str] = []
+ if not isinstance(obj, dict):
+ return ["top level must be an object"]
+ extra, missing = set(obj) - TOP_FIELDS, TOP_FIELDS - set(obj)
+ if extra:
+ errs.append(f"unknown top-level fields: {sorted(extra)}")
+ if missing:
+ errs.append(f"missing top-level fields: {sorted(missing)}")
+ return errs
+ if obj["schema"] != 1:
+ errs.append("schema must be 1")
+ if not (isinstance(obj["policy_version"], str) and VERSION_RE.match(obj["policy_version"])):
+ errs.append("policy_version must look like v1, v2, ...")
+ if obj["enforcement_mode"] not in MODES:
+ errs.append(f"enforcement_mode must be one of {MODES}")
+ if not (isinstance(obj["legacy_aliases"], list) and all(isinstance(a, str) for a in obj["legacy_aliases"])):
+ errs.append("legacy_aliases must be a list of strings")
+ if not (isinstance(obj["summary"], str) and obj["summary"].strip()):
+ errs.append("summary must be a non-empty string")
+ if not (isinstance(obj["not_guaranteed"], list) and all(isinstance(s, str) and s.strip() for s in obj["not_guaranteed"])):
+ errs.append("not_guaranteed must be a list of non-empty strings")
+ rules = obj["rules"]
+ if not (isinstance(rules, list) and rules):
+ errs.append("rules must be a non-empty list")
+ return errs
+ seen: set[str] = set()
+ for i, r in enumerate(rules):
+ where = f"rules[{i}]"
+ if not isinstance(r, dict):
+ errs.append(f"{where} must be an object")
+ continue
+ unknown = set(r) - RULE_FIELDS
+ if unknown:
+ errs.append(f"{where} unknown fields: {sorted(unknown)}")
+ for f in ("id", "title", "statement", "status"):
+ if not (isinstance(r.get(f), str) and r[f].strip()):
+ errs.append(f"{where}.{f} must be a non-empty string")
+ rid = r.get("id", "")
+ if not RULE_ID_RE.match(rid or ""):
+ errs.append(f"{where}.id {rid!r} must look like SRC-1")
+ if rid in seen:
+ errs.append(f"duplicate rule id {rid}")
+ seen.add(rid)
+ if r.get("status") not in STATUSES:
+ errs.append(f"{where}.status must be one of {STATUSES}")
+ if obj["enforcement_mode"] == "descriptive" and r.get("status") == "enforced":
+ errs.append(f"{where}: status 'enforced' not allowed when enforcement_mode is 'descriptive'")
+ return errs
+
+
+def render(obj) -> str:
+ out = [
+ f"# Policy {obj['policy_version']}",
+ "",
+ "",
+ "",
+ f"- Enforcement mode: **{obj['enforcement_mode']}**",
+ f"- Legacy aliases: {', '.join(f'`{a}`' for a in obj['legacy_aliases']) or 'none'}",
+ "- Hash: sha256 of the exact `policy.json` bytes, published as `policy.json.sha256` and as the release asset digest. "
+ "This file cannot contain its own hash.",
+ "",
+ obj["summary"],
+ "",
+ "## Rules",
+ "",
+ ]
+ for r in obj["rules"]:
+ out += [f"### {r['id']} - {r['title']}", "", f"- Status: `{r['status']}`"]
+ if r.get("implemented_by"):
+ out.append(f"- Implemented by: {r['implemented_by']}")
+ out += ["", r["statement"], ""]
+ out += ["## Not guaranteed", ""]
+ out += [f"- {s}" for s in obj["not_guaranteed"]]
+ return "\n".join(out) + "\n"
+
+
+def cmd_lint(a) -> None:
+ raw, obj = load(a.policy)
+ errs = validate(obj)
+ if not errs and raw != canonical(obj):
+ errs.append(f"{a.policy} is not canonical - run: python3 .github/scripts/policy_tool.py fmt")
+ for e in errs:
+ print(f"::error::{e}")
+ if errs:
+ sys.exit(1)
+ print(f"{a.policy} OK sha256={sha256_bytes(raw)}")
+
+
+def cmd_fmt(a) -> None:
+ _, obj = load(a.policy)
+ a.policy.write_bytes(canonical(obj))
+ print(f"rewrote {a.policy}")
+
+
+def cmd_render_md(a) -> None:
+ _, obj = load(a.policy)
+ a.md.write_text(render(obj))
+ print(f"wrote {a.md}")
+
+
+def cmd_check_md(a) -> None:
+ _, obj = load(a.policy)
+ if not a.md.exists() or a.md.read_text() != render(obj):
+ die(f"{a.md} is out of date - run: python3 .github/scripts/policy_tool.py render-md")
+ print(f"{a.md} matches {a.policy}")
+
+
+def cmd_hash(a) -> None:
+ raw, _ = load(a.policy)
+ print(sha256_bytes(raw))
+
+
+def cmd_version(a) -> None:
+ _, obj = load(a.policy)
+ print(obj["policy_version"])
+
+
+def cmd_stage(a) -> None:
+ cmd_lint(a)
+ cmd_check_md(a)
+ raw, _ = load(a.policy)
+ a.outdir.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(a.policy, a.outdir / "policy.json")
+ shutil.copy2(a.md, a.outdir / "POLICY.md")
+ (a.outdir / "policy.json.sha256").write_text(f"{sha256_bytes(raw)} policy.json\n")
+ print(f"staged policy.json, POLICY.md, policy.json.sha256 in {a.outdir}")
+
+
+def main() -> None:
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--policy", type=Path, default=Path("policy/policy.json"))
+ p.add_argument("--md", type=Path, default=Path("policy/POLICY.md"))
+ sub = p.add_subparsers(dest="cmd", required=True)
+ for name, fn in (("lint", cmd_lint), ("fmt", cmd_fmt), ("render-md", cmd_render_md),
+ ("check-md", cmd_check_md), ("hash", cmd_hash), ("version", cmd_version)):
+ sub.add_parser(name).set_defaults(fn=fn)
+ s = sub.add_parser("stage")
+ s.add_argument("outdir", type=Path)
+ s.set_defaults(fn=cmd_stage)
+ a = p.parse_args()
+ a.fn(a)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml
new file mode 100644
index 0000000..2fa4c46
--- /dev/null
+++ b/.github/workflows/release-policy.yml
@@ -0,0 +1,133 @@
+name: release-policy
+
+# Freezes policy/policy.json as an immutable release `policy-`.
+# Manual only, from main, behind the `policy-release` environment (create it
+# in repo Settings -> Environments with required reviewers). Gitsign on the
+# commit says who wrote the policy; this release says it is the frozen rule
+# set. Nothing here publishes automatically.
+
+on:
+ workflow_dispatch:
+ inputs:
+ policy_version:
+ description: "Policy version to freeze; must equal policy_version in policy/policy.json (e.g. v1)"
+ required: true
+ default: "v1"
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WANT: ${{ inputs.policy_version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.11"
+
+ - name: Lint policy and generated POLICY.md
+ run: |
+ set -euo pipefail
+ python3 .github/scripts/policy_tool.py lint
+ python3 .github/scripts/policy_tool.py check-md
+ have="$(python3 .github/scripts/policy_tool.py version)"
+ if [ "$have" != "$WANT" ]; then
+ echo "::error::input policy_version '$WANT' != policy.json policy_version '$have'"
+ exit 1
+ fi
+
+ - name: Refuse if this policy release already exists
+ run: |
+ if gh release view "policy-${WANT}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ echo "::error::release policy-${WANT} already exists - policies are never overwritten, bump policy_version"
+ exit 1
+ fi
+
+ publish:
+ needs: check
+ runs-on: ubuntu-latest
+ environment: policy-release
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WANT: ${{ inputs.policy_version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.11"
+
+ - name: Stage release assets
+ run: python3 .github/scripts/policy_tool.py stage policy-release
+
+ - name: Attest build provenance (policy.json)
+ uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
+ with:
+ subject-path: policy-release/policy.json
+
+ - name: Verify attestation and attach bundle
+ run: |
+ set -euo pipefail
+ signer="${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml"
+ ok=0
+ for i in 1 2 3 4 5 6; do
+ if gh attestation verify policy-release/policy.json --repo "$GITHUB_REPOSITORY" --signer-workflow "$signer"; then
+ ok=1; break
+ fi
+ echo "attestation not visible yet (try $i), waiting"; sleep 10
+ done
+ [ "$ok" = 1 ] || { echo "::error::could not verify policy.json attestation"; exit 1; }
+ digest="$(sha256sum policy-release/policy.json | cut -d' ' -f1)"
+ gh attestation download policy-release/policy.json --repo "$GITHUB_REPOSITORY"
+ # gh names the bundle sha256:.jsonl in the cwd
+ mv "sha256:${digest}.jsonl" policy-release/policy.json.attestations.jsonl
+
+ - name: Draft, upload, verify asset set, publish
+ run: |
+ set -euo pipefail
+ tag="policy-${WANT}"
+ gh release create "$tag" --repo "$GITHUB_REPOSITORY" --draft --target "$GITHUB_SHA" \
+ --title "Policy ${WANT}" \
+ --notes "Frozen policy ${WANT} (sha256 in policy.json.sha256). Verify: gh attestation verify policy.json --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml"
+ gh release upload "$tag" --repo "$GITHUB_REPOSITORY" \
+ policy-release/policy.json policy-release/POLICY.md \
+ policy-release/policy.json.sha256 policy-release/policy.json.attestations.jsonl
+ got="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name] | sort | join(",")')"
+ want="POLICY.md,policy.json,policy.json.attestations.jsonl,policy.json.sha256"
+ if [ "$got" != "$want" ]; then
+ echo "::error::asset set mismatch: got '$got', want '$want' - not publishing"
+ exit 1
+ fi
+ target="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq .targetCommitish)"
+ if [ "$target" != "$GITHUB_SHA" ]; then
+ echo "::error::release target $target != $GITHUB_SHA - not publishing"
+ exit 1
+ fi
+ gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false
+
+ - name: Re-download published assets and compare
+ run: |
+ set -euo pipefail
+ tag="policy-${WANT}"
+ mkdir -p published
+ gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir published
+ cmp published/policy.json policy-release/policy.json
+ cmp published/POLICY.md policy-release/POLICY.md
+ (cd published && sha256sum -c policy.json.sha256)
+ gh attestation verify published/policy.json --repo "$GITHUB_REPOSITORY" \
+ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml"
+ echo "Published $tag: $(sha256sum published/policy.json | cut -d' ' -f1)"
\ No newline at end of file