Repository navigation
234 lines (220 loc) · 11.5 KB
/
Copy pathloop.yml
File metadata and controls
234 lines (220 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
name: Loop
# Single workflow = scheduler + sandbox (05 §5.1): every event joins one serial
# domain, and the concurrency group provides the platform-level single-writer
# lock (R2 has no concurrent writes).
on:
issues:
# `labeled` carries the claim signal (`ready-for-agent`): triage's verdict when the
# loop applied it, or a human claiming an issue for the loop by hand.
types: [opened, reopened, edited, labeled]
issue_comment:
types: [created]
pull_request:
types: [opened, synchronize]
pull_request_review:
types: [submitted, dismissed]
pull_request_target:
types: [closed]
release:
types: [published]
schedule:
# Daily sweep: reconcile GitHub against the state layer, expire stale tasks,
# fold in dependency/security notices, refresh SUMMARY.
- cron: '17 3 * * *'
# Weekly retro: aggregate the two metrics and propose process changes.
# Must equal WEEKLY_CRON in scripts/loop/shared/route.mjs — the router
# compares the cron string exactly, so a typo here silently degrades to a
# sweep rather than failing loudly.
- cron: '23 3 * * 1'
workflow_dispatch:
inputs:
task:
description: 'Task number (issue/PR). Empty = stage-only run.'
required: false
stage:
description: 'triage | bugfix | feature | pr-review | deps | security | sweep | release'
required: false
default: triage
execute_writes:
description: 'Override the repo variable for this run: "true" writes, "false" reports. Leave empty to follow vars.LOOP_EXECUTE_WRITES.'
required: false
smoke:
description: 'Infrastructure only: exercise checkout/install/R2 pull+push, skip the agent'
required: false
default: 'false'
model:
description: 'Override the engine model id (default deepseek/deepseek-v4-flash). A bad value must fail the D19 guard.'
required: false
concurrency:
group: loop
cancel-in-progress: false
# The loop's writes use the job token, and the *ruleset* is what keeps it out of
# `main`: `Main branch` requires every change to arrive through a pull request with
# one approving review, and its only bypass actor is the repository owner in
# `pull_request` mode — so an unapproved direct push is refused for every credential
# including the loop's. `permissions:` carries no `administration`, so the loop
# cannot weaken that ruleset either.
#
# `actions: write` is the newest scope and the narrowest justified one: triage hands a
# claimed task to the implementing stage with `gh workflow run`, because a label the
# loop applies with `GITHUB_TOKEN` raises an event GitHub deliberately does not turn
# into a run. It lets the loop start workflows — not change `main`, not edit workflow
# files (that needs `workflows`, which `permissions:` cannot grant).
permissions:
contents: write
issues: write
pull-requests: write
actions: write
jobs:
loop:
runs-on: ubuntu-24.04
timeout-minutes: 60
# Skip any `pull_request` run that cannot authenticate.
#
# `pull_request` is the only event we listen to where secrets can be absent:
# GitHub treats a Dependabot-triggered run like a fork run and issues a
# read-only token with **no secrets at all**, and a fork PR never has them.
# Unguarded, such a run dies at `Pull state from R2` and shows up as a red
# loop — indistinguishable from a real defect — while it cannot do anything
# useful anyway (no R2 credentials, no LLM key).
#
# `pull_request_target` (loop PR closed) does carry secrets, as do
# issues / schedule / release / workflow_dispatch, so those still run.
#
# Deliberately accepted for A1: read-only analysis of an external PR (host
# spec §6) therefore cannot run, because even a read-only analysis needs the
# LLM key. Enabling it means giving that path its own credentials — see D28
# in scripts/loop/README.md.
if: >-
github.event_name != 'pull_request'
|| (github.event.pull_request.head.repo.full_name == github.repository
&& github.actor != 'dependabot[bot]')
# Note: job-level env can only reference contexts like github / secrets / vars.
# `inputs` is unavailable for non-workflow_dispatch events, and the `runner`
# context exists only inside steps — neither belongs here (this once broke
# workflow parsing, with GitHub only reporting "workflow file issue").
env:
LOOP_EVENT_NAME: ${{ github.event_name }}
LOOP_EVENT_ACTION: ${{ github.event.action }}
LOOP_EVENT_JSON: ${{ toJSON(github.event) }}
LOOP_REPO: ${{ github.repository }}
# Write level for non-dispatch events. A1 shipped `false` (report-only); L2 flips
# it by setting the repository variable LOOP_EXECUTE_WRITES=true — no code change,
# and one place for a human to turn it back off. `workflow_dispatch` can still
# override per run (the rehearsal path).
LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || vars.LOOP_EXECUTE_WRITES || 'false' }}
LOOP_SMOKE: ${{ github.event.inputs.smoke || 'false' }}
LOOP_MODEL: ${{ github.event.inputs.model || 'deepseek/deepseek-v4-flash' }}
LOOP_RUN_TIMEOUT_MS: '3300000' # 55min, leaving headroom for push (job timeout 60min)
# The loop's only GitHub credential: the job token. What it may do to `main`
# is decided by the `Main branch` ruleset, not by this file — see the
# `permissions:` block above. No PAT is involved anywhere in the loop.
GH_TOKEN: ${{ github.token }}
# Credentials: secrets only
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
# Non-sensitive config: prefer the repository variable, falling back to the
# same-named secret (both setups are supported). ACCOUNT_ID is part of the
# R2 endpoint (https://<id>.r2.cloudflarestorage.com), not a credential.
R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }}
# The bucket name needs no config: the code derives it from
# `loop-state-<owner>-<repo>` (one bucket per repo). Set it only to point at
# another bucket (migration/troubleshooting); unset expands to an empty
# string → derivation is used.
R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }}
steps:
# What to check out — decided by "is this code trusted":
#
# workflow_dispatch → github.ref. The human-driven smoke/rehearsal entry;
# dispatch can only target refs in this repo, so fork
# branches are excluded and it stays safe.
# same-repo PR → the PR's head branch. The PRs loop itself opens
# (`loop/<n>-*`) fall here, and pr-review must get the
# PR code to review it — checking out only the default
# branch would leave pr-review blind to the content
# under review. Same-repo branches can only be created
# by OWNER/MEMBER/COLLABORATOR, so the code is trusted.
# everything else → the default branch. **Fork PRs land here**: never
# check out external code, use only trusted scripts
# from this repo's default branch, and read diffs with
# `gh pr diff`. Same for issues / schedule / release /
# pull_request_target.
- uses: actions/checkout@v4
with:
# Its default is `true`, and it writes the job token into the repository's git
# config so that later `git` invocations pick it up implicitly — including the
# agent's. The loop does not need that: the host pushes through
# `http.extraheader` with an explicit token. Defence in depth, not the
# mechanism — what bounds the loop is the `Main branch` ruleset.
persist-credentials: false
ref: >-
${{
github.event_name == 'workflow_dispatch' && github.ref
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& github.event.pull_request.head.ref)
|| github.event.repository.default_branch
}}
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install engine (pi)
run: |
npm i -g @earendil-works/pi-coding-agent@0.85.1
pi --version
echo '--- available DeepSeek models ---'
available=$(pi --list-models deepseek)
echo "$available"
# pi **silently falls back to the default model** for an invalid
# --model rather than erroring — a misspelled model name would quietly
# run every run with the wrong model. Validate right after install.
want="${LOOP_MODEL#*/}"
if ! grep -qE "(^|[[:space:]])${want}([[:space:]]|$)" <<<"$available"; then
echo "::error::LOOP_MODEL=$LOOP_MODEL not in pi's DeepSeek catalog (want '$want')"
exit 1
fi
echo "model $want is available"
# Assert the loop's safety property before anything can act on it. The property
# is "nothing reaches `main` without a human", and it lives in repository
# configuration no file here can express — so it is measured, not restated:
# an idempotent write to `refs/heads/main` must be refused for a pull request,
# and the credential must not be able to edit the ruleset that refuses it.
# Side-effect free: the ref probe writes back the SHA it just read.
- name: Check the main-branch boundary
run: node scripts/loop/gh-check.mjs
- name: Pull state from R2
id: pull
run: node scripts/loop/r2-sync.mjs pull
- name: Run stage
env:
LOOP_SESSION_DIR: ${{ runner.temp }}/pi-sessions
run: node scripts/loop/entry.mjs
# Push the state layer back even on crash/timeout (the task stays in
# processing and is reclaimed by the next sweep via TTL).
#
# Guarded on `pull` having *run*: `state/` is gitignored, so it exists only
# after a pull. If an earlier step failed, pull is skipped, the directory
# never appears, and an unguarded push dies on
# `aws: [ERROR]: The user-provided path ... does not exist` — a second red
# step that buries the real cause and reads like R2 itself broke (D34).
# `!= 'skipped'` rather than `== 'success'` on purpose: a *failed* pull may
# still have left a usable tree, and the point of `always()` is to record
# the processing state after a mid-run crash.
- name: Push state to R2
if: always() && steps.pull.outcome != 'skipped'
run: node scripts/loop/r2-sync.mjs push
- name: Upload session transcripts
if: always()
uses: actions/upload-artifact@v4
with:
name: loop-sessions-${{ github.run_id }}
path: ${{ runner.temp }}/pi-sessions
retention-days: 90
if-no-files-found: ignore