Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
151 commits
Select commit Hold shift + click to select a range
28ab3d2
fix(ui): render a retryable error on a detail load failure, not "not …
sirozha Jul 22, 2026
66d9c83
test(e2e): wait for Submit before clicking in the real-flow spec
sirozha Jul 22, 2026
1308f2d
fix(settings): don't blank a working settings view on a background re…
sirozha Jul 22, 2026
3d5fc75
fix(ui): guard the detail loading branch too, not only the error branch
sirozha Jul 22, 2026
f0860a7
fix(ui): don't treat an authz denial or a partial error as a missing …
sirozha Jul 23, 2026
2de6386
fix(templates): don't disable the detail actions on a background refetch
sirozha Jul 23, 2026
0d68d1d
test(e2e): cover the authz-denial redirect + fix vacuous header-order…
sirozha Jul 23, 2026
184c2b6
fix(ci): redact stand secrets from the e2e report before the public u…
sirozha Jul 23, 2026
05e7990
fix(ci): read the visual container's Playwright version from the imag…
sirozha Jul 23, 2026
8a6eecd
test(e2e): sweep tab panels in the palette gate, not just the default…
sirozha Jul 23, 2026
b6e5564
fix(e2e): write a trace on mock-tier failure so the debug path exists
sirozha Jul 23, 2026
e9c1c56
test(e2e): gate the reconnect test on resubscribe, not just the socke…
sirozha Jul 23, 2026
b919b7a
test(e2e): assert the overview panel is unmounted, not merely hidden
sirozha Jul 23, 2026
aec236d
fix(ci): gate the stand run on the label just added, not the whole la…
sirozha Jul 23, 2026
ecfb202
fix(e2e): require E2E_BASE_URL for the local tier, like stand
sirozha Jul 23, 2026
3e4316e
fix(e2e): don't crash the mock LLM on a non-array `tools` field
sirozha Jul 23, 2026
7907ee5
fix(e2e): raise the flow-run test budget above the sum of its step ti…
sirozha Jul 23, 2026
d414ec0
test(e2e): gate per-tab scans on the panel's own content
sirozha Jul 24, 2026
c45fbd4
test(e2e): pin the pager-switch stream teardown on the subscriber set
sirozha Jul 24, 2026
3b948d6
test(e2e): prove the pager route by its history trail, not by sampling
sirozha Jul 24, 2026
7b1ecfd
fix(ci): stop unrelated labels from cancelling the stand run, and gat…
sirozha Jul 24, 2026
5cec81b
test(e2e): give the visual gate an absolute pixel budget
sirozha Jul 24, 2026
cec08d0
test(e2e): close three gates that could not fail
sirozha Jul 24, 2026
07fa2d3
docs: correct two detail-navigation claims the code does not make
sirozha Jul 24, 2026
c8314c4
fix(e2e): stop the mock LLM dying on a null element in `tools`
sirozha Jul 24, 2026
a51e36f
test(e2e): serve default providers the id the backend actually sends
sirozha Jul 24, 2026
585df2a
test(e2e): sanction palette tokens per variant, and sweep populated p…
sirozha Jul 24, 2026
fc15ec8
fix(ci): close four gaps in the workflow gates
sirozha Jul 24, 2026
9d4f591
test(e2e): assert uncaught page errors everywhere, and narrow two ove…
sirozha Jul 24, 2026
0a8ca10
test(e2e): cover the flag-outranks-sequence rule in the cassette matcher
sirozha Jul 24, 2026
c906539
test(e2e): give review agents a sandbox instead of the live checkout
sirozha Jul 24, 2026
dbfdbe0
test(e2e): pair the terminal message with the type the backend emits
sirozha Jul 24, 2026
fadde5a
fix(e2e): composite text colour onto its own ground before measuring …
sirozha Jul 24, 2026
162c84d
test(e2e): let a cassette entry pin the request's query string
sirozha Jul 24, 2026
280d7b9
fix(ci): route the sticky comment by PR identity, not by list order
sirozha Jul 24, 2026
e31c725
style(e2e): apply prettier to the pager-switch cassette and spec
sirozha Jul 24, 2026
81f3d06
fix(settings): keep unsaved provider-form edits across a background r…
sirozha Jul 24, 2026
fab990d
fix(ci): don't post a green e2e comment when the run's result is unco…
sirozha Jul 24, 2026
b4f50ba
test(e2e): scope flow-detail a11y waivers to the tab that owns the debt
sirozha Jul 24, 2026
0d4232a
test(e2e): fill the provider cassettes with the shape the backend emits
sirozha Jul 24, 2026
dc7eedf
fix(ci): run the lint/codegen/test gate on pull requests, not only pu…
sirozha Jul 24, 2026
44d471f
test(e2e): give the stand smoke data assertion a real-backend timeout
sirozha Jul 24, 2026
4867ad2
test(e2e): make the palette gate see absolute colours
sirozha Jul 24, 2026
5f7a228
test(e2e): count palette offenders instead of deduping them
sirozha Jul 24, 2026
74099d2
fix(e2e): carry binaries and untracked files into the review sandbox
sirozha Jul 24, 2026
3746029
style(e2e): drop a duplicated waiver comment and apply prettier
sirozha Jul 24, 2026
bdce2bf
test(e2e): require the button tag on the /resources Select waiver
sirozha Jul 24, 2026
9bdd41c
test(e2e): assert the resources empty-state upload CTA, not just the …
sirozha Jul 24, 2026
5514181
test(e2e): key the REST cursor on the selected entries, not the raw body
sirozha Jul 24, 2026
dd5b4e0
docs(e2e): correct the stand-tier trigger and gate model
sirozha Jul 24, 2026
9649e7f
fix(ci): route the sticky comment by PR identity on the primary path too
sirozha Jul 24, 2026
a7c0c19
chore(e2e): trim narration and self-justifying comments; drop a stale…
sirozha Jul 25, 2026
a6fc854
fix(editor): keep highlight tokens readable inside code blocks
sirozha Jul 25, 2026
24e0529
perf(editor): stop auto-detecting the language of unlabelled code blocks
sirozha Jul 25, 2026
b58a40d
fix(ci): pick the codegen-freshness range from the PR base, not the l…
sirozha Jul 25, 2026
aa5378b
fix(e2e): contain what the review sandbox tool is willing to delete
sirozha Jul 25, 2026
7fcecdc
fix(e2e): give the review sandbox a root node_modules can be hardlink…
sirozha Jul 25, 2026
0030bfb
fix(ui): give link text its own token instead of reusing the fill colour
sirozha Jul 25, 2026
18abc46
fix(settings): stop a background refetch from wiping unsaved agent edits
sirozha Jul 25, 2026
948f474
fix(ci): gate the stand job on repo identity, not on a nullable fork …
sirozha Jul 25, 2026
37058c8
fix(ci): surface a visual failure when the baseline is missing, not o…
sirozha Jul 25, 2026
9f4edc6
build: keep Playwright output and tsbuildinfo out of the docker context
sirozha Jul 25, 2026
b56833c
fix(e2e): key the GraphQL cursor on the matched entries, like the RES…
sirozha Jul 25, 2026
17198bf
fix(e2e): sweep the panels the manifest claims to cover, and own the …
sirozha Jul 25, 2026
ef53946
test(e2e): pin the REST re-hydration the reconnect spec is supposed t…
sirozha Jul 25, 2026
b6e6a30
test(e2e): assert the expiry an api-token create actually sends
sirozha Jul 25, 2026
d904a69
docs: correct two claims the code does not honour
sirozha Jul 25, 2026
ae1d600
fix(providers): stop the GraphQL round trip from dropping four agent …
sirozha Jul 25, 2026
4e6fd84
fix(e2e): type the codegen-gate fixture shas so tsc -b passes
sirozha Jul 25, 2026
a7b1533
Merge branch 'feature/next-release' into ui/stage-1-non-flows
sirozha Jul 25, 2026
7a86cb0
refactor(e2e): address the message id by slot, not by a test id
sirozha Jul 25, 2026
7a56dc5
test(e2e): pin every resources write verb and the replace guard
sirozha Jul 25, 2026
548cc12
test(e2e): pin the provider create payload, including the fields that…
sirozha Jul 25, 2026
fe812a7
test(e2e): let the three editors' Save reach the wire
sirozha Jul 25, 2026
cf28128
fix(auth): cap passwords at the 72 bytes bcrypt can hash
sirozha Jul 25, 2026
6407436
test(e2e): cover the account page and the password trust boundary
sirozha Jul 25, 2026
e2281d7
test(e2e): cover stopping a flow and answering a waiting one
sirozha Jul 25, 2026
83bd36a
fix(flows): keep the provider picked for a new assistant
sirozha Jul 25, 2026
052d02b
test(e2e): cover the assistant lifecycle on a flow
sirozha Jul 25, 2026
50aa447
test(e2e): make the tab deep link falsifiable
sirozha Jul 25, 2026
55b1b6e
test(e2e): cover what happens when the session dies mid-visit
sirozha Jul 25, 2026
50d3948
test(e2e): cover the report route and its four exports
sirozha Jul 25, 2026
dc621ae
test(e2e): cover uploading a file into the library
sirozha Jul 25, 2026
4a80376
test(e2e): deliver frames to the panels that never received one
sirozha Jul 25, 2026
2307f89
fix(report): stop naming exported PDFs *.pdf.pdf
sirozha Jul 25, 2026
7b03857
fix(a11y): name the three Files-tab icon buttons
sirozha Jul 26, 2026
3aeafff
test(e2e): cover the three provider writes that had no oracle
sirozha Jul 26, 2026
20483e9
test(e2e): cover the prompt editor's update branch and its reset
sirozha Jul 26, 2026
cab5484
test(e2e): download resources for real instead of reading an href
sirozha Jul 26, 2026
533f0a7
test(e2e): assert the exported PDF is a real document
sirozha Jul 26, 2026
c387a4a
test: close the upload limits from both sides of every boundary
sirozha Jul 26, 2026
c1def5e
test(e2e): pin that a 401 clears the stored session
sirozha Jul 26, 2026
70d71df
fix(a11y): name the six icon-only buttons a screen reader met unnamed
sirozha Jul 26, 2026
cbbee09
fix(a11y): hand focus back to whatever opened a dialog
sirozha Jul 26, 2026
115f90d
fix: repair what the review found in the ledger-closure commits
sirozha Jul 26, 2026
cae6df6
Merge remote-tracking branch 'origin/feature/next-release' into featu…
sirozha Jul 26, 2026
9e21770
fix(e2e): keep the review sandbox's sweep inside the root it was given
sirozha Jul 27, 2026
f42a76b
fix(ci): stop the codegen gate from skipping itself on a large diff
sirozha Jul 27, 2026
6c65fb1
fix(a11y): raise the two highlight tokens that fail AA inside a code …
sirozha Jul 27, 2026
a51fafe
test(e2e): let the 767 sidebar assertion see the breakpoint it guards
sirozha Jul 27, 2026
22002c3
test(e2e): cover the transport 403 an expired session really gets on …
sirozha Jul 27, 2026
b7b105b
fix(editor): keep a code block's fence valid when it carries a language
sirozha Jul 27, 2026
0ed5b5d
fix(upload): stop refusing 0-byte files the API stores happily
sirozha Jul 27, 2026
0f1183a
fix(a11y): hand focus back to the control that opened a sheet or a me…
sirozha Jul 27, 2026
eb600a0
fix(flows): actually clear the attached resources after a submit
sirozha Jul 27, 2026
c4291fd
fix(settings): say why a provider save did nothing
sirozha Jul 27, 2026
860d8c9
fix(a11y): name the scroll-to-latest and per-assistant delete buttons
sirozha Jul 27, 2026
ae6de57
fix(a11y): stop Enter and Escape from firing an inline edit that is busy
sirozha Jul 27, 2026
2bad04b
fix(a11y): announce the sort state of a data-table column
sirozha Jul 27, 2026
5ec981c
fix(flows): render the report when a partial error rides along with t…
sirozha Jul 27, 2026
48537eb
refactor(flows): drop the commentary restating useOptimistic
sirozha Jul 27, 2026
73fb0ab
test(e2e): arm the mocks per context, and stop the mock LLM backtracking
sirozha Jul 27, 2026
afd621b
fix(compose): mount the host bedrock config, not the container path
sirozha Jul 27, 2026
bbf3c08
fix(docker): keep .env.local out of the build context
sirozha Jul 27, 2026
3654455
fix(ci): stop the e2e comment claiming a pass it never read
sirozha Jul 27, 2026
daefe9f
docs(e2e): record that downloads escape the mock tier
sirozha Jul 27, 2026
49ad4c8
refactor(frontend): share one text matcher between list filter and de…
sirozha Jul 27, 2026
e659c6d
fix(graphiti): retry the startup health check before disabling the cl…
sirozha Jul 27, 2026
86d0b2f
fix(controller): drop the flow row when starting its worker fails
sirozha Jul 27, 2026
6e9699b
perf(editor): stop the list tokenizers splitting the whole document p…
sirozha Jul 27, 2026
35b7d86
fix(editor): make block toggles reversible under a select-all selection
sirozha Jul 28, 2026
f4bf0b6
fix(editor): name a task checkbox after its own text, not its subtree
sirozha Jul 28, 2026
ff3ad25
fix(editor): derive the whole-document toggle range instead of reseat…
sirozha Jul 28, 2026
bc1ebec
feat(editor): disable toolbar controls whose command reports unavailable
sirozha Jul 28, 2026
1ae69a3
fix(editor): only disable a control when can() answers faithfully
sirozha Jul 28, 2026
d3d40f4
fix(editor): stop a popover closing onto a destroyed editor from fail…
sirozha Jul 28, 2026
c0f5018
fix(editor): escape line-leading markers to marked's grammar, keep in…
sirozha Jul 28, 2026
a40d0e0
fix(editor): escape a setext underline that follows a hard break
sirozha Jul 28, 2026
d695019
fix(editor): a backtick in a backtick fence's info string is not a fe…
sirozha Jul 28, 2026
18a0c4f
fix(editor): protect pipes in a table nested inside a list item
sirozha Jul 28, 2026
0e632b0
fix(editor): keep a Go template pipeline's own pipe out of the cell e…
sirozha Jul 28, 2026
81ec366
fix(editor): reject unusable URLs and keep brackets out of link label…
sirozha Jul 28, 2026
2637243
fix(editor): stop one entity's unsaved form state reaching the next
sirozha Jul 28, 2026
7e9cd9a
fix(editor): disable block controls a table cell cannot hold
sirozha Jul 28, 2026
cbe85ee
fix(editor): give the heading toggle the whole-document wrapper and a…
sirozha Jul 28, 2026
1aacb6d
fix(editor): stop a headerless table growing a blank row on every save
sirozha Jul 28, 2026
fc6e5a1
fix(editor): platform-correct shortcut labels, and make an Escape dis…
sirozha Jul 28, 2026
9b2a32b
fix(editor): make the caret, node selection and viewer comments visible
sirozha Jul 28, 2026
4108901
fix(editor): stop adjacent tables accumulating a blank paragraph per …
sirozha Jul 28, 2026
888d614
fix(editor): keep a leading code span from being promoted to a heading
sirozha Jul 28, 2026
ff76e1b
feat(editor): make the code-block surface follow the theme
sirozha Jul 28, 2026
70dcab0
chore(backend): remove restatement comments
sirozha Jul 28, 2026
e223bfa
refactor(settings): dedupe formatName and drop a redundant type alias
sirozha Jul 28, 2026
db12e0f
refactor(settings): drop an unreachable return in canResetPrompt
sirozha Jul 28, 2026
25f7c05
feat(header): move the prev/next navigation cluster leftward in detai…
sirozha Jul 28, 2026
0270042
fix(editor): serialize inline code containing a backtick as valid Com…
sirozha Jul 28, 2026
b55220e
test(e2e): update header pager order after the nav cluster moved left
sirozha Jul 28, 2026
5512791
test(e2e): refresh the flows-5 visual baseline for the moved header p…
sirozha Jul 28, 2026
9d975d9
test(e2e): cover the knowledge detail header pager order
sirozha Jul 28, 2026
1173bea
fix(e2e): ensure git commit works without global user config in CI
asdek Jul 29, 2026
6cfc72d
refactor(e2e): streamline git command formatting in CI tests
asdek Jul 29, 2026
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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
frontend/coverage
frontend/dist
frontend/node_modules
frontend/e2e/test-results
frontend/e2e/playwright-report
frontend/e2e/blob-report
frontend/e2e/.auth
**/*.tsbuildinfo
frontend/ssl

**/*.log
**/*.env
**/.env.*
!**/.env.example
**/.DS_Store
**/Thumbs.db
57 changes: 57 additions & 0 deletions .github/scripts/codegen-inputs-changed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Decide whether a run has to re-check that frontend/src/graphql/types.ts is fresh.
# Echoes `changed=true|false`. Split out of ci.yml so the range selection is testable
# (frontend/e2e/ci-codegen-gate.unit.test.ts drives it over real git fixtures).
#
# Usage: codegen-inputs-changed.sh <event_name> <before_sha> <base_sha> <head_sha>
set -uo pipefail

