Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/publish-devkit-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# publish-devkit-image — build & publish the devkit harness image to GHCR (PUBLIC).
#
# WHY THIS EXISTS (DEC-071):
# `e2e/Makefile.devkit` defaults DEVKIT_IMAGE to
# ghcr.io/agent-zero-plugins/plugin-devkit:latest, but nothing ever published
# it — the package did not exist, so every consumer's `make e2e` died with
# "manifest unknown" and CI only worked because each workflow rebuilt the
# image inline (~85s per run, every run).
#
# PUBLIC ON PURPOSE:
# The image is quay.io/podman/stable + Node + Playwright + Fedora browser
# libs. It contains NO secrets and NO first-party source. Publishing it
# publicly means an outside contributor can run the documented local loop
# with no GHCR auth at all — the single most important consumability
# property for this repo.
name: publish-devkit-image

on:
push:
branches: [main]
paths:
- "devcontainer/Containerfile"
- ".github/workflows/publish-devkit-image.yml"
tags: ["v*"]
workflow_dispatch:

# Least privilege: this job only needs to read the repo and write the package.
permissions:
contents: read
packages: write

concurrency:
group: publish-devkit-image
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Log in to GHCR
# GITHUB_TOKEN suffices: the package lives in THIS repo's org and the
# job declares packages:write. No PAT, no long-lived secret.
run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u ${{ github.actor }} --password-stdin

- name: Build
run: |
set -euo pipefail
podman build -t devkit:build -f devcontainer/Containerfile devcontainer

- name: Tag and push
run: |
set -euo pipefail
IMAGE=ghcr.io/${{ github.repository_owner }}/plugin-devkit
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]') # GHCR requires lowercase
SHA_TAG="sha-${GITHUB_SHA::12}"

podman tag devkit:build "$IMAGE:$SHA_TAG"
podman push "$IMAGE:$SHA_TAG"
echo "pushed $IMAGE:$SHA_TAG"

# :latest tracks main. Tag builds additionally publish the version.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
podman tag devkit:build "$IMAGE:$VER"
podman push "$IMAGE:$VER"
echo "pushed $IMAGE:$VER"
fi

podman tag devkit:build "$IMAGE:latest"
podman push "$IMAGE:latest"
echo "pushed $IMAGE:latest"

- name: Verify the image is pullable ANONYMOUSLY
# The whole point of publishing is the no-auth consumer path, so prove
# it rather than assume it. A fresh logged-out store is the only honest
# check: a cached layer or a live login would mask a private package.
run: |
set -euo pipefail
IMAGE=ghcr.io/${{ github.repository_owner }}/plugin-devkit
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
podman logout ghcr.io || true
if podman pull --authfile /dev/null "$IMAGE:latest"; then
echo "anonymous pull OK — consumers need no GHCR auth"
else
echo "::error::$IMAGE:latest is NOT anonymously pullable. Set the package visibility to Public (Org -> Packages -> plugin-devkit -> Package settings -> Change visibility). Until then every consumer's 'make e2e' fails with 'manifest unknown'."
exit 1
fi
14 changes: 7 additions & 7 deletions .github/workflows/sample-plugin-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ jobs:
run: podman build -t plugin-devkit:ci -f devcontainer/Containerfile devcontainer

- name: Package sample + run lifecycle (A0 nested, unprivileged)
# Validates DEC-055 fork-first: boots the PRIVATE non-root fork image
# (ghcr.io/nuevanext/agent-zero:latest-nonroot) via the harness ghcr login.
env:
GHCR_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
# Boots the PUBLIC upstream image (docker.io/agent0ai/agent-zero:latest) —
# the A0_IMAGE default from DEC-019. This job must pass with NO secrets:
# dependabot runs and fork PRs from outside contributors never receive
# them, so pinning the private fork image here made this workflow red for
# everyone outside the org. Consumers that need the ML-bundled fork still
# override A0_IMAGE per-plugin via .devkit.yml.
run: |
set -euo pipefail
mkdir -p dist "$GITHUB_WORKSPACE/artifacts"
Expand All @@ -56,9 +58,7 @@ jobs:
-e PLUGIN_NAME=sample_plugin \
-e HOOK_DIR=/workspace/examples/sample-plugin/tests/e2e/hooks \
-e BEHAVIOUR_FILE=/workspace/examples/sample-plugin/tests/e2e/behaviour.mjs \
-e A0_IMAGE="ghcr.io/nuevanext/agent-zero:latest-nonroot" \
-e GHCR_USER=x \
-e GHCR_TOKEN="$GHCR_TOKEN" \
-e A0_IMAGE="docker.io/agent0ai/agent-zero:latest" \
-e ARTIFACT_DIR=/artifacts \
plugin-devkit:ci \
-lc 'bash /workspace/e2e/harness/run-lifecycle.sh'
Expand Down
14 changes: 11 additions & 3 deletions e2e/bdd/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@ const testDir = defineBddConfig({
features: ["features/**/*.feature", ...(P ? [`${P}/features/**/*.feature`] : [])],
steps: ["bdd-fixtures.ts", "steps/**/*.ts", ...(P ? [`${P}/steps/**/*.ts`] : [])],
});
// Red-proof mode (Gate 3, DEC-066 / DEC-070): the suite runs with the plugin NOT
// installed, so EVERY scenario is expected to fail. Capturing video + a
// retain-on-failure trace for every one of those expected failures is pure I/O
// cost for artifacts nobody reads (the gate's signal is the pass COUNT, not the
// artifacts). Disable capture in red-proof mode only — the real run below is
// unaffected, so debuggability of genuine failures is unchanged.
const RED_PROOF = process.env.BDD_SKIP_INSTALL === "1";

