feat(ci): add release trigger authorization guard

This commit is contained in:
Chummy 2026-02-25 11:47:59 +00:00 committed by Chum Yin
parent 1f257d7bf8
commit 5e91f074a8
2 changed files with 433 additions and 36 deletions

View File

@ -60,7 +60,6 @@ jobs:
event_name="${GITHUB_EVENT_NAME}"
publish_release="false"
draft_release="false"
semver_pattern='^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'
if [[ "$event_name" == "push" ]]; then
release_ref="${GITHUB_REF_NAME}"
@ -87,41 +86,6 @@ jobs:
release_tag="verify-${GITHUB_SHA::12}"
fi
if [[ "$publish_release" == "true" ]]; then
if [[ ! "$release_tag" =~ $semver_pattern ]]; then
echo "::error::release_tag must match semver-like format (vX.Y.Z[-suffix])"
exit 1
fi
if ! git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${release_tag}" >/dev/null; then
echo "::error::Tag ${release_tag} does not exist on origin. Push the tag first, then rerun manual publish."
exit 1
fi
# Guardrail: release tags must resolve to commits already reachable from main.
tmp_repo="$(mktemp -d)"
trap 'rm -rf "$tmp_repo"' EXIT
git -C "$tmp_repo" init -q
git -C "$tmp_repo" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git"
git -C "$tmp_repo" fetch --quiet --filter=blob:none origin main "refs/tags/${release_tag}:refs/tags/${release_tag}"
if ! git -C "$tmp_repo" merge-base --is-ancestor "refs/tags/${release_tag}" "origin/main"; then
echo "::error::Tag ${release_tag} is not reachable from origin/main. Release tags must be cut from main."
exit 1
fi
# Guardrail: release tag and Cargo package version must stay aligned.
tag_version="${release_tag#v}"
cargo_version="$(git -C "$tmp_repo" show "refs/tags/${release_tag}:Cargo.toml" | sed -n 's/^version = "\([^"]*\)"/\1/p' | head -n1)"
if [[ -z "$cargo_version" ]]; then
echo "::error::Unable to read Cargo package version from ${release_tag}:Cargo.toml"
exit 1
fi
if [[ "$cargo_version" != "$tag_version" ]]; then
echo "::error::Tag ${release_tag} does not match Cargo.toml version (${cargo_version})."
echo "::error::Bump Cargo.toml version first, then create/publish the matching tag."
exit 1
fi
fi
{
echo "release_ref=${release_ref}"
echo "release_tag=${release_tag}"
@ -138,6 +102,60 @@ jobs:
echo "- draft_release: ${draft_release}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Validate release trigger and authorization guard
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
python3 scripts/ci/release_trigger_guard.py \
--repo-root . \
--repository "${GITHUB_REPOSITORY}" \
--event-name "${GITHUB_EVENT_NAME}" \
--actor "${GITHUB_ACTOR}" \
--release-ref "${{ steps.vars.outputs.release_ref }}" \
--release-tag "${{ steps.vars.outputs.release_tag }}" \
--publish-release "${{ steps.vars.outputs.publish_release }}" \
--authorized-actors "${{ vars.RELEASE_AUTHORIZED_ACTORS || 'willsarg,theonlyhennygod,chumyin' }}" \
--authorized-tagger-emails "${{ vars.RELEASE_AUTHORIZED_TAGGER_EMAILS || '' }}" \
--require-annotated-tag true \
--output-json artifacts/release-trigger-guard.json \
--output-md artifacts/release-trigger-guard.md \
--fail-on-violation
- name: Emit release trigger audit event
if: always()
shell: bash
run: |
set -euo pipefail
python3 scripts/ci/emit_audit_event.py \
--event-type release_trigger_guard \
--input-json artifacts/release-trigger-guard.json \
--output-json artifacts/audit-event-release-trigger-guard.json \
--artifact-name release-trigger-guard \
--retention-days 30
- name: Publish release trigger guard summary
if: always()
shell: bash
run: |
set -euo pipefail
cat artifacts/release-trigger-guard.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload release trigger guard artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: release-trigger-guard
path: |
artifacts/release-trigger-guard.json
artifacts/release-trigger-guard.md
artifacts/audit-event-release-trigger-guard.json
if-no-files-found: error
retention-days: 30
build-release:
name: Build ${{ matrix.target }}
needs: [prepare]

View File

@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Validate release trigger authorization and publish-tag provenance."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
STABLE_TAG_RE = re.compile(r"^v(?P<version>\d+\.\d+\.\d+)$")
TRUE_VALUES = {"1", "true", "yes", "on"}
def parse_bool(raw: str) -> bool:
return raw.strip().lower() in TRUE_VALUES
def parse_csv(raw: str) -> list[str]:
return [item.strip() for item in raw.split(",") if item.strip()]
def normalize_email(raw: str) -> str:
value = raw.strip().lower()
if value.startswith("<") and value.endswith(">") and len(value) > 2:
value = value[1:-1]
return value
def run_git(args: list[str], *, cwd: Path) -> str:
proc = subprocess.run(
["git", *args],
cwd=str(cwd),
text=True,
capture_output=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}")
return proc.stdout.strip()
def build_markdown(report: dict) -> str:
lines: list[str] = []
lines.append("# Release Trigger Guard Report")
lines.append("")
lines.append(f"- Generated at: `{report['generated_at']}`")
lines.append(f"- Event: `{report['event_name']}`")
lines.append(f"- Actor: `{report['actor']}`")
lines.append(f"- Publish release: `{report['publish_release']}`")
lines.append(f"- Release ref: `{report['release_ref']}`")
lines.append(f"- Release tag: `{report['release_tag']}`")
lines.append(f"- Ready to publish: `{report['ready_to_publish']}`")
lines.append("")
lines.append("## Authorization")
lines.append(f"- Actor authorized: `{report['authorization']['actor_authorized']}`")
lines.append(f"- Tagger authorized: `{report['authorization']['tagger_authorized']}`")
actors = report["policy"].get("authorized_actors", [])
if actors:
lines.append(f"- Authorized actors: {', '.join(f'`{item}`' for item in actors)}")
else:
lines.append("- Authorized actors: none configured")
lines.append("")
lines.append("## Tag Metadata")
metadata = report.get("tag_metadata", {})
lines.append(f"- Tag exists on origin: `{metadata.get('tag_exists')}`")
lines.append(f"- Tag object type: `{metadata.get('tag_object_type')}`")
lines.append(f"- Annotated tag: `{metadata.get('annotated_tag')}`")
lines.append(f"- Tag commit: `{metadata.get('tag_commit')}`")
lines.append(f"- Tagger: `{metadata.get('tagger_name')}` / `{metadata.get('tagger_email')}`")
lines.append(f"- Cargo version: `{metadata.get('cargo_version')}`")
lines.append(f"- Tag version: `{metadata.get('tag_version')}`")
lines.append("")
if report["violations"]:
lines.append("## Violations")
for item in report["violations"]:
lines.append(f"- {item}")
lines.append("")
if report["warnings"]:
lines.append("## Warnings")
for item in report["warnings"]:
lines.append(f"- {item}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="Validate release trigger authorization and tag provenance.")
parser.add_argument("--repo-root", default=".")
parser.add_argument("--repository", required=True, help="Repository slug (owner/repo).")
parser.add_argument(
"--origin-url",
default="",
help="Optional explicit origin URL/path (used in tests/local verification).",
)
parser.add_argument("--event-name", required=True)
parser.add_argument("--actor", required=True)
parser.add_argument("--release-ref", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--publish-release", required=True, help="Boolean value (true/false).")
parser.add_argument("--authorized-actors", default="")
parser.add_argument("--authorized-tagger-emails", default="")
parser.add_argument("--require-annotated-tag", default="true")
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
parser.add_argument("--fail-on-violation", action="store_true")
args = parser.parse_args()
repo_root = Path(args.repo_root).resolve()
out_json = Path(args.output_json)
out_md = Path(args.output_md)
publish_release = parse_bool(args.publish_release)
require_annotated_tag = parse_bool(args.require_annotated_tag)
authorized_actors = parse_csv(args.authorized_actors)
authorized_tagger_emails = [normalize_email(item) for item in parse_csv(args.authorized_tagger_emails)]
violations: list[str] = []
warnings: list[str] = []
actor_authorized: bool | None = None
tagger_authorized: bool | None = None
tag_exists = False
tag_object_type: str | None = None
annotated_tag: bool | None = None
tag_commit: str | None = None
tagger_name: str | None = None
tagger_email: str | None = None
tagger_date: str | None = None
cargo_version: str | None = None
tag_version: str | None = None
if publish_release:
stable_match = STABLE_TAG_RE.fullmatch(args.release_tag)
if not stable_match:
violations.append(
f"Release tag `{args.release_tag}` must match stable format `vX.Y.Z`; "
"pre-release tags belong to Pub Pre-release workflow."
)
else:
tag_version = stable_match.group("version")
if args.release_ref != args.release_tag:
violations.append(
f"Publish mode requires release_ref to equal release_tag; got `{args.release_ref}` vs `{args.release_tag}`."
)
if not authorized_actors:
violations.append(
"No authorized publish actors configured. Set `RELEASE_AUTHORIZED_ACTORS` repository variable."
)
actor_authorized = False
else:
actor_authorized = args.actor in authorized_actors
if not actor_authorized:
violations.append(
f"Actor `{args.actor}` is not authorized to trigger release publish. "
f"Allowed actors: {', '.join(authorized_actors)}."
)
origin_url = args.origin_url.strip() or f"https://github.com/{args.repository}.git"
ls_remote = subprocess.run(
["git", "ls-remote", "--tags", origin_url],
text=True,
capture_output=True,
check=False,
)
if ls_remote.returncode != 0:
violations.append(f"Failed to list origin tags from `{origin_url}`: {ls_remote.stderr.strip()}")
else:
refs = set()
for line in ls_remote.stdout.splitlines():
parts = line.split()
if len(parts) >= 2:
refs.add(parts[1].strip())
tag_ref = f"refs/tags/{args.release_tag}"
peeled_ref = f"{tag_ref}^{{}}"
tag_exists = tag_ref in refs or peeled_ref in refs
if not tag_exists:
origin_path = Path(origin_url)
if origin_path.exists():
local_ref = subprocess.run(
["git", "-C", str(origin_path), "show-ref", "--verify", tag_ref],
text=True,
capture_output=True,
check=False,
)
if local_ref.returncode == 0:
tag_exists = True
warnings.append(
f"Resolved tag `{args.release_tag}` via local origin-path fallback (`{origin_path}`)."
)
if not tag_exists:
violations.append(
f"Tag `{args.release_tag}` does not exist on origin `{origin_url}`. Push tag first."
)
if tag_exists:
with tempfile.TemporaryDirectory(prefix="zc-release-trigger-guard-") as tmp_dir:
tmp_repo = Path(tmp_dir)
try:
run_git(["init", "-q"], cwd=tmp_repo)
run_git(["remote", "add", "origin", origin_url], cwd=tmp_repo)
run_git(
[
"fetch",
"--quiet",
"--filter=blob:none",
"origin",
"main",
f"refs/tags/{args.release_tag}:refs/tags/{args.release_tag}",
],
cwd=tmp_repo,
)
except RuntimeError as exc:
violations.append(f"Failed to fetch release refs for guard validation: {exc}")
else:
try:
tag_object_type = run_git(
["cat-file", "-t", f"refs/tags/{args.release_tag}"],
cwd=tmp_repo,
)
annotated_tag = tag_object_type == "tag"
except RuntimeError as exc:
violations.append(f"Failed to inspect tag object type for `{args.release_tag}`: {exc}")
if require_annotated_tag and annotated_tag is False:
violations.append(
f"Release tag `{args.release_tag}` must be an annotated tag (lightweight tags are not allowed)."
)
try:
tag_commit = run_git(
["rev-list", "-n", "1", f"refs/tags/{args.release_tag}"],
cwd=tmp_repo,
)
except RuntimeError as exc:
violations.append(f"Failed to resolve commit for tag `{args.release_tag}`: {exc}")
ancestor_check = subprocess.run(
["git", "merge-base", "--is-ancestor", f"refs/tags/{args.release_tag}", "origin/main"],
cwd=str(tmp_repo),
text=True,
capture_output=True,
check=False,
)
if ancestor_check.returncode != 0:
violations.append(
f"Tag `{args.release_tag}` is not reachable from `origin/main`; release tags must be cut from main."
)
try:
cargo_toml = run_git(["show", f"refs/tags/{args.release_tag}:Cargo.toml"], cwd=tmp_repo)
for line in cargo_toml.splitlines():
stripped = line.strip()
if stripped.startswith("version = "):
cargo_version = stripped.split('"', 2)[1]
break
except RuntimeError as exc:
violations.append(f"Failed to inspect Cargo.toml at `{args.release_tag}`: {exc}")
if tag_version and cargo_version and tag_version != cargo_version:
violations.append(
f"Tag `{args.release_tag}` version `{tag_version}` does not match Cargo.toml version `{cargo_version}`."
)
if annotated_tag:
try:
tagger_raw = run_git(
[
"for-each-ref",
"--format=%(taggername)|%(taggeremail)|%(taggerdate:iso8601)",
f"refs/tags/{args.release_tag}",
],
cwd=tmp_repo,
)
if tagger_raw:
parts = tagger_raw.split("|", 2)
if len(parts) == 3:
tagger_name = parts[0] or None
tagger_email = parts[1] or None
tagger_date = parts[2] or None
except RuntimeError as exc:
warnings.append(f"Failed to inspect tagger metadata for `{args.release_tag}`: {exc}")
if authorized_tagger_emails:
normalized_tagger = normalize_email(tagger_email or "")
if not normalized_tagger:
tagger_authorized = False
violations.append(
f"Tag `{args.release_tag}` has no tagger email metadata but tagger allowlist is enforced."
)
else:
tagger_authorized = normalized_tagger in authorized_tagger_emails
if not tagger_authorized:
violations.append(
f"Tagger email `{normalized_tagger}` is not authorized. "
f"Allowed tagger emails: {', '.join(authorized_tagger_emails)}."
)
else:
tagger_authorized = None
warnings.append(
"No authorized tagger email list configured; tagger authorization check skipped."
)
else:
warnings.append("Verification mode detected (`publish_release=false`); publish-trigger authorization skipped.")
ready_to_publish = publish_release and not violations
report = {
"schema_version": "zeroclaw.release-trigger-guard.v1",
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"event_name": args.event_name,
"actor": args.actor,
"release_ref": args.release_ref,
"release_tag": args.release_tag,
"publish_release": publish_release,
"ready_to_publish": ready_to_publish,
"policy": {
"stable_tag_pattern": STABLE_TAG_RE.pattern,
"require_annotated_tag": require_annotated_tag,
"authorized_actors": authorized_actors,
"authorized_tagger_emails": authorized_tagger_emails,
},
"authorization": {
"actor_authorized": actor_authorized,
"tagger_authorized": tagger_authorized,
},
"tag_metadata": {
"tag_exists": tag_exists,
"tag_object_type": tag_object_type,
"annotated_tag": annotated_tag,
"tag_commit": tag_commit,
"tagger_name": tagger_name,
"tagger_email": normalize_email(tagger_email or "") if tagger_email else None,
"tagger_date": tagger_date,
"tag_version": tag_version,
"cargo_version": cargo_version,
},
"trigger_provenance": {
"repository": args.repository,
"origin_url": args.origin_url.strip() or f"https://github.com/{args.repository}.git",
"workflow": os.environ.get("GITHUB_WORKFLOW"),
"run_id": os.environ.get("GITHUB_RUN_ID"),
"run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"sha": os.environ.get("GITHUB_SHA"),
"ref": os.environ.get("GITHUB_REF"),
"ref_name": os.environ.get("GITHUB_REF_NAME"),
"actor": os.environ.get("GITHUB_ACTOR"),
},
"warnings": warnings,
"violations": violations,
}
out_json.parent.mkdir(parents=True, exist_ok=True)
out_md.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
out_md.write_text(build_markdown(report), encoding="utf-8")
if args.fail_on_violation and violations:
print("release trigger guard violations found:", file=sys.stderr)
for item in violations:
print(f"- {item}", file=sys.stderr)
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())