From bbdee1925fae6211eef4701b84fe83e69fab683c Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Wed, 16 Sep 2026 02:29:00 +0800 Subject: [PATCH 1/2] chore(loop): report every GitHub action a run took, not just the last two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real `pr-opened` run (task #48, PR #50) logged `GitHub actions executed: 2` while having executed four: `applyActions` ran the push and the PR creation in `finishRun`'s pre-flight, and only the label and comment were left in `actions` — the list the log line and the Step Summary count from. The two writes that matter most were the two the report did not mention, so the summary of a run that opened a PR read as if it had only tagged an issue. `finishRun` now keeps what the pre-flight executed and prepends it to the plan for the rest, so `actions` and `applied` describe the whole run in the order it happened (push → pr-create → label → comment). The report-boundary path is unaffected: `validatePush` refuses `pr-opened` there, so the pre-flight never runs and nothing is prepended. --- scripts/loop/README.md | 20 ++++++++++++++++++++ scripts/loop/shared/state.mjs | 23 ++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 209c175..0c2615b 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -91,6 +91,26 @@ 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 clicks *Approve workflows to run* in the PR's merge box. + +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 for +the click. In order: + +1. `gh pr view ` → review the diff. +2. Click *Approve workflows to run* (or `gh workflow run` the CI job at that ref). +3. CI runs; then approve/merge as usual — merging is your bypass. + +This is deliberately left in place rather than worked around. Switching the push and +the PR creation to a PAT would make CI start by itself, and would also hand the loop +the owner's ruleset bypass — see the table above. One human click is the cheaper +half of that trade. The same click also removes any chance of the loop reviewing its +own PR: `pr-review` cannot fire on it until someone approves the run. + 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 From 67b17ad3702745686046046277fc35ff16f2fcfa Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Wed, 16 Sep 2026 02:34:11 +0800 Subject: [PATCH 2/2] docs(loop): approving a loop PR's runs one at a time is the better default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real loop PR (#50) showed the approval-required state is per *run*, not per PR: `CI` and `Loop` both sat at `completed/action_required`, and approving just the first ran CI to green while leaving `Loop` untouched — that is, the loop verified its own change without reviewing its own work. The UI button approves both, so the text now names the API path (`POST /actions/runs//approve`) and says which to pick when. --- scripts/loop/README.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 0c2615b..4cab1ff 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -95,21 +95,31 @@ that ruleset. `gh-check.mjs` asserts exactly that pair of properties (see below) 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 clicks *Approve workflows to run* in the PR's merge box. +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 for -the click. In order: - -1. `gh pr view ` → review the diff. -2. Click *Approve workflows to run* (or `gh workflow run` the CI job at that ref). -3. CI runs; then approve/merge as usual — merging is your bypass. - -This is deliberately left in place rather than worked around. Switching the push and -the PR creation to a PAT would make CI start by itself, and would also hand the loop -the owner's ruleset bypass — see the table above. One human click is the cheaper -half of that trade. The same click also removes any chance of the loop reviewing its -own PR: `pr-review` cannot fire on it until someone approves the run. +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