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
71 changes: 69 additions & 2 deletions .github/workflows/protected-controls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ jobs:

from autorelease.control import (
ControlError,
sha256_file,
validate_completed_event_record,
validate_readiness_event_record,
validate_evidence_attestation_predicate,
validate_evidence_state_record,
)
Expand Down Expand Up @@ -136,7 +138,25 @@ jobs:
print(f"Protected paths changed by the configured owner {author}.")
raise SystemExit(0)

# Both trusted-automation exemptions below bind the whole diff, not just
def started_on_main_history(run_sha):
"""Whether a run's start commit is `base` or an ancestor of it.

Publish and implementation runs merge other records while they run, so
main can move past the commit they started from before they file their
own record. The record is still one commit directly on current main.
"""
if not isinstance(run_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", run_sha):
return False
if run_sha == base:
return True
try:
comparison = api_one(f"repos/{repo}/compare/{run_sha}...{base}")
except (subprocess.CalledProcessError, json.JSONDecodeError):
print(f"Run start commit {run_sha} could not be compared with {base}.", file=sys.stderr)
return False
return comparison.get("status") == "ahead" and comparison.get("behind_by") == 0

# The trusted-automation exemptions below bind the whole diff, not just
# its protected subset: the watcher writes exactly one file, so any
# unprotected passenger riding along is proof this is not that PR.
evidence_run = re.fullmatch(r"autorelease/evidence-(\d+)", head_ref)
Expand Down Expand Up @@ -272,13 +292,60 @@ jobs:
and run.get("path") == expected_workflow
and run.get("event") in allowed_events
and run.get("head_branch") == "main"
and run.get("head_sha") == base
and run.get("status") == "in_progress"
and started_on_main_history(run.get("head_sha"))
)
if direct_parent and trusted_run:
print(f"Protected completed event approved from trusted run {run['id']}.")
raise SystemExit(0)

# A lifecycle implementation merges its admitted patch, then files the
# php_bin_ready record for that exact merge commit. The record must sit
# directly on the commit it names, from the implementation run that is
# still in progress, so a new branch needs no owner review to publish.
readiness_run = re.fullmatch(r"autorelease/readiness-(\d+)", head_ref)
if (
len(files) == 1
and len(protected) == 1
and re.fullmatch(r"autorelease-events/[A-Za-z0-9._-]+\.json", protected[0])
and readiness_run
and author == "github-actions[bot]"
and head_repo.lower() == repo.lower()
):
commit = api_one(f"repos/{repo}/commits/{head}")
run = api_one(f"repos/{repo}/actions/runs/{readiness_run.group(1)}")
content = api_one(f"repos/{repo}/contents/{protected[0]}?ref={head}")
try:
decoded = base64.b64decode(content["content"].replace("\n", ""), validate=True)
record = json.loads(decoded)
merged_commit = validate_readiness_event_record(record)
except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error:
print(f"Invalid readiness record: {error}", file=sys.stderr)
raise SystemExit(1) from error
expected_filename = record["actionKey"].translate(str.maketrans({":": "-", "/": "-"})) + ".json"
direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base]
# This step runs on a checkout of `base`, so the policy digests the
# record carries, the only fields publish and EOL completion act on,
# must describe exactly the tree the record sits on.
policy_bound = (
record["supportPolicyDigest"] == sha256_file(pathlib.Path("support-policy.json"))
and record["policyInvariantsDigest"]
== sha256_file(pathlib.Path("autorelease/policy-invariants.json"))
)
trusted_run = (
protected[0] == f"autorelease-events/{expected_filename}"
and merged_commit == base
and policy_bound
and run.get("path") == ".github/workflows/autorelease-implement.yml"
and run.get("event") == "workflow_dispatch"
and run.get("head_branch") == "main"
and run.get("status") == "in_progress"
and started_on_main_history(run.get("head_sha"))
)
if direct_parent and trusted_run:
print(f"Protected readiness record approved from trusted run {run['id']}.")
raise SystemExit(0)

