From 87fdf348462d6df50e45ca06c21a7d93fc0a715f Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Wed, 30 Sep 2026 19:25:40 -0500 Subject: [PATCH] feat(ci): add policy validation tool and release workflow - Add .github/scripts/policy_tool.py to lint, format, hash, render, and stage policy.json/POLICY.md - Add .github/workflows/release-policy.yml workflow to publish immutable policy releases with provenance attestations and verification --- .github/scripts/policy_tool.py | 200 +++++++++++++++++++++++++++ .github/workflows/release-policy.yml | 133 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 .github/scripts/policy_tool.py create mode 100644 .github/workflows/release-policy.yml 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