EVENT="${1:-}"
BEFORE="${2:-}"
BASE_SHA="${3:-}"
HEAD_SHA="${4:-}"

# On pull_request, `before` is the PR's PREVIOUS HEAD, so a follow-up push diffs a range
# that no longer contains the commit that edited the schema — the gate would skip itself
# on every push after the first. The PR's base is the range that always spans the change.
if [ "$EVENT" = "pull_request" ]; then
base="$BASE_SHA"
else
base="$BEFORE"
fi

# When the range can't be resolved (new branch, force-push, tag), check anyway.
# stdout is consumed verbatim as a workflow output, so diagnostics go to stderr.
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "reason=unresolvable-base" >&2
echo "changed=true"
exit 0
fi

# The generated file is in the list too: a push that edits only it would otherwise skip
# the check, and the drift surfaces later on someone else's unrelated codegen push.
INPUTS=(
backend/pkg/graph/schema.graphqls
frontend/graphql-schema.graphql
frontend/graphql-codegen.ts
frontend/pnpm-lock.yaml
frontend/src/graphql/types.ts
)

if ! files=$(git diff --name-only "$base" "$HEAD_SHA"); then
echo "reason=diff-failed" >&2
echo "changed=true"
exit 0
fi

for input in "${INPUTS[@]}"; do
case $'\n'"$files"$'\n' in
*$'\n'"$input"$'\n'*)
echo "changed=true"
exit 0
;;
esac
done