export default defineConfig({
testDir, reporter: [["list"]], timeout: 120000, workers: 1, fullyParallel: false,
// trace = the rich single-file artifact (network + DOM snapshots + console + video + timeline),
// viewable via `npx playwright show-trace` / trace.playwright.dev. Default: only failing scenarios
// (retain-on-failure); set BDD_TRACE=on (the workflow's capture-all-traces dispatch input) for every one.
use: {
baseURL: process.env.A0_BASE || "http://localhost:8099",
video: "on",
screenshot: "on",
trace: (process.env.BDD_TRACE as any) || "retain-on-failure",
video: RED_PROOF ? "off" : "on",
screenshot: RED_PROOF ? "off" : "on",
trace: RED_PROOF ? "off" : ((process.env.BDD_TRACE as any) || "retain-on-failure"),
},
});
69 changes: 66 additions & 3 deletions e2e/harness/run-bdd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,27 @@ export A0_USERNAME="${A0_USERNAME:-admin}" A0_PASSWORD="${A0_PASSWORD:-admin}"
# plugin, the behaviour scenarios must NOT pass — the seam endpoint 404s without the
# plugin, so honest scenarios go RED. Any pass here is fake-green. Cheap: one extra
# playwright pass, no second A0 boot.
# SPEED (DEC-070): the red-proof's cost is ENTIRELY in the fail path. With no
# plugin the seam 404s, so every scenario runs until something times out — and
# the binding clock is Playwright's TEST-level timeout (120s), not the action /
# expect timeouts: a step's explicit per-call `{ timeout: N }` (which plugin
# steps routinely pass) is NOT overridable from config, but IS capped by the
# test timeout. Measured: 12 scenarios x ~113s = 22m40s ~= 12 x the 120s cap.
# So cap the test timeout for the red-proof pass only. Semantics are untouched:
# every scenario still runs, and the assertion is still exactly 0 passes.
RED_PROOF_TIMEOUT_MS="${RED_PROOF_TIMEOUT_MS:-30000}"
RP_LOG="$(mktemp)"
( cd "$BDD" && BDD_SKIP_INSTALL=1 npx playwright test --config=playwright.config.ts ) > "$RP_LOG" 2>&1 || true
rp_t0=$SECONDS
( cd "$BDD" && BDD_SKIP_INSTALL=1 npx playwright test --config=playwright.config.ts \
--timeout="$RED_PROOF_TIMEOUT_MS" ) > "$RP_LOG" 2>&1 || true
rp_secs=$(( SECONDS - rp_t0 ))
# NB: 0-passed is the SUCCESS case and prints no "N passed" line, so BOTH greps
# exit non-zero on empty input; with pipefail that propagates, so `|| true` must
# guard the WHOLE pipeline (not just the first grep) or set -e kills the script.
RP_PASSED=$(grep -oE '[0-9]+ passed' "$RP_LOG" | grep -oE '[0-9]+' | head -1 || true); RP_PASSED=${RP_PASSED:-0}
echo "[run-bdd] red-proof: $RP_PASSED scenario(s) passed with NO plugin installed (want 0)"
RP_TOTAL=$(grep -oE 'Running [0-9]+ test' "$RP_LOG" | grep -oE '[0-9]+' | head -1 || true); RP_TOTAL=${RP_TOTAL:-0}
echo "[run-bdd] red-proof: $RP_PASSED scenario(s) passed with NO plugin installed (want 0) \
— $RP_TOTAL scenario(s) run in ${rp_secs}s (test-timeout cap ${RED_PROOF_TIMEOUT_MS}ms)"
if [ "$RP_PASSED" -gt 0 ]; then
echo "::error::seam-off red-proof FAILED — $RP_PASSED behaviour scenario(s) passed with no plugin installed (fake-green). See tests/_testkit/docs/BDD-GATES.md (Gate 3)."
grep -aE '✓|passed|failed' "$RP_LOG" | tail -20
Expand All @@ -68,7 +82,56 @@ fi
echo "[run-bdd] red-proof OK — nothing passes without the plugin; the suite is real."

