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
200 changes: 200 additions & 0 deletions .github/scripts/policy_tool.py
Original file line number Diff line number Diff line change
@@ -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 <dir>
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']}",
"",
"<!-- Generated from policy/policy.json by policy_tool.py render-md. Do not edit. -->",
"",
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()
133 changes: 133 additions & 0 deletions .github/workflows/release-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: release-policy

# Freezes policy/policy.json as an immutable release `policy-<version>`.
# 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:<hex>.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)"
Loading