echo "changed=false"
51 changes: 27 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
name: Docker build and push

on:
# pull_request so the lint/codegen/test gate runs on fork PRs too: a fork's commits never push to
# this repo, so a push-only trigger let a fork edit the schema without regenerating types.ts (or
# ship a lint/test failure) and merge green, since e2e.yml's tsc build cannot see stale codegen.
# lint-and-test uses no secrets and is fork-safe; docker-build stays gated to main/tags below.
pull_request:
types: [opened, synchronize, reopened]
push:
branches:
- "**"
- main
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

Expand All @@ -32,7 +42,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
go-version: "1.24"
cache: true
cache-dependency-path: backend/go.sum

Expand All @@ -47,8 +57,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version-file: frontend/.nvmrc
cache: 'pnpm'
cache-dependency-path: 'frontend/pnpm-lock.yaml'
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"

# Frontend lint and test
- name: Frontend - Install dependencies
Expand All @@ -70,21 +80,14 @@ jobs:
# Only when a codegen input moved. The lockfile is included on purpose: a codegen
# version bump can change the output, and skipping it there would let types.ts go
# stale and fail the next schema push for something its author did not do.
# When the range can't be resolved (new branch, force-push, tag), check anyway.
- name: Frontend - Detect GraphQL codegen input changes
id: codegen-inputs
run: |
base='${{ github.event.before }}'
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$base" "${{ github.sha }}" | grep -qE '^(backend/pkg/graph/schema\.graphqls|frontend/graphql-schema\.graphql|frontend/graphql-codegen\.ts|frontend/pnpm-lock\.yaml)$'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
.github/scripts/codegen-inputs-changed.sh \
'${{ github.event_name }}' \
'${{ github.event.before }}' \
'${{ github.event.pull_request.base.sha }}' \
'${{ github.sha }}' >> "$GITHUB_OUTPUT"

