Repository navigation
362 lines (348 loc) · 18.6 KB
/
Copy pathplugin-e2e.yml
File metadata and controls
362 lines (348 loc) · 18.6 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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# Reusable plugin-e2e workflow (SPEC DEC-020/044).
#
# A consumer plugin repo invokes it via a thin, parameter-free caller
# (templates/plugin-e2e.caller.yml, copied in by `make link-workflows`). It:
# 1. mints the sync-App token (to clone the private devkit submodule),
# 2. checks out the consumer + inits its devkit submodule (tests/_testkit),
# 3. resolves the plugin (.devkit.yml `plugin_dir` → else usr/plugins/<name>,
# per DEC-042/043),
# 4. builds the devkit devcontainer, packages the plugin, and runs the full
# install → verify-installed → uninstall → verify-uninstalled lifecycle
# with A0 booted NESTED + rootless (Phase-0 model).
name: plugin-e2e
on:
workflow_call:
inputs:
a0_image:
description: "A0 image under test (overridden by .devkit.yml a0_image)."
required: false
type: string
default: "ghcr.io/nuevanext/agent-zero:latest-nonroot"
artifact-keep:
description: "Count-based artifact retention: keep this many most recent runs' e2e artifacts (DEC-074)."
required: false
type: number
default: 5
devkit-submodule-path:
description: "Path to the vendored devkit submodule."
required: false
type: string
default: "tests/_testkit"
capture-all-traces:
# Kept for compatibility with existing callers (the caller template passes
# it from a workflow_dispatch input). It is now a no-op in the default
# direction: traces are ALWAYS captured (DEC-073). Setting it false no
# longer suppresses them — use trace-mode if you really want that.
description: "Deprecated — traces are always captured (DEC-073). Use trace-mode to override."
required: false
type: boolean
default: false
trace-mode:
# Escape hatch for the rare case where trace capture itself is the
# problem (disk pressure on a huge suite). Accepts any Playwright trace
# mode: on | retain-on-failure | on-first-retry | off.
description: "Playwright trace mode. Default 'on' (DEC-073 — a green run must leave evidence too)."
required: false
type: string
default: "on"
secrets:
# DEC-075: all three are now OPTIONAL. They were `required: true` back when
# the devkit was a PRIVATE repo and the submodule could only be cloned with
# an App token. The devkit is public now (verified: anonymous clone works),
# so the token buys nothing — while `required: true` made every Dependabot
# PR structurally red, because Dependabot runs never receive repo secrets.
# Symptom was "Input required and not supplied: app-id" on 5 open PRs, none
# of which had anything to do with the dependency being bumped.
SKILLS_SYNC_APP_CLIENT_ID:
description: "Sync App OAuth Client ID. Optional/legacy — only used if the devkit submodule is private."
required: false
SKILLS_SYNC_APP_PRIVATE_KEY:
description: "Sync App private-key PEM. Optional/legacy — see above."
required: false
GHCR_PULL_TOKEN:
description: "ghcr read:packages token to pull the private fork image (DEC-055). Optional — without it the e2e runs on the public default image."
required: false
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
# actions:write is needed ONLY to DELETE superseded artifacts for the
# count-based retention prune (DEC-074). A reusable workflow's job
# permissions are capped by what the CALLER granted, so the caller
# template must grant actions:write too or the prune silently degrades
# to a warning (the step is continue-on-error and never fails the run).
actions: write
steps:
- name: Detect whether App credentials are available
id: creds
# The `secrets` context is NOT one of the contexts allowed in an `if:`
# expression — using it there makes the whole workflow file invalid and
# the run dies as a startup_failure with zero jobs. So probe the secrets
# in a `run:` step (where they ARE available, via env) and branch on the
# step output instead. Nothing secret is printed, only a yes/no.
env:
CLIENT_ID: ${{ secrets.SKILLS_SYNC_APP_CLIENT_ID }}
PRIVATE_KEY: ${{ secrets.SKILLS_SYNC_APP_PRIVATE_KEY }}
run: |
if [ -n "${CLIENT_ID:-}" ] && [ -n "${PRIVATE_KEY:-}" ]; then
echo "have_app=true" >> "$GITHUB_OUTPUT"
else
echo "have_app=false" >> "$GITHUB_OUTPUT"
echo "no App credentials (Dependabot run, fork PR, or public devkit) — will clone anonymously"
fi
- name: Mint App token (read-only — devkit submodule clone)
id: app-token
# Only when the App credentials are actually present. Dependabot runs and
# fork PRs get no secrets, and the devkit is public, so they skip this and
# clone the submodule anonymously below (DEC-075).
if: steps.creds.outputs.have_app == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.SKILLS_SYNC_APP_CLIENT_ID }}
private-key: ${{ secrets.SKILLS_SYNC_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout consumer (GITHUB_TOKEN, no submodules)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: false
- name: Init devkit submodule
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
DEVKIT_PATH: ${{ inputs.devkit-submodule-path }}
# The devkit is a PUBLIC repo, so the plain clone is the normal path and
# works with no credentials at all — which is what lets Dependabot PRs
# (no secrets) and outside contributors run this workflow. The App token
# is used only when it happens to be available, so a consumer that keeps
# its devkit private still works unchanged (DEC-075).
run: |
set -euo pipefail
if [ -n "${APP_TOKEN:-}" ]; then
AUTH=$(printf 'x-access-token:%s' "$APP_TOKEN" | base64 -w0)
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH" \
submodule update --init "$DEVKIT_PATH"
else
echo "no App token (Dependabot/fork/public devkit) — cloning submodule anonymously"
git submodule update --init "$DEVKIT_PATH"
fi
- name: Build plugin (optional — .devkit.yml `build:`)
# Build-generated plugins (e.g. a vendored upstream compiled into a
# plugin dir) declare a `build:` command in .devkit.yml. It runs on the
# runner (python3/make/git/zip available) BEFORE resolve+package, and is
# responsible for initialising any of its OWN (public) submodules it
# needs — e.g. `git submodule update --init upstream && make build`.
# Absent `build:` → no-op, so this step is transparent to the fleet.
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
build=$(sed -nE 's/^build:[[:space:]]*"?([^"]+)"?.*/\1/p' .devkit.yml 2>/dev/null | head -1)
if [ -n "$build" ]; then
echo "::group::build: $build"
bash -euo pipefail -c "$build"
echo "::endgroup::"
else
echo "no .devkit.yml build: step — skipping"
fi
- name: Resolve plugin (.devkit.yml → else usr/plugins/<name>)
id: resolve
env:
INPUT_A0_IMAGE: ${{ inputs.a0_image }}
run: |
set -euo pipefail
y(){ sed -nE "s/^$1:[[:space:]]*\"?([^\"]+)\"?.*/\1/p" "$2" 2>/dev/null | head -1; }
plugin_dir="" display_name="" cfg_a0=""
if [ -f .devkit.yml ]; then
plugin_dir=$(y plugin_dir .devkit.yml)
display_name=$(y display_name .devkit.yml)
cfg_a0=$(y a0_image .devkit.yml)
fi
if [ -z "$plugin_dir" ]; then
plugin_dir=$(dirname "$(find usr/plugins -maxdepth 2 -name plugin.yaml 2>/dev/null | head -1)")
fi
[ -d "$plugin_dir" ] && [ -f "$plugin_dir/plugin.yaml" ] || {
echo "::error::could not resolve plugin_dir (set .devkit.yml plugin_dir or use usr/plugins/<name>/)"; exit 1; }
plugin_name=$(y name "$plugin_dir/plugin.yaml")
[ -n "$display_name" ] || display_name=$(y title "$plugin_dir/plugin.yaml")
[ -n "$display_name" ] || display_name="$plugin_name"
a0_image="${cfg_a0:-$INPUT_A0_IMAGE}"
# Optional plugin-declared nested-A0 env (DEC-057). Natural YAML map:
# e2e_pod_env:
# A0_FOO_TEST_PROBE: "1"
# flattened to a space-separated KEY=VAL list the harness forwards as
# `-e KEY=VAL` into the nested A0 pod. Enables env-gated test seams for
# e2e ONLY (the seam stays off in prod). Also accepts an inline scalar
# `e2e_pod_env: "A=1 B=2"`.
pod_env=""
if [ -f .devkit.yml ]; then
pod_env=$(y e2e_pod_env .devkit.yml)
if [ -z "$pod_env" ]; then
pod_env=$(awk '
/^e2e_pod_env:[[:space:]]*$/ {inblk=1; next}
inblk && /^[[:space:]]+[A-Za-z_][A-Za-z0-9_]*:/ {
l=$0; sub(/^[[:space:]]+/,"",l)
k=l; sub(/:.*/,"",k)
v=l; sub(/^[^:]*:[[:space:]]*/,"",v); gsub(/^["'\'']|["'\'']$/,"",v)
printf "%s=%s ", k, v; next }
inblk && /^[^[:space:]]/ {inblk=0}
' .devkit.yml)
fi
fi
echo "plugin_dir=$plugin_dir" >> "$GITHUB_OUTPUT"
echo "plugin_name=$plugin_name" >> "$GITHUB_OUTPUT"
echo "display_name=$display_name" >> "$GITHUB_OUTPUT"
echo "a0_image=$a0_image" >> "$GITHUB_OUTPUT"
echo "pod_env=$pod_env" >> "$GITHUB_OUTPUT"
echo "Resolved: dir=$plugin_dir name=$plugin_name display='$display_name' a0=$a0_image pod_env='$pod_env'"
- name: Free disk space
# The fork image (ghcr.io/nuevanext/agent-zero, bundles ML deps e.g.
# onnxruntime) is larger than upstream's 8.76GB and exhausts the runner
# disk during the nested unpack. Reclaim ~30GB of preinstalled tooling.
run: |
df -h / | tail -1
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/usr/local/lib/android /opt/hostedtoolcache/CodeQL \
/usr/local/share/boost "$AGENT_TOOLSDIRECTORY" 2>/dev/null || true
sudo docker image prune -af 2>/dev/null || true
echo "after reclaim:"; df -h / | tail -1
- name: Tier-1 BDD lint (DEC-066, hard-fail)
# Behaviour-first honesty/purity/traceability gates + REQUIRES BDD tests:
# a plugin with no tests/e2e/features/ hard-fails (no exceptions). Runs before
# the expensive build so a standard violation fails fast — the gate humans +
# agents can't lazy past.
env:
DEVKIT_PATH: ${{ inputs.devkit-submodule-path }}
# tests/e2e/ + docs/spec/ are repo-root artifacts (per run-bdd), not under
# plugin_dir — lint the repo root so subdir-layout plugins work.
run: python3 "$DEVKIT_PATH/e2e/lint/bdd_lint.py" .
- name: Build devkit devcontainer (rootless)
env:
DEVKIT_PATH: ${{ inputs.devkit-submodule-path }}
run: podman build -t plugin-devkit:ci -f "$DEVKIT_PATH/devcontainer/Containerfile" "$DEVKIT_PATH/devcontainer"
- name: Package + run lifecycle (A0 nested, unprivileged)
env:
DEVKIT_PATH: ${{ inputs.devkit-submodule-path }}
PLUGIN_DIR: ${{ steps.resolve.outputs.plugin_dir }}
PLUGIN_NAME: ${{ steps.resolve.outputs.plugin_name }}
DISPLAY_NAME: ${{ steps.resolve.outputs.display_name }}
A0_IMAGE: ${{ steps.resolve.outputs.a0_image }}
A0_POD_ENV: ${{ steps.resolve.outputs.pod_env }}
GHCR_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
# DEC-073: traces are ALWAYS captured, so a green run leaves evidence to
# diff a later regression against. This line used to hardcode
# 'retain-on-failure' unless capture-all-traces was set, which silently
# overrode the config default and meant every consumer's green e2e
# shipped 0 traces — the exact defect DEC-073 was written to fix.
BDD_TRACE: ${{ inputs.trace-mode }}
run: |
set -euo pipefail
mkdir -p dist "$GITHUB_WORKSPACE/artifacts"
chmod 777 "$GITHUB_WORKSPACE/artifacts" # rootless-podman maps the container user to a subuid; 777 lets it write
# Exclude the standardization wrapper + repo meta so root-layout
# plugins (.devkit.yml plugin_dir: .) don't pack .git, the devkit
# submodule (tests/_testkit), caller workflows, etc. into the zip.
# No-ops for subdir layouts (those dirs aren't inside PLUGIN_DIR).
( cd "$PLUGIN_DIR" && zip -qr "$GITHUB_WORKSPACE/dist/$PLUGIN_NAME.zip" . \
-x '.git/*' 'tests/*' '.github/*' '.skills/*' 'dist/*' '*/node_modules/*' \
'.devkit.yml' '.gitmodules' '.gitignore' )
HOOK_DIR_REL="tests/e2e/hooks"
# Behaviour seam (DEC-053/056). Prefer ≤10 grouped specs under
# tests/e2e/specs/*.mjs (each runs as its own test → its own video);
# fall back to a single tests/e2e/behaviour.mjs. Absent ⇒ spec logs the gap.
BEHAVIOUR_REL="tests/e2e/behaviour.mjs"
if [ -f "$BEHAVIOUR_REL" ]; then BEHAVIOUR_ARG="/workspace/$BEHAVIOUR_REL"; else BEHAVIOUR_ARG=""; fi
SPECS_JSON="[]"
if ls tests/e2e/specs/*.mjs >/dev/null 2>&1; then
SPECS_JSON=$(for f in tests/e2e/specs/*.mjs; do
n=$(basename "$f" .mjs); printf '{"name":"%s","path":"/workspace/%s"}\n' "$n" "$f"; done \
| paste -sd, - | sed 's/^/[/; s/$/]/')
fi
# Cycle-3 (DEC-063): if the plugin ships BDD features, run the
# playwright-bdd harness; otherwise the classic .mjs lifecycle.
HARNESS=run-lifecycle.sh
if ls tests/e2e/features/*.feature >/dev/null 2>&1; then HARNESS=run-bdd.sh; fi
echo "harness: $HARNESS"
podman run --rm \
--device /dev/fuse \
--security-opt label=disable \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
-v "$GITHUB_WORKSPACE":/workspace:ro \
-v "$GITHUB_WORKSPACE/dist/$PLUGIN_NAME.zip":/tmp/plugin.zip:ro \
-v "$GITHUB_WORKSPACE/artifacts":/artifacts:rw \
-e A0_IMAGE="$A0_IMAGE" \
-e GHCR_USER=x \
-e GHCR_TOKEN="$GHCR_TOKEN" \
-e PLUGIN_ZIP=/tmp/plugin.zip \
-e PLUGIN_DISPLAY_NAME="$DISPLAY_NAME" \
-e PLUGIN_NAME="$PLUGIN_NAME" \
-e HOOK_DIR="/workspace/$HOOK_DIR_REL" \
-e BEHAVIOUR_FILE="$BEHAVIOUR_ARG" \
-e BEHAVIOUR_SPECS="$SPECS_JSON" \
-e A0_POD_ENV="$A0_POD_ENV" \
-e ARTIFACT_DIR=/artifacts \
-e WORKSPACE=/workspace \
-e DEVKIT_PATH="$DEVKIT_PATH" \
-e BDD_TRACE="$BDD_TRACE" \
plugin-devkit:ci \
-lc 'bash /workspace/'"$DEVKIT_PATH"'/e2e/harness/'"$HARNESS"
- name: Upload e2e traces
# Playwright trace.zip per captured scenario (network + DOM snapshots + console +
# video + timeline). Default: failing scenarios only; run this workflow via
# workflow_dispatch with capture-all-traces=true for every scenario. Open a trace with
# `npx playwright show-trace <file>` or drag it into https://trace.playwright.dev .
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-traces-${{ steps.resolve.outputs.plugin_name }}
path: artifacts/
if-no-files-found: warn
# Backstop only. The real policy is count-based (keep last N runs) and
# is enforced by the prune step below — GitHub has NO native "keep last
# N artifacts" setting, retention-days is the only built-in control.
# Kept long so the prune step, not expiry, decides what disappears.
retention-days: 90
- name: Prune old e2e artifacts (keep last N runs)
# Count-based retention (DEC-074): keep the newest E2E_ARTIFACT_KEEP runs'
# artifacts for THIS workflow+plugin and delete the rest, so "the last N
# executions" are always available regardless of how often CI runs.
#
# Needs actions:write. On fork PRs the token is read-only, so deletion is
# skipped rather than failing the run — nothing to prune there anyway,
# since a fork PR's artifacts belong to the fork.
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
KEEP: ${{ inputs.artifact-keep }}
ART_NAME: e2e-traces-${{ steps.resolve.outputs.plugin_name }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork || false }}
DEVKIT_PATH: ${{ inputs.devkit-submodule-path }}
# Single definition, shared with the devkit's own sample-plugin-e2e.yml.
run: bash "${DEVKIT_PATH}/e2e/ci/prune-artifacts.sh"
merge-guard:
# Free private repos can't use required status checks (paid feature), so block
# the merge button the only free way: convert the PR to draft when plugin-e2e
# is red, mark it ready when green. A deterrent + signal — the publish gate
# (DEC-066 Tier-2) is the hard block on *shipping*; a maintainer can still
# re-ready a draft, so this is PR-level friction, not a lock.
needs: e2e
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Draft on red / ready on green
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
RESULT: ${{ needs.e2e.result }}
run: |
if [ "$RESULT" = "success" ]; then
gh pr ready "$PR" --repo "$REPO" || true
echo "plugin-e2e green → PR #$PR marked ready."
else
gh pr ready "$PR" --repo "$REPO" --undo || true
gh pr comment "$PR" --repo "$REPO" --body "🚫 **Merge blocked** — \`plugin-e2e\` concluded **$RESULT**, so this PR was converted to draft. Fix the gates (see \`tests/_testkit/docs/BDD-GATES.md\`); a green run marks it ready again. The publish gate also blocks shipping until green." || true
echo "plugin-e2e $RESULT → PR #$PR converted to draft (merge blocked)."
fi