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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: CI

on:
pull_request:
paths-ignore:
- maintenance-state/**
- maintenance-events/**
workflow_dispatch:
push:
branches:
Expand Down
97 changes: 81 additions & 16 deletions .github/workflows/maintenance-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,10 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/capture-maintenance-evidence --output release-run/evidence
while IFS= read -r encoded; do
capture_id="$(printf '%s' "$encoded" | base64 --decode | jq -r .captureId)"
admitted_digest="$(printf '%s' "$encoded" | base64 --decode | jq -r .digest)"
current_digest="$(jq -r --arg id "$capture_id" '.captures[] | select(.captureId==$id) | .digest' release-run/evidence/evidence-manifest.json)"
test "$current_digest" = "$admitted_digest"
done < <(jq -r '.evidence[] | @base64' admitted-run/maintenance-plan.json)
./maintenance/control.py validate-recaptured-evidence \
--plan admitted-run/maintenance-plan.json \
--admitted-manifest admitted-run/evidence/evidence-manifest.json \
--current-manifest release-run/evidence/evidence-manifest.json
- name: Verify cross-repository readiness when required
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -275,9 +273,7 @@ jobs:
mise install "php@$branch"
mise exec "php@${{ inputs.version }}" -- php -v
mise exec "php@$branch" -- php -v
- name: Complete durable event and notify owner
env:
GH_TOKEN: ${{ github.token }}
- name: Complete durable event
run: |
jq -n --arg version "${{ inputs.version }}" --argjson assets "$(jq .assetDigests release-run/transaction.json)" \
'[{kind:"published_release",version:$version,assetDigests:$assets}]' > release-run/evidence.json
Expand All @@ -295,13 +291,6 @@ jobs:
mv release-run/next-event.json release-run/event.json
jq '.severity="info" | .summary="PHP ${{ inputs.version }} was published and verified through fresh exact and branch-shorthand mise installs." | .finalResult="passed"' \
release-run/event.json > release-run/notification-event.json
./scripts/notify-maintenance \
--event release-run/notification-event.json \
--state release-run/notification-state.json \
--output release-run/notification-next.json \
--backend github \
--repo "${{ github.repository }}" \
--owner "${{ vars.MAINTENANCE_OWNER }}"
- name: Commit final event record through a checked PR
id: event_pr
env:
Expand Down Expand Up @@ -345,6 +334,39 @@ jobs:
test "$(git diff --name-only "${{ steps.event_pr.outputs.base_sha }}" "$actual")" = "${{ steps.event_pr.outputs.record }}"
test "sha256:$(git show "$actual:${{ steps.event_pr.outputs.record }}" | shasum -a 256 | awk '{print $1}')" = "${{ steps.event_pr.outputs.digest }}"
gh pr merge "${{ steps.event_pr.outputs.number }}" --squash --delete-branch
- name: Notify owner of completed release
env:
GH_TOKEN: ${{ github.token }}
run: |
./scripts/notify-maintenance \
--event release-run/notification-event.json \
--state release-run/notification-state.json \
--output release-run/notification-next.json \
--backend github \
--repo "${{ github.repository }}" \
--owner "${{ vars.MAINTENANCE_OWNER }}"
- name: Record release failure diagnostic
if: failure()
env:
ACTION_KEY: ${{ inputs.action_key }}
VERSION: ${{ inputs.version }}
EXACT_COMMIT: ${{ inputs.exact_commit }}
INVESTIGATION_RUN_ID: ${{ inputs.investigation_run_id }}
run: |
[[ "$ACTION_KEY" =~ ^[A-Za-z0-9._:-]+$ ]]
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]]
[[ "$EXACT_COMMIT" =~ ^[0-9a-f]{40}$ ]]
[[ "$INVESTIGATION_RUN_ID" =~ ^[1-9][0-9]*$ ]]
mkdir -p release-run
jq -n \
--arg actionKey "$ACTION_KEY" \
--arg version "$VERSION" \
--arg exactCommit "$EXACT_COMMIT" \
--arg investigationRunId "$INVESTIGATION_RUN_ID" \
--arg releaseRunId "${{ github.run_id }}" \
--arg runUrl "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
'{schemaVersion:1,actionKey:$actionKey,version:$version,exactCommit:$exactCommit,investigationRunId:$investigationRunId,releaseRunId:$releaseRunId,state:"failed",runUrl:$runUrl}' \
> release-run/failure.json
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Retain transaction and exact staging bytes
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand All @@ -356,3 +378,46 @@ jobs:
if-no-files-found: error
retention-days: 90
include-hidden-files: true

notify-failure:
name: Notify actionable release failure
needs: [release]
if: always() && needs.release.result == 'failure'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Create structured critical event
env:
ACTION_KEY: ${{ inputs.action_key }}
VERSION: ${{ inputs.version }}
EXACT_COMMIT: ${{ inputs.exact_commit }}
INVESTIGATION_RUN_ID: ${{ inputs.investigation_run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
[[ "$ACTION_KEY" =~ ^[A-Za-z0-9._:-]+$ ]]
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]]
[[ "$EXACT_COMMIT" =~ ^[0-9a-f]{40}$ ]]
[[ "$INVESTIGATION_RUN_ID" =~ ^[1-9][0-9]*$ ]]
jq -n \
--arg actionKey "$ACTION_KEY" \
--arg summary "Guarded release failed for PHP $VERSION: $RUN_URL" \
--arg failureFingerprint "release-failure:$ACTION_KEY" \
'{actionKey:$actionKey,state:"blocked",severity:"critical",humanActionRequired:false,summary:$summary,failureFingerprint:$failureFingerprint}' \
> event.json
- name: Notify owner
env:
GH_TOKEN: ${{ github.token }}
run: |
./scripts/notify-maintenance \
--event event.json \
--state notification-state.json \
--output notification.json \
--backend github \
--repo "${{ github.repository }}" \
--owner "${{ vars.MAINTENANCE_OWNER }}"
42 changes: 42 additions & 0 deletions .github/workflows/protected-controls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: Protected controls

on:
pull_request_target:
paths-ignore:
- maintenance-state/**
- maintenance-events/**
workflow_dispatch:
inputs:
pr_number:
Expand Down Expand Up @@ -73,6 +76,7 @@ jobs:

from maintenance.control import (
ControlError,
validate_completed_event_record,
validate_evidence_attestation_predicate,
validate_evidence_state_record,
)
Expand Down Expand Up @@ -218,6 +222,44 @@ jobs:
print("No attestation matched the exact watcher run and evidence state.", file=sys.stderr)
raise SystemExit(1)

event_run = re.fullmatch(r"maintenance/(event|eol-complete)-(\d+)", head_ref)
if (
len(protected) == 1
and re.fullmatch(r"maintenance-events/[A-Za-z0-9._-]+\.json", protected[0])
and event_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/{event_run.group(2)}")
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)
validate_completed_event_record(record)
except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error:
print(f"Invalid completed maintenance event: {error}", file=sys.stderr)
raise SystemExit(1) from error
expected_filename = record["actionKey"].translate(str.maketrans({":": "-", "/": "-"})) + ".json"
expected_workflow = (
".github/workflows/maintenance-release.yml"
if event_run.group(1) == "event"
else ".github/workflows/maintenance-watch.yml"
)
allowed_events = {"workflow_dispatch"} if event_run.group(1) == "event" else {"schedule", "workflow_dispatch"}
direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base]
trusted_run = (
protected[0] == f"maintenance-events/{expected_filename}"
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"
)
if direct_parent and trusted_run:
print(f"Protected completed event 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
101 changes: 101 additions & 0 deletions maintenance/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,92 @@ def validate_stable_release_evidence(
)


def validate_recaptured_evidence(
plan: dict[str, Any],
admitted_manifest: dict[str, Any],
current_manifest: dict[str, Any],
) -> dict[str, Any]:
"""Verify cited authoritative captures while allowing runtime-only evidence."""

def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str, Any]]:
require(isinstance(manifest, dict), f"{label} evidence manifest must be an object")
require(manifest.get("schemaVersion") == 1, f"{label} evidence manifest version is invalid")
captures = manifest.get("captures")
require(isinstance(captures, list), f"{label} evidence captures must be an array")
indexed: dict[str, dict[str, Any]] = {}
comparable = []
for capture in captures:
require(isinstance(capture, dict), f"{label} evidence capture must be an object")
capture_id = capture.get("captureId")
digest = capture.get("digest")
require(capture_id in EVIDENCE_CAPTURE_IDS, f"{label} evidence capture is unknown")
require(capture_id not in indexed, f"{label} evidence capture is duplicated: {capture_id}")
require(capture.get("status") == 200, f"{label} evidence capture is not healthy: {capture_id}")
require(bool(SHA256_RE.fullmatch(digest or "")), f"{label} evidence digest is invalid: {capture_id}")
indexed[capture_id] = capture
comparable.append({"captureId": capture_id, "status": capture["status"], "digest": digest})
require(set(indexed) == EVIDENCE_CAPTURE_IDS, f"{label} evidence capture set changed")
require(
manifest.get("manifestDigest") == sha256_bytes(canonical_json(comparable)),
f"{label} evidence manifest digest mismatch",
)
return indexed

admitted = indexed_captures(admitted_manifest, "admitted")
current = indexed_captures(current_manifest, "current")
evidence = plan.get("evidence")
require(isinstance(evidence, list) and bool(evidence), "maintenance plan has no evidence")
verified = []
for item in evidence:
require(isinstance(item, dict), "plan evidence entry must be an object")
capture_id = item.get("captureId")
digest = item.get("digest")
require(bool(SHA256_RE.fullmatch(digest or "")), f"plan evidence digest is invalid: {capture_id}")
if capture_id in RUNTIME_PLAN_EVIDENCE_IDS:
continue
require(capture_id in admitted, f"plan evidence capture is unknown: {capture_id}")
require(admitted[capture_id]["digest"] == digest, f"admitted evidence digest mismatch: {capture_id}")
require(current[capture_id]["digest"] == digest, f"recaptured evidence changed: {capture_id}")
verified.append(capture_id)
require(bool(verified), "maintenance plan cites no authoritative captured evidence")
return {"valid": True, "verifiedCaptureIds": sorted(verified)}


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), "maintenance event must be an object")
require(record.get("schemaVersion") == 1, "maintenance event version is invalid")
require(bool(ACTION_KEY_RE.fullmatch(record.get("actionKey", ""))), "maintenance event action key is invalid")
require(record.get("state") == "complete", "maintenance event is not complete")
history = record.get("history")
require(isinstance(history, list) and bool(history), "maintenance event has no transition history")
current = history[0].get("from") if isinstance(history[0], dict) else None
for transition in history:
require(isinstance(transition, dict), "maintenance event transition must be an object")
require(
set(transition) == {"from", "to", "at", "evidence"},
"maintenance event transition fields changed",
)
require(transition.get("from") == current, "maintenance event history is not contiguous")
target = transition.get("to")
require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), "maintenance event transition is illegal")
timestamp = transition.get("at")
require(
isinstance(timestamp, str) and timestamp.endswith("Z"),
"maintenance event transition timestamp is invalid",
)
evidence = transition.get("evidence")
require(
isinstance(evidence, list)
and bool(evidence)
and all(isinstance(item, dict) and bool(item) for item in evidence),
"maintenance event transition evidence is invalid",
)
current = target
require(current == record["state"], "maintenance event state does not match its history")


def validate_evidence_state_record(record: dict[str, Any]) -> None:
require(isinstance(record, dict), "evidence state must be an object")
require(
Expand Down Expand Up @@ -1127,6 +1213,11 @@ def main(argv: list[str] | None = None) -> int:
capture_parser = subparsers.add_parser("capture-evidence")
capture_parser.add_argument("--output", required=True, type=pathlib.Path)

recapture_parser = subparsers.add_parser("validate-recaptured-evidence")
recapture_parser.add_argument("--plan", required=True, type=pathlib.Path)
recapture_parser.add_argument("--admitted-manifest", required=True, type=pathlib.Path)
recapture_parser.add_argument("--current-manifest", required=True, type=pathlib.Path)

event_parser = subparsers.add_parser("transition-event")
event_parser.add_argument("--event", required=True, type=pathlib.Path)
event_parser.add_argument("--target", required=True)
Expand All @@ -1150,6 +1241,16 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"valid": True}))
elif args.command == "capture-evidence":
print(json.dumps(capture_evidence(args.output, token=os.environ.get("GITHUB_TOKEN"))))
elif args.command == "validate-recaptured-evidence":
print(
json.dumps(
validate_recaptured_evidence(
load_json(args.plan),
load_json(args.admitted_manifest),
load_json(args.current_manifest),
)
)
)
elif args.command == "transition-event":
updated = transition_event(load_json(args.event), args.target, load_json(args.evidence))
write_json(args.output, updated)
Expand Down
Loading
Loading