# The app ships the operations compiled into src/graphql/types.ts; regenerate and
# diff so it can't drift from graphql-schema.graphql (the codegen input the stand's
Expand Down Expand Up @@ -133,17 +136,17 @@ jobs:
PACKAGE_VER=${LATEST_TAG#v}
CURRENT_COMMIT=$(git rev-parse HEAD)
TAG_COMMIT=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null || echo "")

if [ "$CURRENT_COMMIT" != "$TAG_COMMIT" ]; then
PACKAGE_REV=$(git rev-parse --short HEAD)
else
PACKAGE_REV=""
fi

LDFLAGS="-X pentagi/pkg/version.PackageName=pentagi -X pentagi/pkg/version.PackageVer=${PACKAGE_VER} -X pentagi/pkg/version.PackageRev=${PACKAGE_REV}"

echo "Building with version: ${PACKAGE_VER}${PACKAGE_REV:+-$PACKAGE_REV}"

# Build for AMD64
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o /tmp/pentagi-amd64 ./cmd/pentagi
echo "✓ Successfully built for linux/amd64"
Expand Down Expand Up @@ -179,13 +182,13 @@ jobs:
# Get latest tag version (without 'v' prefix)
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
VERSION=${LATEST_TAG#v}

# Get current commit hash
CURRENT_COMMIT=$(git rev-parse HEAD)

# Get commit hash of the latest tag
TAG_COMMIT=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null || echo "")