reviews = api(f"repos/{repo}/pulls/{number}/reviews")
approved = any(
review.get("state") == "APPROVED"
Expand Down
22 changes: 16 additions & 6 deletions AUTORELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ only after the one before succeeded:
watcher, ends the job without a second record, and a pull request or branch
an earlier attempt left on the run's own `autorelease/event-<run id>` branch
is withdrawn before the record is filed afresh. A complete record on main
that names another release stops the job. The rerun can only file the
record while main is still the commit the run was dispatched at, because
`Protected controls` binds a publish run's record to exactly that commit;
once main has moved on, the watcher's record recovery is the path.
that names another release stops the job. `Protected controls` accepts the
record while the run is still in progress and started on current main or
an ancestor of it, so a rerun files the record even after main has moved
on; the watcher's record recovery remains the path once the run has ended.

Both install jobs pass their read-only token to `mise-php`, whose GitHub API
reads would otherwise be rate limited, and restore no mise cache. Every
Expand Down Expand Up @@ -322,7 +322,15 @@ from the merged policy with its own deterministic scripts. The readiness and
event records then merge on their own: `autorelease-events/`, `autorelease-state/`, and `mise-php`'s
`readiness/` sit outside CODEOWNERS precisely so their exact-SHA automation
PRs satisfy branch protection without a reviewer, while every protected
control still cannot. Publication waits only on machine facts — matching
control still cannot. `Protected controls` admits each record PR only from
`github-actions[bot]` in this repository, as one file directly on the base
commit, from the exact workflow run named by its branch while that run is in
progress on main: evidence from the watcher, which must also have started at
exactly the base commit and carry a matching attestation; completed events
from publish or the watcher; and a lifecycle's `php_bin_ready` record from the
implementation run, which must name the base commit as its validated merge and
carry the policy digests of the base tree. A new branch therefore publishes
with no human approval. Publication waits only on machine facts — matching
`php_bin_ready` and `mise_ready` records at exact commits. The `mise_ready`
record names the mise-php synchronization commit it validated, and the record
itself merges on top of it, so the publish job requires the captured mise-php
Expand Down Expand Up @@ -401,7 +409,9 @@ autorelease-investigation-<run-id>`):

Inspect `autorelease-events/`, generated `support-policy.json`, the reviewed
`autorelease/policy-invariants.json`, retained workflow artifacts, and the
event issue marker to reconstruct a decision. `scripts/verify-autorelease-system`
event issue marker to reconstruct a decision. The notifier only trusts issues
and comments written by `github-actions[bot]`: the repository is public, so a
copied marker or fingerprint from anyone else is ignored. `scripts/verify-autorelease-system`
writes `autorelease-verification.json` and `autorelease-verification.md` into
its `--output` directory; both are per-run artifacts, not checked-in files.

Expand Down
78 changes: 76 additions & 2 deletions autorelease/_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from ._validation import (
ACTION_KEY_RE,
COMMIT_SHA_RE,
SHA256_RE,
STABLE_VERSION_RE,
ControlError,
Expand Down Expand Up @@ -68,9 +69,82 @@ def validate_completed_event_record(record: dict[str, Any]) -> None:
"""Validate a durable event as a complete, contiguous legal transition history."""

require(isinstance(record, dict), "autorelease event must be an object")
require(record.get("schemaVersion") == 1, "autorelease event version is invalid")
require(bool(ACTION_KEY_RE.fullmatch(record.get("actionKey", ""))), "autorelease event action key is invalid")
require(record.get("state") == "complete", "autorelease event is not complete")
_validate_event_history(record)


# The exact field set the implementation run writes for a lifecycle readiness record.
READINESS_RECORD_FIELDS = {
"schemaVersion",
"actionKey",
"classification",
"state",
"history",
"phpBinCommit",
"planDigest",
"supportPolicyDigest",
"policyInvariantsDigest",
"evidenceManifestDigest",
"evidenceDigests",
}


def validate_readiness_event_record(record: dict[str, Any]) -> str:
"""Validate an implementation run's `php_bin_ready` record and return its merged commit.

The implementation run writes exactly one transition, `detected` to
`php_bin_ready`, whose only evidence is the validated merge of the admitted
lifecycle patch. That merge commit is what the record vouches for, so the trusted
automation exemption binds it to the pull request's base: the record can only be
accepted directly on top of the commit it names.
"""
require(isinstance(record, dict), "autorelease event must be an object")
require(set(record) == READINESS_RECORD_FIELDS, "readiness record fields changed")
require(record.get("state") == "php_bin_ready", "readiness record is not at php_bin_ready")
_validate_event_history(record)
classification = record.get("classification")
require(classification in {"new_branch", "branch_eol"}, "readiness record is not a lifecycle action")
require(
record["actionKey"].split(":", 1)[0] == classification,
"readiness record action key does not match its classification",
)
history = record["history"]
require(
len(history) == 1 and history[0]["from"] == "detected" and history[0]["to"] == "php_bin_ready",
"readiness record history is not a single detected to php_bin_ready transition",
)
evidence = history[0]["evidence"]
require(
len(evidence) == 1
and set(evidence[0]) == {"kind", "commit", "planDigest"}
and evidence[0]["kind"] == "validated_merge",
"readiness record evidence is not one validated merge",
)
commit = evidence[0]["commit"]
require(isinstance(commit, str) and bool(COMMIT_SHA_RE.fullmatch(commit)), "readiness merge commit is invalid")
require(record.get("phpBinCommit") == commit, "readiness record names a different php-bin commit")
require(evidence[0]["planDigest"] == record.get("planDigest"), "readiness record plan digest differs")
for field in ("planDigest", "supportPolicyDigest", "policyInvariantsDigest", "evidenceManifestDigest"):
value = record.get(field)
require(isinstance(value, str) and bool(SHA256_RE.fullmatch(value)), f"readiness record {field} is invalid")
digests = record.get("evidenceDigests")
require(
isinstance(digests, list)
and bool(digests)
and all(isinstance(item, str) and SHA256_RE.fullmatch(item) for item in digests),
"readiness record evidence digests are invalid",
)
return commit


def _validate_event_history(record: dict[str, Any]) -> None:
"""Require a versioned event whose history is a contiguous chain of legal transitions."""
require(record.get("schemaVersion") == 1, "autorelease event version is invalid")
action_key = record.get("actionKey")
require(
isinstance(action_key, str) and bool(ACTION_KEY_RE.fullmatch(action_key)),
"autorelease event action key is invalid",
)
history = record.get("history")
require(isinstance(history, list) and bool(history), "autorelease event has no transition history")
current = history[0].get("from") if isinstance(history[0], dict) else None
Expand Down
1 change: 1 addition & 0 deletions autorelease/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
transition_event,
unrecorded_published_release,
validate_completed_event_record,
validate_readiness_event_record,
watch_decision,
)
from autorelease._validation import ( # noqa: E402
Expand Down
33 changes: 27 additions & 6 deletions scripts/notify-autorelease
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ FINGERPRINT_RE = re.compile(
)


# The repository is public, so anyone can open an issue or comment carrying a
# marker. Only what the workflow itself wrote identifies an event issue; anything
# else must neither capture nor silence the owner notification.


def written_by_workflow(item: dict) -> bool:
"""Whether a `gh issue list` entry or a REST comment was written by GitHub Actions."""
author = item.get("author")
if isinstance(author, dict):
return author.get("login") == "app/github-actions" and author.get("is_bot") is True
user = item.get("user") or {}
return user.get("login") == "github-actions[bot]" and user.get("type") == "Bot"


def find_issue(repo: str, action_key: str) -> dict | None:
matches: dict[int, dict] = {}
for prefix in MARKER_PREFIXES:
Expand All @@ -54,13 +68,13 @@ def find_issue(repo: str, action_key: str) -> dict | None:
"--search",
f'"{marker}" in:body',
"--json",
"number,body,state,url",
"number,body,state,url,author",
"--limit",
"100",
)
)
for issue in issues:
if marker in issue.get("body", ""):
if marker in issue.get("body", "") and written_by_workflow(issue):
matches[issue["number"]] = issue
if len(matches) > 1:
raise ControlError("multiple issues exist for one action key")
Expand All @@ -71,11 +85,18 @@ def discover_github_prior(repo: str, action_key: str) -> dict | None:
issue = find_issue(repo, action_key)
if issue is None:
return None
detail = json.loads(
gh("issue", "view", str(issue["number"]), "--repo", repo, "--json", "body,comments")
# REST comments name the author unambiguously (`github-actions[bot]`, type Bot),
# where `gh issue view` reports a bare `github-actions` login.
pages = json.loads(
gh("api", f"repos/{repo}/issues/{issue['number']}/comments", "--paginate", "--slurp")
)
bodies = [issue.get("body", "")]
bodies.extend(
comment.get("body", "")
for page in pages
for comment in page
if written_by_workflow(comment)
)
bodies = [detail.get("body", "")]
bodies.extend(comment.get("body", "") for comment in detail.get("comments", []))
fingerprints = [
match.group(1) for body in bodies for match in FINGERPRINT_RE.finditer(body)
]
Expand Down
Loading
Loading