PW_RC=0
( cd "$BDD" && npx playwright test --config=playwright.config.ts ) || PW_RC=$?
PW_LOG="$(mktemp)"
( cd "$BDD" && npx playwright test --config=playwright.config.ts ) 2>&1 | tee "$PW_LOG" || true
PW_RC=${PIPESTATUS[0]}

# ── Anti-weakening guards on the speed-up (DEC-070) ───────────────────────────
# Capping the red-proof clock is only safe while a scenario that WOULD pass
# plugin-less still has room to pass. If the cap clipped such a scenario into a
# timeout-failure, the gate would report 0-passed and go green while a fake-green
# scenario sat undetected — a silently weakened gate. Two checks make that
# impossible to happen unnoticed.
PW_TOTAL=$(grep -oE 'Running [0-9]+ test' "$PW_LOG" | grep -oE '[0-9]+' | head -1 || true); PW_TOTAL=${PW_TOTAL:-0}

# Guard A — coverage parity: the red-proof must exercise the SAME scenario count
# as the real run. Catches any future change that narrows the red-proof pass
# (a filter, a project split, a grep) rather than merely speeding it up.
if [ "$RP_TOTAL" -ne "$PW_TOTAL" ]; then
echo "::error::red-proof coverage MISMATCH — red-proof ran $RP_TOTAL scenario(s) but the real run ran $PW_TOTAL. The red-proof must cover every behaviour scenario (DEC-066/070)."
exit 1
fi

# Guard B — cap adequacy: every scenario that genuinely PASSES in the real run
# must complete well inside the red-proof cap. If the slowest honest pass needs
# more time than the cap allows, the cap is capable of masking a fake-green, so
# fail loudly with the value to raise it to. 80% leaves headroom for the extra
# waiting a plugin-less run does before its own failure.
# Parse the per-scenario durations Playwright prints at end-of-line, e.g. "(18.5s)".
# POSIX sed + shell arithmetic ONLY — deliberately no gawk-style 3-arg match():
# the harness runs wherever the devkit image runs and mawk (Debian's default awk)
# rejects that GNU extension outright, which would make this guard a syntax error
# at runtime rather than a check. Verified against real CI log output.
SLOWEST_PASS_MS=0
while read -r v u; do
case "$u" in
ms) ms=${v%.*} ;;
s) ms=$(( ${v%%.*} * 1000 + 10#$(printf '%s' "${v#*.}0" | cut -c1-1) * 100 )) ;;
m) ms=$(( ${v%%.*} * 60000 )) ;;
*) ms=0 ;;
esac
[ "$ms" -gt "$SLOWEST_PASS_MS" ] && SLOWEST_PASS_MS=$ms
done <<EOF
$(grep -a '✓' "$PW_LOG" | sed -nE 's/.*\(([0-9.]+)(ms|s|m)\)[[:space:]]*$/\1 \2/p')
EOF
SLOWEST_PASS_MS=${SLOWEST_PASS_MS:-0}
BUDGET_MS=$(( RED_PROOF_TIMEOUT_MS * 80 / 100 ))
echo "[run-bdd] red-proof cap check: slowest PASSING scenario ${SLOWEST_PASS_MS}ms vs 80% of cap ${BUDGET_MS}ms"
if [ "$SLOWEST_PASS_MS" -gt "$BUDGET_MS" ]; then
need=$(( SLOWEST_PASS_MS * 100 / 80 / 1000 * 1000 + 1000 ))
echo "::error::red-proof timeout cap TOO TIGHT — a passing scenario takes ${SLOWEST_PASS_MS}ms but the red-proof cap is ${RED_PROOF_TIMEOUT_MS}ms. A scenario that passes plugin-less could be clipped into a false failure, masking a fake-green. Raise RED_PROOF_TIMEOUT_MS to >= ${need}."
exit 1
fi

# One Playwright trace.zip per captured scenario → artifacts (network + DOM snapshots +
# console + video + timeline in one file; open with `npx playwright show-trace <file>` or
Expand Down
1 change: 1 addition & 0 deletions e2e/node_modules
10 changes: 9 additions & 1 deletion e2e/pages/LoginPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ export class LoginPage {
// A0 redirects to `/` on success. We assert on a post-login landmark
// (the top-nav "Plugins" button) rather than the URL alone, so a
// silent auth failure shows up as a timeout on this line.
//
// Explicit 30s (DEC-072): on a cold nested A0 the WebUI hydrates ~5s AFTER
// the post-login redirect, straddling Playwright's 5s default and flaking
// ~50%. Measured from run 30721063715's trace: page reached `/` at t=2.4s,
// top-nav rendered at t=7.755s — the default deadline (~7.4s) missed it by
// ~350ms. This wait exists to surface a SILENT AUTH FAILURE, not to measure
// hydration speed, so bound it generously — consistent with PluginsPage's
// 8s/20s/10s landmark waits.
await expect(
this.page.getByRole("button", { name: "Plugins", exact: true }),
).toBeVisible();
).toBeVisible({ timeout: 30_000 });
}
}