yams/scripts/ci/reconcile_promotion_pr.py
trvon 438f8a8a7b
ci(release): refuse stable releases from non-version refs
A stable workflow_dispatch took the release tag from GITHUB_REF_NAME
without checking it, so dispatching from main published a release
tagged main. Require refs/tags/v* or refs/tags/yams-v*.

Fetch main and experimental with explicit refspecs in the candidate
preflight so a tag named main cannot shadow the branch, and drop the
workflow_run trust path from draft reconciliation now that the workflow
is dispatch-only.
2026-09-22 12:18:20 -06:00

262 lines
9.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""Main-owned draft visibility only. Default to a read-only plan; never promote."""
from __future__ import annotations
import argparse
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
REPOSITORY = "trvon/yams"
START = "<!-- yams:draft-promotion:start -->"
END = "<!-- yams:draft-promotion:end -->"
MAX_PAGES = 5
class PolicyError(RuntimeError):
"""Refuse ambiguous or untrusted reconciliation without mutating a PR."""
class APIError(RuntimeError):
def __init__(self, status):
self.status = status
super().__init__(
f"GitHub API returned HTTP {status}; no permissions/settings were changed"
)
def object_value(value):
if not isinstance(value, dict):
raise PolicyError("Expected a JSON object")
return value
def sha(value):
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{40}", value):
raise PolicyError("Expected an immutable commit SHA")
return value
def trusted_context(env, event):
expected = REPOSITORY + "/.github/workflows/draft-promotion.yml@refs/heads/main"
if (
env.get("GITHUB_REPOSITORY") != REPOSITORY
or env.get("GITHUB_REF") != "refs/heads/main"
or env.get("GITHUB_WORKFLOW_REF") != expected
):
raise PolicyError(
"Reconciliation must run from the canonical main-owned workflow"
)
sha(env.get("GITHUB_WORKFLOW_SHA"))
repo = object_value(object_value(event).get("repository"))
if repo.get("full_name") != REPOSITORY or repo.get("default_branch") != "main":
raise PolicyError("Unexpected repository/default branch")
# Draft promotion is manual-only: a maintainer dispatch from main is the sole trigger.
if env.get("GITHUB_EVENT_NAME") != "workflow_dispatch":
raise PolicyError("Expected a main-only manual workflow_dispatch")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise APIError(code) # Never forward the authorization header elsewhere.
class GitHubAPI:
def __init__(self, token):
if not token:
raise PolicyError("GITHUB_TOKEN is required")
self.token = token
self.opener = urllib.request.build_opener(NoRedirect())
def call(self, method, path, data=None):
request = urllib.request.Request(
f"https://api.github.com/repos/{REPOSITORY}/{path}",
method=method,
data=None if data is None else json.dumps(data).encode(),
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "yams-draft-promotion",
},
)
try:
with self.opener.open(request, timeout=20) as response:
body = response.read(4 * 1024 * 1024 + 1)
if len(body) > 4 * 1024 * 1024:
raise PolicyError(
"GitHub response exceeded the bounded response budget"
)
return json.loads(body)
except urllib.error.HTTPError as error:
# Do not log request headers, response bodies, credentials or human PR text.
raise APIError(error.code) from None
def exact_pull(pr, state):
pr = object_value(pr)
head, base = object_value(pr.get("head")), object_value(pr.get("base"))
if (
pr.get("state") != state
or head.get("ref") != "experimental"
or base.get("ref") != "main"
or object_value(head.get("repo")).get("full_name") != REPOSITORY
or object_value(base.get("repo")).get("full_name") != REPOSITORY
or type(pr.get("number")) is not int
or pr["number"] <= 0
):
raise PolicyError(
"PR response does not identify the exact canonical branch pair"
)
return pr
def list_pulls(api, state):
matches = []
for page in range(1, MAX_PAGES + 1):
query = urllib.parse.urlencode(
{
"state": state,
"base": "main",
"head": "trvon:experimental",
"per_page": 100,
"page": page,
}
)
rows = api.call("GET", "pulls?" + query)
if not isinstance(rows, list):
raise PolicyError("Expected a PR list")
matches.extend(exact_pull(pr, state) for pr in rows)
if len(rows) < 100:
return matches
raise PolicyError(
"PR pagination budget exhausted; reconcile manually instead of guessing"
)
def owned_section(base, head, ahead, behind):
return f"""{START}
## Automated promotion visibility (not approval)
Observed `main`: `{base}`; `experimental`: `{head}`.
Experimental is {ahead} commits ahead and {behind} behind this observed main.
This snapshot is informational, not a validation of the current PR head.
Promotion, merge, version selection and publication remain maintainer decisions.
Release Please owns version changes; this automation never marks a PR ready or merges it.
### Promotion Merge Policy (MANDATORY)
- **Merge Strategy**: You MUST select **"Create a merge commit"** (`git merge --no-ff`) or fast-forward.
- **NEVER Squash and Merge**: Squash-merging into `main` collapses all individual `feat:`, `fix:`, and `perf:` commits into a single generic commit, destroying Release Please semver calculation and changelog generation. It also severs Git ancestry between `main` and `experimental`, causing future merge conflicts and candidate validation failures.
- **Back-Merge Requirement**: Any hotfixes or release commits landing on `main` must be back-merged immediately into `experimental` (`git checkout experimental && git merge main`).
**Check-trigger warning:** PRs created with `GITHUB_TOKEN` do not trigger the normal
PR-opened workflows. A maintainer must verify all required checks for the current head;
if absent, manually **close and reopen** this PR using their own authenticated account.
Do not treat this draft or the upstream Tests event as release approval.
This is an initial snapshot. Future runs leave existing PR text and state untouched.
{END}"""
def reconcile(api, env, event, *, apply=False):
trusted_context(env, event) # Before even read-only API requests.
base = sha(object_value(api.call("GET", "git/ref/heads/main"))["object"]["sha"])
head = sha(
object_value(api.call("GET", "git/ref/heads/experimental"))["object"]["sha"]
)
# GitHub serves changed-file patches only on page 1. Page 2 retains summary counts,
# even with zero/one commits, without downloading arbitrary candidate file patches.
comparison = object_value(
api.call("GET", f"compare/{base}...{head}?per_page=1&page=2")
)
ahead, behind = comparison.get("ahead_by"), comparison.get("behind_by")
if any(type(n) is not int or n < 0 for n in (ahead, behind)):
raise PolicyError("Invalid comparison counts")
summary = {"base": base, "head": head, "ahead": ahead, "behind": behind}
if ahead == 0:
return {**summary, "action": "no-changes"}
section = owned_section(base, head, ahead, behind)
def existing_summary(pulls):
if len(pulls) != 1:
raise PolicyError("Ambiguous open promotion PRs")
# GitHub already updates the PR's branch diff on push. Do not PATCH metadata:
# full-body writes can overwrite concurrent human edits, even within a
# workflow concurrency group. No undocumented conditional-write assumption.
return {**summary, "action": "unchanged", "number": pulls[0]["number"]}
opened = list_pulls(api, "open")
if opened:
return existing_summary(opened)
# A maintainer dispatch is an explicit request, so a previously closed PR for the same head
# does not block recreating the draft.
if not apply:
return {**summary, "action": "would-create"}
try:
created = api.call(
"POST",
"pulls",
{
"title": "chore: promote experimental to main",
"head": "experimental",
"base": "main",
"draft": True,
"body": section,
},
)
pr = exact_pull(created, "open")
if pr.get("draft") is not True:
raise PolicyError(
"Creation response did not confirm a draft; inspect the PR manually"
)
return {**summary, "action": "created", "number": pr["number"]}
except APIError as error:
if error.status != 422:
raise
# A maintainer/another run may have created the PR after our initial read.
opened = list_pulls(api, "open")
if not opened:
raise
return existing_summary(opened)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--apply",
action="store_true",
help="Create a missing draft visibility PR; never update an existing PR",
)
args = parser.parse_args()
try:
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
trusted_context(os.environ, event)
result = reconcile(
GitHubAPI(os.environ.get("GITHUB_TOKEN")),
os.environ,
event,
apply=args.apply,
)
except (
PolicyError,
APIError,
urllib.error.URLError,
ValueError,
KeyError,
TypeError,
OSError,
) as error:
parser.exit(1, f"Draft reconciliation failed: {error}\n")
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())