# Set revision only if current commit differs from tag commit
if [ "$CURRENT_COMMIT" != "$TAG_COMMIT" ]; then
PACKAGE_REV=$(git rev-parse --short HEAD)
Expand All @@ -205,7 +208,7 @@ jobs:
echo "patch=${VERSION}" >> $GITHUB_OUTPUT
echo " Docker tags: latest, ${major}, ${major}.${minor}, ${VERSION}"
fi

echo "version=${VERSION}" >> $GITHUB_OUTPUT

- name: Generate Docker metadata
Expand Down
56 changes: 45 additions & 11 deletions .github/workflows/e2e-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Download results
id: results
continue-on-error: true
uses: actions/download-artifact@v4
with:
Expand Down Expand Up @@ -55,15 +56,25 @@ jobs:
const fs = require('fs');
const run = context.payload.workflow_run;

// workflow_run.pull_requests is empty for fork PRs — resolve by head SHA.
let prNumber = run.pull_requests[0]?.number;
// Match the PR whose head is still this run's — one commit can belong to several PRs, so
// taking the first association (either source) could hand the report to a stale or
// foreign one. Same filter on both paths: workflow_run.pull_requests (empty for forks)
// and the head-SHA lookup that covers them.
// workflow_run.pull_requests carries head.repo.name but no full_name, unlike the API
// lookup — enforce the repo only when it is present, so the primary path still matches.
const isThisRun = (pr) =>
pr.head?.sha === run.head_sha &&
pr.head?.ref === run.head_branch &&
(pr.head?.repo?.full_name == null || pr.head.repo.full_name === run.head_repository?.full_name);

