@plainconceptsplatform/workflows 1.0.14 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,460 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/evidence.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: "Agentics: Evidence"
3
+
4
+ # Archives what a merged pull request looks like. The agent that implemented the change wrote a
5
+ # verification plan with the harness skill pc-repo-verify; this replays that plan against the
6
+ # app, with no model, and keeps one screenshot per waypoint beside the plan it came from. The
7
+ # router calls it when a pull request merges, and operation=evidence runs it by hand for any pull
8
+ # request. Nothing waits on it: it is the record, not a gate. Project Health reads the manifest
9
+ # embedded in the comment to show the screenshots with each issue.
10
+ #
11
+ # Two jobs, because one runs the pull request's code and the other writes. `capture` checks out
12
+ # the head, starts the app and drives Chromium with a read-only token and no secret. `publish`
13
+ # checks nothing out: it takes the images as an artifact, commits them into an `evidence/` folder
14
+ # beside each plan on the default branch and writes one comment. Both use GITHUB_TOKEN, so a
15
+ # repository configures nothing, and nothing either job writes raises another workflow event.
16
+ #
17
+ # This replaced agent-visual-verify, which never produced a screenshot: it ran only on pull
18
+ # requests nobody would review, raced the merge it was dispatched before, re-planned with a model
19
+ # what implement had already planned, and published through `gh --attach`, which refuses the
20
+ # installation tokens a workflow has.
21
+
22
+ on:
23
+ workflow_call:
24
+ inputs:
25
+ pr-number:
26
+ description: Pull request whose verification plans are replayed.
27
+ required: true
28
+ type: string
29
+
30
+ env:
31
+ # The command that brings the whole stack up with mock auth. Every Platform repository has it at
32
+ # the root; a plan that needs something else names it in its own `start:`.
33
+ EVIDENCE_START_COMMAND: "pnpm run dev"
34
+ # How long the app may take to answer before the run records that it never did. A cold .NET
35
+ # build plus SQL Server in a container is most of it.
36
+ EVIDENCE_START_TIMEOUT_SECONDS: "900"
37
+ # The SDK when a repository has .NET projects but no global.json to say which.
38
+ EVIDENCE_DOTNET_VERSION: "10.0.x"
39
+ EVIDENCE_MARKER: "<!-- agentic-evidence -->"
40
+ # How long the run keeps the screenshots it could not archive: a pull request that is not merged
41
+ # has no plan on the default branch to keep them beside.
42
+ EVIDENCE_ARTIFACT_DAYS: "14"
43
+ # The capture runs in mcr.microsoft.com/playwright at this tag, pre-pulled by the runner image
44
+ # (runners/versions.env), so the two move together.
45
+ PLAYWRIGHT_VERSION: "1.62.1"
46
+
47
+ jobs:
48
+ capture:
49
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
50
+ timeout-minutes: 45
51
+ permissions:
52
+ contents: read
53
+ pull-requests: read
54
+ outputs:
55
+ has-evidence: ${{ steps.done.outputs.has-evidence }}
56
+ steps:
57
+ - name: Checkout workflow actions
58
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
59
+ with:
60
+ persist-credentials: false
61
+
62
+ - name: Find the pull request's verification plans
63
+ id: plans
64
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
65
+ env:
66
+ PR_NUMBER: ${{ inputs.pr-number }}
67
+ with:
68
+ script: |
69
+ const pull_number = Number(process.env.PR_NUMBER);
70
+ const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number });
71
+ const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number, per_page: 100 });
72
+ // A plan that was only moved (the archive step, in a later pull request) is skipped: its
73
+ // evidence/ folder moves with it, so replaying it would only repeat the record.
74
+ const plans = files
75
+ .filter((file) => file.status !== 'removed' && /(^|\/)verification-plan\.md$/.test(file.filename))
76
+ .filter((file) => !(file.status === 'renamed' && file.changes === 0))
77
+ .map((file) => file.filename);
78
+ core.setOutput('sha', pr.head.sha);
79
+ core.setOutput('plans', plans.join('\n'));
80
+ core.setOutput('found', plans.length > 0 ? 'true' : 'false');
81
+ core.info(plans.length > 0
82
+ ? `Verification plans in #${pull_number}: ${plans.join(', ')}`
83
+ : `#${pull_number} carries no verification plan; there is nothing to capture.`);
84
+
85
+ - name: Check out the pull request head
86
+ if: steps.plans.outputs.found == 'true'
87
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
88
+ with:
89
+ ref: ${{ steps.plans.outputs.sha }}
90
+ path: pr
91
+ persist-credentials: false
92
+
93
+ # A plan that only says "no UI surface", or that is prose with no journey, starts nothing and
94
+ # writes nothing: a comment on every backend pull request saying so would be noise.
95
+ - name: Read the plans
96
+ id: inspect
97
+ if: steps.plans.outputs.found == 'true'
98
+ uses: ./.github/actions/capture-evidence
99
+ with:
100
+ mode: inspect
101
+ root: pr
102
+ plans: ${{ steps.plans.outputs.plans }}
103
+ playwright-version: ${{ env.PLAYWRIGHT_VERSION }}
104
+
105
+ - name: Detect the toolchain the app needs
106
+ id: toolchain
107
+ if: steps.inspect.outputs.runnable == 'true'
108
+ working-directory: pr
109
+ run: |
110
+ set -euo pipefail
111
+ global_json="$(find . -maxdepth 3 -name global.json -not -path '*/node_modules/*' | head -n 1)"
112
+ dotnet_project="$(find . -maxdepth 3 \( -name '*.slnx' -o -name '*.sln' -o -name '*.csproj' \) -not -path '*/node_modules/*' | head -n 1)"
113
+ if [ -n "$global_json" ] || [ -n "$dotnet_project" ]; then
114
+ echo "dotnet=true" >> "$GITHUB_OUTPUT"
115
+ else
116
+ echo "dotnet=false" >> "$GITHUB_OUTPUT"
117
+ fi
118
+ echo "global-json=${global_json#./}" >> "$GITHUB_OUTPUT"
119
+
120
+ - name: Set up .NET
121
+ if: steps.toolchain.outputs.dotnet == 'true'
122
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
123
+ with:
124
+ global-json-file: ${{ steps.toolchain.outputs.global-json != '' && format('pr/{0}', steps.toolchain.outputs.global-json) || '' }}
125
+ dotnet-version: ${{ steps.toolchain.outputs.global-json == '' && env.EVIDENCE_DOTNET_VERSION || '' }}
126
+ env:
127
+ # the default /usr/share/dotnet needs root; the tool cache belongs to the runner user
128
+ DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
129
+
130
+ - name: Install the app's dependencies
131
+ if: steps.inspect.outputs.runnable == 'true'
132
+ working-directory: pr
133
+ run: |
134
+ set -euo pipefail
135
+ # Local development reads .env; the example is what a new clone is told to copy.
136
+ if [ ! -f .env ] && [ -f .env.example ]; then cp .env.example .env; fi
137
+ if [ -f pnpm-lock.yaml ]; then
138
+ corepack enable
139
+ corepack prepare --activate
140
+ pnpm install --frozen-lockfile
141
+ elif [ -f package-lock.json ]; then
142
+ npm ci
143
+ elif [ -f package.json ]; then
144
+ npm install --no-audit --no-fund
145
+ fi
146
+
147
+ - name: Start the app
148
+ id: app
149
+ if: steps.inspect.outputs.runnable == 'true'
150
+ working-directory: pr
151
+ env:
152
+ START: ${{ steps.inspect.outputs.start || env.EVIDENCE_START_COMMAND }}
153
+ APP_URL: ${{ steps.inspect.outputs.url }}
154
+ TIMEOUT: ${{ env.EVIDENCE_START_TIMEOUT_SECONDS }}
155
+ run: |
156
+ set -euo pipefail
157
+ log="$RUNNER_TEMP/evidence-app.log"
158
+ # Its own session, so the whole tree it spawns stops with one signal at the end.
159
+ setsid bash -c "$START" > "$log" 2>&1 < /dev/null &
160
+ pid=$!
161
+ {
162
+ echo "pid=$pid"
163
+ echo "start=$START"
164
+ } >> "$GITHUB_OUTPUT"
165
+
166
+ waited=0
167
+ up=false
168
+ # Any HTTP answer counts, a 500 from a dev server still compiling included: the first
169
+ # waypoint's navigation waits for the page itself.
170
+ while [ "$waited" -lt "$TIMEOUT" ]; do
171
+ if curl -s -o /dev/null --max-time 5 "$APP_URL"; then
172
+ up=true
173
+ break
174
+ fi
175
+ if ! kill -0 "$pid" 2>/dev/null; then
176
+ echo "::warning::'$START' exited before the app answered at $APP_URL"
177
+ break
178
+ fi
179
+ sleep 5
180
+ waited=$((waited + 5))
181
+ done
182
+
183
+ {
184
+ echo "up=$up"
185
+ echo "waited=$waited"
186
+ } >> "$GITHUB_OUTPUT"
187
+ if [ "$up" = "true" ]; then
188
+ echo "The app answered at $APP_URL after ${waited}s."
189
+ else
190
+ echo "::warning::the app did not answer at $APP_URL after ${waited}s of '$START'; recording that instead of screenshots"
191
+ echo "::group::Last lines of the app's output"
192
+ tail -n 80 "$log" || true
193
+ echo "::endgroup::"
194
+ # An orchestrator (Aspire, compose) keeps each service's output to itself, so the
195
+ # start command's own log rarely says which service never came up. Show them.
196
+ echo "::group::Listening ports"
197
+ (ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null) | head -n 40 || true
198
+ echo "::endgroup::"
199
+ echo "::group::Containers"
200
+ docker ps -a --format '{{.Names}} {{.Status}} {{.Image}}' 2>/dev/null || true
201
+ echo "::endgroup::"
202
+ for id in $(docker ps -aq 2>/dev/null); do
203
+ echo "::group::Container $(docker inspect -f '{{.Name}}' "$id" 2>/dev/null)"
204
+ docker logs --tail 60 "$id" 2>&1 || true
205
+ echo "::endgroup::"
206
+ done
207
+ for f in "$HOME"/.aspire/logs/*.log; do
208
+ [ -f "$f" ] || continue
209
+ echo "::group::$f"
210
+ tail -n 200 "$f" || true
211
+ echo "::endgroup::"
212
+ done
213
+ fi
214
+
215
+ - name: Replay the plans
216
+ if: steps.app.outputs.up == 'true'
217
+ uses: ./.github/actions/capture-evidence
218
+ env:
219
+ EVIDENCE_SHA: ${{ steps.plans.outputs.sha }}
220
+ EVIDENCE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
221
+ with:
222
+ mode: capture
223
+ root: pr
224
+ plans: ${{ steps.plans.outputs.plans }}
225
+ out-dir: ${{ runner.temp }}/evidence
226
+ playwright-version: ${{ env.PLAYWRIGHT_VERSION }}
227
+
228
+ - name: Record that the app never answered
229
+ if: steps.app.outputs.up == 'false'
230
+ uses: ./.github/actions/capture-evidence
231
+ env:
232
+ EVIDENCE_SHA: ${{ steps.plans.outputs.sha }}
233
+ EVIDENCE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
234
+ EVIDENCE_APP_URL: ${{ steps.inspect.outputs.url }}
235
+ EVIDENCE_START: ${{ steps.app.outputs.start }}
236
+ EVIDENCE_WAITED_SECONDS: ${{ steps.app.outputs.waited }}
237
+ with:
238
+ mode: unreached
239
+ root: pr
240
+ plans: ${{ steps.plans.outputs.plans }}
241
+ out-dir: ${{ runner.temp }}/evidence
242
+
243
+ - name: Stop the app
244
+ if: always() && steps.app.outputs.pid != ''
245
+ env:
246
+ PID: ${{ steps.app.outputs.pid }}
247
+ run: |
248
+ kill -TERM -- "-$PID" 2>/dev/null || true
249
+ sleep 3
250
+ kill -KILL -- "-$PID" 2>/dev/null || true
251
+
252
+ - name: Hand the evidence to the publish job
253
+ id: done
254
+ if: always() && steps.plans.outputs.found == 'true'
255
+ run: |
256
+ if [ -f "$RUNNER_TEMP/evidence/comment.md" ]; then
257
+ echo "has-evidence=true" >> "$GITHUB_OUTPUT"
258
+ else
259
+ echo "has-evidence=false" >> "$GITHUB_OUTPUT"
260
+ fi
261
+
262
+ - name: Upload the evidence
263
+ if: always() && steps.done.outputs.has-evidence == 'true'
264
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
265
+ with:
266
+ name: evidence-pr-${{ inputs.pr-number }}
267
+ path: ${{ runner.temp }}/evidence
268
+ retention-days: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
269
+ if-no-files-found: error
270
+
271
+ publish:
272
+ needs: capture
273
+ if: always() && needs.capture.outputs.has-evidence == 'true'
274
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
275
+ timeout-minutes: 10
276
+ permissions:
277
+ contents: write
278
+ pull-requests: write
279
+ issues: write
280
+ steps:
281
+ - name: Download the evidence
282
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
283
+ with:
284
+ name: evidence-pr-${{ inputs.pr-number }}
285
+ path: ${{ runner.temp }}/evidence
286
+
287
+ - name: Read the pull request
288
+ id: pr
289
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
290
+ env:
291
+ PR_NUMBER: ${{ inputs.pr-number }}
292
+ with:
293
+ script: |
294
+ const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.PR_NUMBER) });
295
+ core.setOutput('merged', pr.merged ? 'true' : 'false');
296
+ core.setOutput('branch', pr.base.ref);
297
+
298
+ # Beside each plan on the default branch, where the plan itself is archived: the record stays
299
+ # with the change it proves, and moves with it if the change folder ever moves. Only a merged
300
+ # pull request's plans are there to keep it beside. A sparse, blob-less fetch reads only the
301
+ # plan folders, and [skip ci] plus GITHUB_TOKEN keep the commit from starting any workflow.
302
+ - name: Commit the screenshots beside their plans
303
+ id: archive
304
+ if: steps.pr.outputs.merged == 'true'
305
+ env:
306
+ TOKEN: ${{ github.token }}
307
+ REPO: ${{ github.repository }}
308
+ SERVER: ${{ github.server_url }}
309
+ BRANCH: ${{ steps.pr.outputs.branch }}
310
+ PR: ${{ inputs.pr-number }}
311
+ run: |
312
+ set -euo pipefail
313
+ src="$RUNNER_TEMP/evidence/files"
314
+ if [ ! -d "$src" ] || [ -z "$(find "$src" -name '*.jpg' -print -quit)" ]; then
315
+ echo "No screenshots to keep; the comment records why."
316
+ echo "base=" >> "$GITHUB_OUTPUT"
317
+ exit 0
318
+ fi
319
+
320
+ # Each folder holding screenshots is <plan directory>/evidence.
321
+ mapfile -t dirs < <(cd "$src" && find . -type d -name evidence | sed 's|^\./||; s|/evidence$||' | sort)
322
+
323
+ work="$RUNNER_TEMP/evidence-commit"
324
+ rm -rf "$work"
325
+ auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
326
+ # Blob-less and sparse: only the plan folders are downloaded, however large the repository.
327
+ git -c "http.${SERVER}/.extraheader=${auth}" clone -q --filter=blob:none --no-checkout --depth=1 \
328
+ --branch "$BRANCH" "${SERVER}/${REPO}.git" "$work"
329
+ cd "$work"
330
+ git config "http.${SERVER}/.extraheader" "$auth"
331
+ git config user.name "github-actions[bot]"
332
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
333
+ patterns=()
334
+ for dir in "${dirs[@]}"; do patterns+=("/$dir/"); done
335
+ git sparse-checkout set --no-cone "${patterns[@]}"
336
+
337
+ pushed=false
338
+ missing=()
339
+ for attempt in 1 2 3 4 5; do
340
+ if [ "$attempt" -eq 1 ]; then
341
+ git checkout -q "$BRANCH"
342
+ else
343
+ git fetch -q --depth=1 origin "$BRANCH"
344
+ git reset -q --hard FETCH_HEAD
345
+ fi
346
+ missing=()
347
+ for dir in "${dirs[@]}"; do
348
+ # A plan moved or removed since the merge has no folder to keep the record in.
349
+ if [ ! -f "$dir/verification-plan.md" ]; then
350
+ missing+=("$dir")
351
+ continue
352
+ fi
353
+ rm -rf "$dir/evidence"
354
+ mkdir -p "$dir/evidence"
355
+ cp -R "$src/$dir/evidence/." "$dir/evidence/"
356
+ git add -A "$dir/evidence"
357
+ done
358
+ if git diff --cached --quiet; then
359
+ pushed=true
360
+ break
361
+ fi
362
+ git commit -q -m "evidence: screenshots for #$PR [skip ci]"
363
+ if git push -q origin "HEAD:refs/heads/$BRANCH"; then
364
+ pushed=true
365
+ break
366
+ fi
367
+ echo "$BRANCH moved before the push (attempt $attempt); retrying on its new tip."
368
+ sleep $((attempt * 3))
369
+ done
370
+ if [ "$pushed" != "true" ]; then
371
+ # A protected default branch refuses the push. The comment says so, and the run's
372
+ # artifact keeps the screenshots, so the merge that already happened stays green.
373
+ echo "::warning::could not push the screenshots to $BRANCH after 5 attempts"
374
+ echo "base=" >> "$GITHUB_OUTPUT"
375
+ echo "reason=the push to $BRANCH was refused" >> "$GITHUB_OUTPUT"
376
+ exit 0
377
+ fi
378
+
379
+ if [ "${#missing[@]}" -eq "${#dirs[@]}" ]; then
380
+ echo "None of the plans is on $BRANCH any more; nothing was kept."
381
+ echo "base=" >> "$GITHUB_OUTPUT"
382
+ echo "reason=none of the plans is on $BRANCH any more" >> "$GITHUB_OUTPUT"
383
+ exit 0
384
+ fi
385
+
386
+ sha="$(git rev-parse HEAD)"
387
+ echo "base=${SERVER}/${REPO}/raw/${sha}" >> "$GITHUB_OUTPUT"
388
+ {
389
+ echo "missing<<EOF"
390
+ printf '%s\n' "${missing[@]}"
391
+ echo "EOF"
392
+ } >> "$GITHUB_OUTPUT"
393
+ echo "Screenshots kept at ${SERVER}/${REPO}/tree/${sha} beside $(( ${#dirs[@]} - ${#missing[@]} )) plan(s)"
394
+
395
+ # GITHUB_TOKEN on purpose: its comment raises no workflow event, so the router does not wake
396
+ # up to read its own record. One comment per pull request, rewritten on a re-run.
397
+ - name: Write the evidence comment
398
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
399
+ env:
400
+ PR_NUMBER: ${{ inputs.pr-number }}
401
+ MARKER: ${{ env.EVIDENCE_MARKER }}
402
+ MERGED: ${{ steps.pr.outputs.merged }}
403
+ IMAGE_BASE: ${{ steps.archive.outputs.base }}
404
+ MISSING: ${{ steps.archive.outputs.missing }}
405
+ REASON: ${{ steps.archive.outputs.reason }}
406
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
407
+ ARTIFACT_DAYS: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
408
+ with:
409
+ script: |
410
+ const fs = require('node:fs');
411
+ const path = require('node:path');
412
+ const issue_number = Number(process.env.PR_NUMBER);
413
+ const evidence = path.join(process.env.RUNNER_TEMP, 'evidence');
414
+ let source = fs.readFileSync(path.join(evidence, 'comment.md'), 'utf8');
415
+ const base = process.env.IMAGE_BASE ?? '';
416
+ const missing = (process.env.MISSING ?? '').split('\n').map((line) => line.trim()).filter(Boolean);
417
+ const kept = (file) => Boolean(base) && !missing.some((dir) => file.startsWith(`${dir}/evidence/`));
418
+
419
+ // An image that was not archived would render broken: its line goes, and a note says
420
+ // the run's artifact has it instead.
421
+ source = source.replace(/^!\[[^\]]*\]\(__EVIDENCE_BASE__\/([^)]*)\)\n?/gm, (line, file) => (kept(file) ? line : ''));
422
+ const shots = fs.readdirSync(evidence, { recursive: true }).some((name) => String(name).endsWith('.jpg'));
423
+ if (!base) source = source.replace(/^The screenshots are kept beside each plan.*\n\n?/m, '');
424
+ if (shots && (!base || missing.length > 0)) {
425
+ const why = process.env.MERGED !== 'true'
426
+ ? 'the pull request is not merged, so its plans are not on the default branch'
427
+ : !base
428
+ ? (process.env.REASON || 'the screenshots could not be committed')
429
+ : `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} no longer on the default branch`;
430
+ source = source.replace('## Visual evidence\n', `## Visual evidence\n\n> Not archived: ${why}. The screenshots are in [the run](${process.env.RUN_URL})'s artifact for ${process.env.ARTIFACT_DAYS} days.\n`);
431
+ }
432
+
433
+ // The embedded manifest is what other tools read, so it says exactly what was kept: the
434
+ // base where the files are, and no file for a screenshot that was not archived. It sits
435
+ // in an HTML comment, which a `--` would close; `\u002d` is the same to a JSON reader.
436
+ source = source.replace(/<!-- agentic-evidence-manifest\n([\s\S]*?)\n-->/, (block, json) => {
437
+ let data;
438
+ try {
439
+ data = JSON.parse(json);
440
+ } catch {
441
+ return block;
442
+ }
443
+ data.base = base || null;
444
+ for (const plan of data.plans ?? []) {
445
+ for (const waypoint of plan.waypoints ?? []) {
446
+ if (waypoint.file && !kept(waypoint.file)) waypoint.file = null;
447
+ }
448
+ }
449
+ return `<!-- agentic-evidence-manifest\n${JSON.stringify(data).replace(/-(?=-)/g, '\\u002d')}\n-->`;
450
+ });
451
+ const body = source.split('__EVIDENCE_BASE__').join(base);
452
+ const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number, per_page: 100 });
453
+ const existing = comments.find((comment) => comment.user?.type === 'Bot' && (comment.body ?? '').includes(process.env.MARKER));
454
+ if (existing) {
455
+ await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
456
+ core.info(`Updated ${existing.html_url}`);
457
+ } else {
458
+ const { data } = await github.rest.issues.createComment({ ...context.repo, issue_number, body });
459
+ core.info(`Created ${data.html_url}`);
460
+ }
@@ -0,0 +1,98 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/release-tag.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Release Tag
3
+
4
+ # The second half of the release route. agent-release.md opens a `chore(release): vX.Y.Z` pull
5
+ # request instead of pushing to `main`, so `main` can require pull requests. Once that PR is
6
+ # squash-merged, this tags the merge commit and publishes the GitHub Release with the notes the
7
+ # agent wrote, which the PR body carries. No model is involved.
8
+ #
9
+ # The tag and the Release are created with GITHUB_TOKEN, never the App token, and that is
10
+ # load-bearing. GITHUB_TOKEN writes start no workflows, so a consumer whose deploy triggers on
11
+ # `v*` tags (Numa's app-deploy.yml deploys to production on one) does not ship to production
12
+ # because a release was cut. That is exactly how the release behaved when it pushed the tag
13
+ # itself; moving the bump into a pull request must not change it.
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+
19
+ permissions:
20
+ contents: write
21
+ pull-requests: read
22
+
23
+ concurrency:
24
+ group: release-tag
25
+ cancel-in-progress: false
26
+
27
+ jobs:
28
+ tag:
29
+ name: Tag and publish the release
30
+ if: >-
31
+ startsWith(github.event.head_commit.message, 'chore(release): v')
32
+ runs-on: ubuntu-latest
33
+ timeout-minutes: 10
34
+ steps:
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
36
+ with:
37
+ persist-credentials: false
38
+ - name: Tag the release commit and publish the GitHub Release
39
+ env:
40
+ GH_TOKEN: ${{ github.token }}
41
+ REPO: ${{ github.repository }}
42
+ SHA: ${{ github.sha }}
43
+ HEAD_MESSAGE: ${{ github.event.head_commit.message }}
44
+ shell: bash
45
+ run: |
46
+ set -euo pipefail
47
+
48
+ # Squash keeps the PR title as the subject and appends ` (#123)`.
49
+ subject="${HEAD_MESSAGE%%$'\n'*}"
50
+ if ! [[ "$subject" =~ ^chore\(release\):\ v([0-9]+\.[0-9]+\.[0-9]+)([[:space:]]|$) ]]; then
51
+ echo "Head commit is not a release commit: $subject"
52
+ exit 0
53
+ fi
54
+ version="${BASH_REMATCH[1]}"
55
+ tag="v${version}"
56
+
57
+ if [ -f package.json ]; then
58
+ manifest=$(node -p "require('./package.json').version")
59
+ if [ "$manifest" != "$version" ]; then
60
+ echo "::error::The commit says $tag but package.json is at $manifest; not tagging."
61
+ exit 1
62
+ fi
63
+ fi
64
+
65
+ # Idempotent: a re-run, or a second push of the same commit, does nothing.
66
+ if gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then
67
+ echo "Release $tag already exists; nothing to do."
68
+ exit 0
69
+ fi
70
+ if existing=$(gh api "repos/$REPO/git/ref/tags/$tag" --jq '.object.sha' 2>/dev/null); then
71
+ if [ "$existing" != "$SHA" ]; then
72
+ echo "::warning::Tag $tag already exists on $existing, not on $SHA; leaving it alone."
73
+ exit 0
74
+ fi
75
+ fi
76
+
77
+ # The marker tells the release PR apart from any other PR associated with this commit.
78
+ marker="<!-- release:${tag} -->"
79
+ gh api "repos/$REPO/commits/$SHA/pulls" > /tmp/pulls.json
80
+ MARKER="$marker" node -e '
81
+ const pulls = JSON.parse(require("fs").readFileSync("/tmp/pulls.json", "utf8"));
82
+ const pr = pulls.find((p) => p.merged_at && (p.body || "").includes(process.env.MARKER));
83
+ if (!pr) process.exit(3);
84
+ const notes = pr.body.split(/\r?\n/).filter((line) => line.trim() !== process.env.MARKER).join("\n").trim();
85
+ require("fs").writeFileSync("/tmp/release-notes.md", notes + "\n");
86
+ console.log(`Notes from #${pr.number}`);
87
+ ' || status=$?
88
+
89
+ if [ "${status:-0}" -eq 3 ]; then
90
+ echo "::warning::No merged pull request carrying $marker is associated with $SHA; publishing generated notes instead."
91
+ gh release create "$tag" --repo "$REPO" --target "$SHA" --title "$tag" --generate-notes
92
+ elif [ "${status:-0}" -ne 0 ]; then
93
+ echo "::error::Could not read the release notes from the pull request."
94
+ exit 1
95
+ else
96
+ gh release create "$tag" --repo "$REPO" --target "$SHA" --title "$tag" --notes-file /tmp/release-notes.md
97
+ fi
98
+ echo "Tagged $SHA as $tag and published the GitHub Release."
@@ -139,12 +139,11 @@ pre-agent-steps:
139
139
  npm install -g "@fission-ai/openspec@1.8.0"
140
140
  openspec --version
141
141
 
142
- # NOTE: playwright-cli and SQL Server startup steps are not part of the shared CI
143
- # baseline. The agent path captures no visual evidence: it cannot reach a browser
144
- # from inside the awf sandbox, and a screenshot with no baseline to compare against
145
- # gated nothing. Run /ops-evidence locally when a change warrants screenshots. If
146
- # you need playwright-cli or SQL Server inside the agent sandbox for other reasons,
147
- # add them as consumer-specific steps after the shared baseline.
142
+ # NOTE: no browser and no SQL Server in the shared CI baseline. An agent cannot reach a
143
+ # browser from inside the awf sandbox, so it only writes the verification plan
144
+ # (/repo-verify); evidence.yml replays that plan outside the sandbox once the pull request
145
+ # merges and archives the screenshots on it. If you need SQL Server inside the agent sandbox
146
+ # for other reasons, add it as a consumer-specific step after the shared baseline.
148
147
 
149
148
  - name: Setup .NET
150
149
  # Ephemeral VMs carry no SDK; the old host had it baked in, and GitHub-hosted images made
@@ -67,8 +67,8 @@ run-name: >-
67
67
  && inputs.operation == 'merge-gate'
68
68
  && format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
69
69
  || github.event_name == 'workflow_dispatch'
70
- && inputs.operation == 'visual-verify'
71
- && format('Agent: Visual Verify - PR #{0}', inputs.pr-number)
70
+ && inputs.operation == 'evidence'
71
+ && format('Agentics: Evidence - PR #{0}', inputs.pr-number)
72
72
  || github.event_name == 'workflow_dispatch'
73
73
  && inputs.operation == 'batch'
74
74
  && format('Working: Batch - #{0}', inputs.issue-number)
@@ -144,6 +144,11 @@ env:
144
144
  # merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
145
145
  # agree, because a belt that gives up later than the worker says burns whole runs.
146
146
  MAX_GATE_ATTEMPTS: "6"
147
+ # Whether a merged pull request's verification plan is replayed and its screenshots archived on
148
+ # the pull request (evidence.yml). It needs nothing configured: the app starts with the root
149
+ # `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
150
+ # repository whose app cannot start on a runner; operation=evidence still works by hand.
151
+ EVIDENCE_ENABLED: "true"
147
152
 
148
153
  on:
149
154
  issues:
@@ -158,7 +163,8 @@ on:
158
163
  pull_request_target:
159
164
  # opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
160
165
  # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
161
- # moved and previously-mergeable sibling bot PRs may now conflict.
166
+ # moved and previously-mergeable sibling bot PRs may now conflict. A merge also routes to
167
+ # evidence, which replays the pull request's verification plan.
162
168
  types: [opened, synchronize, reopened, labeled, closed]
163
169
 
164
170
  # CI completion on a pull request. The classifier admits only conclusion == failure with an
@@ -199,7 +205,7 @@ on:
199
205
  - triage
200
206
  - apply-review
201
207
  - merge-gate
202
- - visual-verify
208
+ - evidence
203
209
  - audit
204
210
  - audit-close
205
211
  - cleanup-artifacts
@@ -212,7 +218,7 @@ on:
212
218
  required: false
213
219
  type: string
214
220
  pr-number:
215
- description: "Pull request number (required for apply-review / merge-gate)"
221
+ description: "Pull request number (required for apply-review / merge-gate / evidence)"
216
222
  required: false
217
223
  type: string
218
224
  mode:
@@ -623,18 +629,24 @@ jobs:
623
629
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
624
630
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
625
631
 
626
- call-visual-verify:
632
+ # Screenshots of a merged pull request, replayed from the verification plan its implementation
633
+ # wrote and archived beside that plan. Plain YAML with no model, so no secret is passed and the
634
+ # grant is exactly what the publish job writes: one commit of evidence/ folders and one comment. Its capture
635
+ # job runs the pull request's code with read-only access; only publish holds these writes, and it
636
+ # runs no repository code.
637
+ call-evidence:
627
638
  needs: classify
628
- if: needs.classify.outputs.route == 'visual-verify'
629
- uses: ./.github/workflows/agent-visual-verify.lock.yml
630
- permissions: write-all
639
+ if: needs.classify.outputs.route == 'evidence'
640
+ uses: ./.github/workflows/evidence.yml
641
+ concurrency:
642
+ group: evidence-${{ needs.classify.outputs.pr-number }}
643
+ cancel-in-progress: false
644
+ permissions:
645
+ contents: write
646
+ pull-requests: write
647
+ issues: write
631
648
  with:
632
649
  pr-number: ${{ needs.classify.outputs.pr-number }}
633
- linked-issue: ${{ needs.classify.outputs.linked-issue }}
634
- secrets:
635
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
636
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
637
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
638
650
 
639
651
  call-audit:
640
652
  needs: classify
@@ -663,6 +675,9 @@ jobs:
663
675
  version-bump: ${{ github.event.inputs.version-bump || 'auto' }}
664
676
  secrets:
665
677
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
678
+ # The release PR is pushed and opened with the App token so its CI runs; see conclude.
679
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
680
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
666
681
 
667
682
  bot-approve:
668
683
  needs: classify