diff --git a/.github/workflows/remi-proof.yml b/.github/workflows/remi-proof.yml index 60cd0c1..d9b2679 100644 --- a/.github/workflows/remi-proof.yml +++ b/.github/workflows/remi-proof.yml @@ -8,7 +8,7 @@ # - otherwise: pending ("needs a remi pass at ") until a maintainer, or a Claude session working for one, # records remi's verdict on that exact commit (CONTRIBUTING.md, layer 4, "Proof on remi"). Statuses belong to a commit, so # every push starts over. -# A verdict someone recorded by hand is never overwritten here, whatever the state. +# A recorded verdict (remi-verdict.yml's, or one a person posted) is never overwritten here, whatever the state. # # remi is never reached from here, and nothing here can start a run there: remi only runs what a maintainer starts. # @@ -60,8 +60,9 @@ jobs: # the entry point that runs them HARNESS = re.compile(r"(tools/|adapters/|boxes/|wend$)") # the workflows that define the required checks run from the PR (gate.yml) or become the next judge - # (this file): nothing in CI judges a change to them either. Dependabot's own action bumps are exempt. - CHECKS = re.compile(r"\.github/workflows/(gate|remi-proof)\.yml$") + # (this file, and remi-verdict.yml, which records verdicts): nothing in CI judges a change to them either. + # Dependabot's own action bumps are exempt. + CHECKS = re.compile(r"\.github/workflows/(gate|remi-proof|remi-verdict)\.yml$") # package and unit names (a prefix of the name, any case) BOOTNET = re.compile( r"(kernel|linux-(image|headers|modules|generic|server|virtual|signed)|linux-firmware|grub|shim|efibootmgr" @@ -165,10 +166,12 @@ jobs: print(f"#{PR} head {sha}") def stand_by_hand_verdict(): - """A verdict recorded by hand stands; only this workflow's own success/pending is ours to replace.""" + """A recorded verdict stands: one a person posted, or one remi-verdict.yml posted for them (it's + github-actions[bot]'s too, and says so). Only this workflow's own success/pending is ours to replace.""" mine = [s for s in api(f"repos/{REPO}/commits/{sha}/statuses?per_page=100") if s["context"] == CONTEXT] - if mine and (mine[0].get("creator") or {}).get("login") != "github-actions[bot]": - print(f"already recorded by hand: {mine[0]['creator']['login']} {mine[0]['state']}") + if mine and ((mine[0].get("creator") or {}).get("login") != "github-actions[bot]" + or (mine[0].get("description") or "").startswith("remi verdict:")): + print(f"verdict already recorded: {mine[0]['state']}: {mine[0].get('description')}") sys.exit(0) stand_by_hand_verdict() diff --git a/.github/workflows/remi-verdict.yml b/.github/workflows/remi-verdict.yml new file mode 100644 index 0000000..199cba5 --- /dev/null +++ b/.github/workflows/remi-verdict.yml @@ -0,0 +1,71 @@ +# Records remi's verdict on a PR's exact head as its `remi-proof` status (CONTRIBUTING.md, layer 4, "Proof on remi"). +# +# Run by hand, after a maintainer-run proof on remi: Actions, remi-verdict, Run workflow (or the dispatch API). Only +# people with write access can start it, the same people who could post the status themselves; it exists because the +# API proxy Claude sessions use can dispatch a workflow but can't write a commit status. It never touches remi. +# +# It refuses a commit that isn't the PR's current head, so a pass on an old push can't green a newer one. Its statuses +# say "remi verdict:", which is how remi-proof.yml tells them from its own (both are github-actions[bot]'s) and leaves +# them standing. +name: remi-verdict + +on: + workflow_dispatch: + inputs: + pr: { description: "PR number", required: true } + sha: { description: "the full commit remi ran (run.json head.commit)", required: true } + state: { description: "remi's verdict", required: true, type: choice, options: [success, failure] } + summary: { description: "what ran and how it ended, e.g. trusty-to-resolute on ubuntu/trusty-web, BIOS, passed", required: true } + link: { description: "the write-up: a PR comment or thread (optional)", required: false } + +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + record: + runs-on: ubuntu-24.04 + steps: + - env: + GH_TOKEN: "${{ github.token }}" + REPO: "${{ github.repository }}" + ACTOR: "${{ github.actor }}" + PR: "${{ inputs.pr }}" + SHA: "${{ inputs.sha }}" + STATE: "${{ inputs.state }}" + SUMMARY: "${{ inputs.summary }}" + LINK: "${{ inputs.link }}" + shell: python3 -I {0} + run: | + import json, os, re, sys, urllib.request + + REPO, PR, SHA, STATE = os.environ["REPO"], os.environ["PR"], os.environ["SHA"], os.environ["STATE"] + + def api(path, body=None): + req = urllib.request.Request("https://api.github.com/" + path, method="POST" if body else "GET", + data=json.dumps(body).encode() if body else None) + req.add_header("Authorization", "Bearer " + os.environ["GH_TOKEN"]) + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + if not re.fullmatch(r"[0-9]+", PR) or not re.fullmatch(r"[0-9a-f]{40}", SHA) or STATE not in ("success", "failure"): + sys.exit("need a PR number, a full 40-character commit and success or failure") + pr = api(f"repos/{REPO}/pulls/{PR}") + if pr["state"] != "open": + sys.exit(f"#{PR} is not open") + if pr["head"]["sha"] != SHA: + sys.exit(f"#{PR}'s head is {pr['head']['sha']}, not {SHA}: remi has to prove the current head") + + link = os.environ["LINK"].strip() + if link and not re.fullmatch(r"https://[^\s]+", link): + sys.exit("the link must be an https URL") + desc = f"remi verdict: {os.environ['SUMMARY'].strip()}" + desc = desc if len(desc) <= 140 else desc[:139] + "…" + body = {"state": STATE, "context": "remi-proof", "description": desc} + if link: + body["target_url"] = link + api(f"repos/{REPO}/statuses/{SHA}", body) + print(f"#{PR} {SHA[:7]}: remi-proof {STATE}, recorded for {os.environ['ACTOR']}: {desc}") diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ba0ac1..51c20ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ PRs pass at once: the gate's replays are their proof. A PR stays pending until s verdict on its exact head commit when it: - touches `tools/`, `adapters/`, `boxes/` or `wend`, or the workflows behind the required checks (`gate.yml`, - `remi-proof.yml`; Dependabot's own action bumps excepted); + `remi-proof.yml`, `remi-verdict.yml`; Dependabot's own action bumps excepted); - adds, changes, removes, pins or unpins a fix a chroot can't judge (before or after the change), because it works on what a chroot never boots: a `net.*` or `fs.layout` op, or one that names a kernel, bootloader, initramfs, storage, init, network or firewall package or unit in any argument (`install`'s packages, `rpm.transit`'s @@ -86,28 +86,32 @@ verdict on its exact head commit when it: Statuses belong to a commit, so every push is judged again from scratch. -To record a verdict, check that the run's `run.json` `head` is the PR's current head and not dirty, then: +To record a verdict, check that the run's `run.json` `head` is the PR's current head and not dirty, then run the +`remi-verdict` workflow (Actions, remi-verdict, Run workflow). It needs write access, refuses a commit that isn't the +PR's head, and posts the status as "remi verdict: ": ``` -pr=123 -sha=$(gh pr view $pr -R FieldmouseWorks/wend --json headRefOid -q .headRefOid) # must equal run.json head.commit -state=success # or failure -desc="remi: trusty-to-resolute on ubuntu/trusty-web, BIOS, passed" # 140 characters at most -gh api -X POST repos/FieldmouseWorks/wend/statuses/$sha -f context=remi-proof -f state=$state \ - -f description="$desc" -f target_url="" +gh workflow run remi-verdict.yml -R FieldmouseWorks/wend -f pr=123 -f sha= -f state=success \ + -f summary="trusty-to-resolute on ubuntu/trusty-web, BIOS, passed" -f link="" ``` -A Claude session whose `gh` isn't signed in but whose proxy authenticates GitHub API calls sends the same with curl: +A Claude session whose `gh` isn't signed in but whose proxy authenticates GitHub API calls sends the same with curl +(the proxy refuses a direct commit status write, but not this): ``` -curl -fsS -X POST -H "Content-Type: application/json" https://api.github.com/repos/FieldmouseWorks/wend/statuses/$sha \ - -d "$(jq -n --arg s "$state" --arg d "$desc" --arg u "" '{context:"remi-proof",state:$s,description:$d,target_url:$u}')" +curl -fsS -X POST -H "Content-Type: application/json" \ + https://api.github.com/repos/FieldmouseWorks/wend/actions/workflows/remi-verdict.yml/dispatches \ + -d "$(jq -n --arg pr 123 --arg sha --arg s success --arg m "" --arg l "" \ + '{ref:"main",inputs:{pr:$pr,sha:$sha,state:$s,summary:$m,link:$l}}')" ``` -A verdict recorded this way stands: the workflow never overwrites a status a person or a session set. Record it with -a person's own credentials, never from an Actions job's `GITHUB_TOKEN`: the workflow tells its own statuses apart by -that token's account. To re-judge an -open PR that was opened before the workflow existed, run it by hand: Actions, remi-proof, Run workflow, PR number. +Then check the run in Actions (it fails, posting nothing, on a stale or wrong commit) and the PR's `remi-proof` status. +Someone with a token that can write statuses may also post the status directly (`gh api -X POST +repos/FieldmouseWorks/wend/statuses/ -f context=remi-proof -f state=success -f description=...`). A recorded +verdict stands: remi-proof.yml never overwrites a status a person posted or one whose description starts with +"remi verdict:". Never post one from any other workflow: the classifier treats the rest of github-actions[bot]'s +statuses as its own. To re-judge an open PR that was opened before the workflow existed, run it by hand: Actions, +remi-proof, Run workflow, PR number. ### 5. Claude review (automatic, minutes)