diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 209c175..4cab1ff 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -91,6 +91,36 @@ Two consequences worth stating plainly: `permissions:` cannot grant `administration`, so the loop cannot delete or weaken that ruleset. `gh-check.mjs` asserts exactly that pair of properties (see below). +**A loop PR starts with its workflows unrun.** GitHub suppresses workflow runs for +events raised by `GITHUB_TOKEN` — the same recursion guard that stops the loop +triggering itself with a label — with one exception: a `pull_request` opened or +updated by `GITHUB_TOKEN` does create runs, but in an **approval-required** state. +Nothing runs until a human approves. + +That has a consequence worth knowing before it confuses you: **a loop PR shows no +checks at all**, which reads like a broken workflow. It is not — CI is waiting. + +Approval is per run, which is the useful part. A loop PR creates two: `CI` and +`Loop` (the latter because the PR is loop-produced, so it routes to `pr-review`). +Approving both makes the loop review its own work; approving only the first gives you +the thing you actually want: + +```bash +gh api 'repos/Alex1990/tiny-oss/actions/runs?status=action_required' \ + --jq '.workflow_runs[] | "\(.id) \(.name)"' +gh api --method POST repos/Alex1990/tiny-oss/actions/runs//approve +``` + +The UI's *Approve workflows to run* button approves every pending run for that PR, +so use it when you do not mind the self-review, and the API when you do. Verified on +PR #50: approving only the `CI` run left `Loop` at `completed/action_required` while +CI ran and passed. + +This is deliberately worked around no further than a click. Switching the push and +the PR creation to a PAT would start CI by itself, and would also hand the loop the +owner's ruleset bypass — see the table above. One click is the cheaper half of that +trade. + What bounds the loop beyond `main` is therefore *not* a credential: the host owns every write (`applyActions` derives them from the agent's `result.json`) as a division of labour, and the agent is asked not to write — but if it did, the ruleset would diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index d425c96..612897c 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -653,9 +653,10 @@ export async function finishRun(s, { // downgrades the run to `failed`: the task goes to the inbox, where a human sees it, // instead of `waiting-merge`, where nothing would ever resolve it. let created = null; + let executed = []; if (t && !noGithub && writeLevel === 'auto' && outcome === 'pr-opened' && push) { - const done = applyActions(planPush(t, push, ghRepoOf(t.url)), { log, warn, cwd, exec }); - created = done.find((a) => a.kind === 'pr-create' && a.prNumber) ?? null; + executed = applyActions(planPush(t, push, ghRepoOf(t.url)), { log, warn, cwd, exec }); + created = executed.find((a) => a.kind === 'pr-create' && a.prNumber) ?? null; if (created) pr = created.prNumber; else { outcome = 'failed'; @@ -692,15 +693,19 @@ export async function finishRun(s, { await saveTask(s, t); await releaseLock(s, t, t.stage); - const actions = noGithub ? [] : planActions(t, outcome, { - // The push/PR actions already ran above; passing `null` here would drop the label - // too, so pass them through only when they have not been executed. + // What the run reports as its GitHub actions. The two that create the PR already ran + // above and are prepended, so the Step Summary and the log count them: reporting only + // the label and comment would hide the two writes that matter most. + const rest = noGithub ? [] : planActions(t, outcome, { + // Passing the push proposal again would re-plan actions that have already executed; + // `null` here drops their replanning while keeping the label and the comment. label: label ?? m.label, comment, note, push: created ? null : push, }); - let applied = created ? [created] : []; - if (actions.length) { - if (writeLevel === 'auto') applied = [...applied, ...applyActions(actions, { log, warn, cwd, exec })]; - else await appendActionBlock(s, runId, actions); + const actions = [...executed, ...rest]; + let applied = [...executed]; + if (rest.length) { + if (writeLevel === 'auto') applied = [...applied, ...applyActions(rest, { log, warn, cwd, exec })]; + else await appendActionBlock(s, runId, rest); } // A PR the host just opened exists on GitHub but not yet in the state layer: record