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
30 changes: 30 additions & 0 deletions scripts/loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ci-id>/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
Expand Down
23 changes: 14 additions & 9 deletions scripts/loop/shared/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading