@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.
- package/dist/catalog-installation.js +17 -7
- package/dist/route-processing.js +1 -1
- package/dist/workflow-catalog.d.ts +4 -1
- package/dist/workflow-catalog.js +10 -1
- package/loops/actions/capture-evidence/action.yml +118 -0
- package/loops/actions/capture-evidence/capture.mjs +693 -0
- package/loops/actions/classify-route/action.yml +1 -0
- package/loops/actions/classify-route/classify-route.sh +15 -3
- package/loops/actions/report-workflow-errors/action.yml +1 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +78 -61
- package/loops/docs/diagrams.md +48 -44
- package/loops/workflows/agent-merge-gate.md +0 -45
- package/loops/workflows/agent-release.md +73 -17
- package/loops/workflows/evidence.yml +460 -0
- package/loops/workflows/release-tag.yml +98 -0
- package/loops/workflows/shared/opencode-ci.md +5 -6
- package/loops/workflows/work-router.yml +29 -14
- package/package.json +1 -1
- package/loops/actions/attach-screenshots/action.yml +0 -93
- package/loops/workflows/agent-visual-verify.md +0 -350
|
@@ -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:
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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 == '
|
|
71
|
-
&& format('
|
|
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
|
-
-
|
|
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
|
-
|
|
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 == '
|
|
629
|
-
uses: ./.github/workflows/
|
|
630
|
-
|
|
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
|