let prNumber = run.pull_requests.find(isThisRun)?.number;
if (!prNumber) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: run.head_sha,
});
prNumber = prs[0]?.number;
prNumber = prs.find(isThisRun)?.number;
}
if (!prNumber) {
core.info(`No PR found for ${run.head_sha}; skipping comment.`);
Expand Down Expand Up @@ -94,32 +105,55 @@ jobs:
return;
}

let statsLine = 'The run produced no results file (it likely failed before the tests started).';
// e2e.yml uploads results.json with `if-no-files-found: ignore`, so a run that never
// wrote one publishes no artifact at all: a failed download means the file is absent,
// a successful download with unreadable stats means it is corrupt.
const resultsDownloaded = '${{ steps.results.outcome }}' === 'success';

let statsLine = resultsDownloaded
? 'The results file could not be parsed, so no test counts are available.'
: 'No results artifact was produced for this run.';
let statsRead = false;
let noFailures = false;
try {
const report = JSON.parse(fs.readFileSync('results/results.json', 'utf8'));
const n = (value) => Number(value) || 0;
const { expected, unexpected, flaky, skipped } = report.stats ?? {};
statsLine = `**${n(expected)} passed**, ${n(unexpected)} failed, ${n(flaky)} flaky, ${n(skipped)} skipped.`;
noFailures = n(unexpected) === 0;
statsRead = true;
} catch (error) {
core.info(`results.json unavailable: ${error}`);
}

// Both halves are required: results.json is written before an abort can be
// observed, so a run killed by the global timeout still reports zero failures.
// When the jobs API was unreachable, fall back to results.json alone.
const mockPassed = noFailures && (jobsFetched ? mockJob?.conclusion === 'success' : true);
// The job conclusion is load-bearing, not a nicety: results.json is written before a
// global-timeout abort can be observed (its un-run tests land in `skipped`, so
// `unexpected` stays 0), and only the job's own conclusion reflects the abort. So a
// green needs BOTH zero failures AND a successful job; whenever either source is
// unreadable the verdict is unknown — never a green, and never a red.
const jobFailed = jobsFetched && mockJob?.conclusion !== 'success';
const mockStatus = jobFailed || (statsRead && !noFailures)
? 'fail'
: statsRead && jobsFetched
? 'pass'
: 'unknown';

// Key the advisory on the e2e-visual-diffs artifact (uploaded only when the snapshot
// step itself failed), not on the visual job's conclusion — a tag-guard, install or
// build failure would otherwise read as "snapshots differ".
const visualDiffers = '${{ steps.visualdiffs.outcome }}' === 'success';

const conclusion = !mockPassed ? '❌' : visualDiffers ? '⚠️' : '✅';
const conclusion = mockStatus === 'fail' ? '❌' : mockStatus === 'unknown' || visualDiffers ? '⚠️' : '✅';
const marker = '<!-- e2e-report -->';
const advisoryLine = visualDiffers
? '⚠️ **Visual snapshots differ** — advisory only, does not block merge. Download the `e2e-visual-diffs` artifact to compare expected / actual / diff.'
const unconfirmedReason = !jobsFetched && !statsRead
? 'neither the workflow jobs API nor `results.json` could be read'
: !jobsFetched
? 'the workflow jobs API was unreachable, so this reflects `results.json` alone, which cannot distinguish a clean pass from a run killed mid-flight'
: '`results.json` could not be read, so this reflects the job conclusion alone and no test counts were checked';
const advisoryLine = mockStatus === 'unknown'
? `⚠️ **Result unconfirmed** — ${unconfirmedReason}. Check the run before merging.`
: visualDiffers
? '⚠️ **Visual snapshots differ** — advisory only, does not block merge. Download the `e2e-visual-diffs` artifact to compare expected / actual / diff (a missing baseline uploads actual only).'
: null;
const body = [
marker,
Expand Down
57 changes: 43 additions & 14 deletions .github/workflows/e2e-stand.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,26 @@ on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
tier:
description: "Run the stand tier"
type: choice
options:
- stand
- all
default: stand

permissions:
contents: read

# Keyed on the label too: concurrency is evaluated for the whole run before the job's `if`, so a run
# started by an unrelated label would share this group and cancel an approved stand run mid-flight.
concurrency:
group: e2e-stand-${{ github.event.pull_request.number || github.run_id }}
group: e2e-stand-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.label.name }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
e2e-stand:
# GitHub withholds secrets from fork `pull_request` runs: without the fork check a labelled fork
# PR holds reviewers for an environment approval and then fails on empty credentials. Assert repo
# identity rather than negating `head.repo.fork`: a deleted fork sends `head.repo: null`, and
# GitHub coerces null and false alike to 0, so the negation lets that PR through.
if: >
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'e2e:stand')
(github.event.label.name == 'e2e:stand' &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 30
environment: e2e-stand
Expand Down Expand Up @@ -78,11 +77,41 @@ jobs:
E2E_USER: ${{ secrets.E2E_STAND_USER }}
E2E_PASSWORD: ${{ secrets.E2E_STAND_PASSWORD }}

# Traces, videos and screenshots are all disabled on this tier — they would
# embed the live session cookie, the stand URL or the login into a
# public-repo artifact. results.json carries none of those.
- name: Upload report
# Traces, videos and screenshots are disabled on this tier. results.json still
# embeds the stand URL (baseURL in navigation/locator error messages) and the
# user (in locator text) on a failing run, so redact the secrets before the
# public-repo upload — GitHub masks secrets in logs, never in artifacts.
- name: Redact secrets from report
id: redact
if: always()
working-directory: frontend
run: |
node -e '
const fs = require("fs");
const f = "e2e/test-results/results.json";
if (!fs.existsSync(f)) process.exit(0);
let s = fs.readFileSync(f, "utf8");
// Derived forms too, not just the raw value: auth.setup.ts builds a locator name from
// the regex-escaped user, so a failed login writes `user@host\.com` into the error —
// and since this redacts the JSON text, that backslash is itself encoded as `\\`.
const regexEscaped = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const jsonEncoded = (v) => JSON.stringify(v).slice(1, -1);
for (const v of [process.env.E2E_BASE_URL, process.env.E2E_USER, process.env.E2E_PASSWORD]) {
if (!v) continue;
const forms = [v, regexEscaped(v), encodeURIComponent(v)];
for (const form of new Set([...forms, ...forms.map(jsonEncoded)])) {
s = s.split(form).join("<redacted>");
}
}
fs.writeFileSync(f, s);
'
env:
E2E_BASE_URL: ${{ secrets.E2E_STAND_URL }}
E2E_USER: ${{ secrets.E2E_STAND_USER }}
E2E_PASSWORD: ${{ secrets.E2E_STAND_PASSWORD }}

- name: Upload report
if: always() && steps.redact.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: e2e-stand-report
Expand Down
Loading
Loading