forked from DFXswiss/app
-
Notifications
You must be signed in to change notification settings - Fork 0
337 lines (314 loc) · 16 KB
/
Copy pathe2e-stack.yml
File metadata and controls
337 lines (314 loc) · 16 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
# Full-stack E2E: real frontend + API + Postgres against mocked external providers.
#
# Draft skip lives on the caller in pr.yml (job `if:`). This file has no
# pull_request trigger, so `labeled` and Ready do not start it. Ready dispatches
# pr.yml (not this file); pr.yml then workflow_calls here after Build and test.
# workflow_dispatch remains for manual runs. Never listen to ready_for_review
# here. Scope (full vs none) still happens inside the job (the "Determine e2e
# scope" step) instead of `on.paths` or a job `if:` that would skip a required
# check. If you ever consider adding another skip anywhere in this file,
# justify it in a comment right there.
#
# Relation to DFXswiss/backend: the corresponding workflow there checks this repository out to
# find e2e-stack/, so it reads whatever is on develop at the time it runs. Until this pull
# request has merged, that workflow bootstraps the harness from this pull request's head
# instead, which means neither side is blocked on the other and either merge order works.
#
# A develop PR without the ci:full label does not bring up the stack (mode=none
# inside the job). That is what keeps a standard develop PR under 10 minutes.
# PRs into main, a bare workflow_dispatch, and the ci:full label run the full
# suite. PR CI passes the boolean `full` (derived from base branch and labels);
# the workflow_call path only reads that input. Fork pull requests also take
# mode=none: they do not receive repository secrets, so E2E_API_CHECKOUT_KEY is
# empty and Checkout API would fail as "repository not found". The probe is the
# secret, not github.head_ref
# (that name is fork-spoofable). Draft skip is the only job-level `if:`. API
# checkout, bootstrap resolution, and harness tsc run only when mode != none.
# There is no selected/partial spec mapping in this repository.
name: Full-stack E2E
on:
# Called from pr.yml after Build and test succeed. A pull_request trigger
# here would start this job in parallel with unit tests.
workflow_call:
inputs:
pr_number:
description: PR number when called from PR CI
required: false
type: string
base_ref:
description: PR base branch when called from PR CI
required: false
type: string
full:
description: Bring up the stack (main / ci:full / bare dispatch)
required: false
type: boolean
default: false
api_ref:
description: Git ref of the companion API to build against
required: false
type: string
workflow_dispatch:
inputs:
api_ref:
description: 'Git ref of DFXswiss/backend to check out and build against (empty = develop)'
required: false
type: string
pr_number:
description: PR number for manual dispatch (empty = no PR)
required: false
type: string
base_ref:
description: PR base branch for manual dispatch (empty = bare/full run)
required: false
type: string
permissions:
contents: read
# Draft-skip runs must not share the working group with a live call.
# workflow_call (PR path) and workflow_dispatch (manual) must not share a
# cancel group either. Include event_name so a dispatch cannot cancel the
# nested required check.
concurrency:
group: e2e-stack-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}-${{ github.event_name }}-${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ci') || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'run' || 'skip' }}
cancel-in-progress: true
jobs:
e2e:
name: Full-stack E2E
if: >
github.event_name != 'pull_request' ||
github.event.pull_request.draft == false ||
contains(github.event.pull_request.labels.*.name, 'ci') ||
contains(github.event.pull_request.labels.*.name, 'ci:full')
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
# Event-only: no checkout. Develop PRs without ci:full stop here.
# Draft skip is the job `if:` above (see header). Modes are only full
# and none; there is no selected/partial spec mapping in this repository.
- name: Determine e2e scope
id: scope
env:
EVENT_NAME: ${{ github.event_name }}
BASE: ${{ github.base_ref || inputs.base_ref }}
FULL: ${{ inputs.full }}
HAS_API_KEY: ${{ secrets.E2E_API_CHECKOUT_KEY != '' }}
run: |
set -euo pipefail
# Fork PRs never receive repository secrets. Probe the secret, not
# github.head_ref (that name is fork-spoofable).
if [ "$HAS_API_KEY" != 'true' ]; then
echo 'No run: E2E_API_CHECKOUT_KEY is not available (typical of a fork pull request).'
echo "mode=none" >> "$GITHUB_OUTPUT"
exit 0
fi
# Caller (PR CI) already decided full vs none after unit tests passed.
if [ "$EVENT_NAME" = 'workflow_call' ]; then
if [ "$FULL" = 'true' ]; then
echo 'Full run: caller requested a full stack.'
echo "mode=full" >> "$GITHUB_OUTPUT"
else
echo 'E2E skipped: add the ci:full label to run the stack.'
echo "mode=none" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Manual workflow_dispatch with a non-empty base_ref maps to
# pull-request scope. A bare manual dispatch has empty BASE and stays full.
if [ "$EVENT_NAME" = 'workflow_dispatch' ] && [ -n "${BASE:-}" ]; then
EVENT_NAME=pull_request
fi
# Release PRs into main (and any non-develop base) and manual dispatches always
# run everything: they are the last gate before a release, so no selection there.
if [ "$EVENT_NAME" != 'pull_request' ] || [ "$BASE" != 'develop' ]; then
echo "Scope: full (event=$EVENT_NAME, base=${BASE:-n/a})"
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
# Exact `.name == "ci:full"` via jq: GitHub's `contains()` on label arrays is
# not case-sensitive. jq exit 1 (no match) must not be conflated with >=2
# (failure) - a failed jq must fail the step, not silently shrink the gate.
rc=0
jq -e 'any(.pull_request.labels[]?; .name == "ci:full")' "$GITHUB_EVENT_PATH" >/dev/null || rc=$?
if (( rc >= 2 )); then
echo "::error::jq failed while checking for the ci:full label (exit $rc)."
exit 1
fi
if (( rc == 0 )); then
echo 'Full run: the PR has the ci:full label.'
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
echo 'E2E skipped: add the ci:full label to run the stack.'
echo "mode=none" >> "$GITHUB_OUTPUT"
- name: Checkout services
if: steps.scope.outputs.mode != 'none'
uses: actions/checkout@v4
# GITHUB_TOKEN of this public repository cannot read the private API
# repository. A read-only deploy key (secret E2E_API_CHECKOUT_KEY) is
# the checkout credential. Fork PRs do not receive that secret; detect
# the empty value here (never print it) so the job still runs and the
# Playwright step can take the documented no-stack path instead of
# failing at checkout with "repository not found".
- name: Detect API checkout credential
if: steps.scope.outputs.mode != 'none'
id: api_cred
env:
KEY: ${{ secrets.E2E_API_CHECKOUT_KEY }}
run: |
set -euo pipefail
if [ -n "${KEY}" ]; then
echo "available=yes" >> "$GITHUB_OUTPUT"
else
echo "available=no" >> "$GITHUB_OUTPUT"
echo "E2E_API_CHECKOUT_KEY is not available to this run (typical for a fork pull request)."
fi
- name: Checkout API
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
uses: actions/checkout@v4
with:
repository: DFXswiss/backend
ref: ${{ inputs.api_ref || 'develop' }}
path: api-repo
ssh-key: ${{ secrets.E2E_API_CHECKOUT_KEY }}
persist-credentials: false
# The harness refuses an API image whose process error handling can throw while logging,
# because such an image cannot stay up in a network with no route out. That guard lives in
# DFXswiss/backend#4753 and is not on its default branch yet, so until it lands this job builds
# from that pull request instead — the mirror image of what the API repository's own workflow
# does with this one. Self-disabling: once the guard is on develop, the check below passes and
# this step is skipped. It also refuses to bootstrap from a pull request that is no longer
# open, so it cannot quietly re-arm later and build a years-old revision.
- name: Resolve the API revision to build
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
id: api_source
env:
API_REF: ${{ inputs.api_ref }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f api-repo/src/shared/utils/safe-log.ts ]; then
echo "bootstrap=false" >> "$GITHUB_OUTPUT"
elif [ -n "$API_REF" ]; then
echo "::error::DFXswiss/backend@${API_REF} does not carry the guarded process error handling"
echo "::error::(src/shared/utils/safe-log.ts), and api_ref was set explicitly, so no fallback"
echo "::error::applies. Point it at a revision that has it, or omit api_ref to use develop."
exit 1
else
err_file="$(mktemp -p "${RUNNER_TEMP}")"
if ! state="$(gh api repos/DFXswiss/backend/pulls/4753 --jq .state 2>"$err_file")"; then
echo "::error::Could not determine the state of DFXswiss/backend#4753 (gh api call failed):"
echo "::error::$(cat "$err_file")"
rm -f "$err_file"
exit 1
fi
rm -f "$err_file"
if [ "$state" != "open" ]; then
echo "::error::DFXswiss/backend#4753 is no longer open (state: ${state}); this fallback is spent."
echo "::error::The guard is expected on DFXswiss/backend@develop now. Delete this step and the"
echo "::error::'Check out the API revision that carries the guard' step below."
exit 1
fi
echo "::warning::DFXswiss/backend@develop does not carry the guarded process error handling yet."
echo "::warning::Building the API image from DFXswiss/backend#4753 instead."
echo "::warning::Merge that pull request to remove this temporary fallback."
echo "bootstrap=true" >> "$GITHUB_OUTPUT"
fi
- name: Check out the API revision that carries the guard
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes' && steps.api_source.outputs.bootstrap == 'true'
uses: actions/checkout@v4
with:
repository: DFXswiss/backend
ref: refs/pull/4753/head
path: api-repo
ssh-key: ${{ secrets.E2E_API_CHECKOUT_KEY }}
persist-credentials: false
- name: Setup Node.js
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# The only `npm ci` in this job runs in e2e-stack/, so the cache key must hash that
# lockfile — the repo-root one it defaults to belongs to a dependency set nothing here
# installs.
cache-dependency-path: e2e-stack/package-lock.json
# The suite sits outside the repo's own tsconfig and ESLint globs (both scoped to src/),
# so nothing else in CI type-checks it. Doing it here costs under a minute and fails on a
# type error before the run spends eight on Docker.
- name: Type-check the harness
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
working-directory: e2e-stack
run: |
npm ci
npx tsc --noEmit
# run.sh is not used here: its EXIT trap tears the stack down with
# `docker compose down -v`, which destroys the named volumes that hold
# Playwright traces, screenshots, videos, and the HTML report before they
# can be copied out. Instead we bring the stack up, run tests, copy
# artifacts from the still-present named volumes / tests container, and
# only then tear down in a final always() step.
- name: Bring stack up
if: steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
run: E2E_API_REPO=$GITHUB_WORKSPACE/api-repo bash e2e-stack/scripts/up.sh
- name: Run Playwright tests
# No continue-on-error: a failed test must fail the job. Later steps use
# `if: always()` so artifact collection and teardown still run.
# --env-file is not optional here: up.sh writes the values it resolved into that file, and
# a compose run that resolves them differently recreates the API container mid-run.
# Draft skip is the job `if:` above. Inside a run, selection is this step
# rather than a `paths:` filter or a scope `if:` (see header comment).
env:
MODE: ${{ steps.scope.outputs.mode }}
API_CRED: ${{ steps.api_cred.outputs.available }}
run: |
set -euo pipefail
case "$MODE" in
none)
echo 'E2E skipped: add the ci:full label to run the stack (or target main / dispatch).'
exit 0
;;
full)
if [ "${API_CRED}" != 'yes' ]; then
echo 'No API checkout credential — not starting the e2e stack. The job ran.'
exit 0
fi
# Every spec is in scope here, so the coverage gate checks navigations, not just claims.
# E2E_FULL_RUN is inline on this command only — not a static step env that would also
# apply to mode=none.
E2E_FULL_RUN=1 docker compose \
-p dfx-e2e-stack \
--env-file e2e-stack/.env.generated \
-f e2e-stack/compose.yml \
-f e2e-stack/compose.tests.yml \
run --name dfx-e2e-stack-tests tests
;;
*)
# An unknown mode must fail loud — the check would otherwise succeed
# without having run anything (interface drift protection).
echo "::error::Unknown scope mode: '$MODE'."
exit 1
;;
esac
- name: Collect test artifacts
if: always() && steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
run: |
mkdir -p e2e-stack-artifacts
# Named volumes back these paths inside the tests container. Bind mounts
# are not used. Copy out before down.sh removes the volumes (-v).
docker cp dfx-e2e-stack-tests:/work/test-results e2e-stack-artifacts/test-results \
|| docker compose -p dfx-e2e-stack cp dfx-e2e-stack-tests:/work/test-results e2e-stack-artifacts/test-results \
|| true
docker cp dfx-e2e-stack-tests:/work/playwright-report e2e-stack-artifacts/playwright-report \
|| docker compose -p dfx-e2e-stack cp dfx-e2e-stack-tests:/work/playwright-report e2e-stack-artifacts/playwright-report \
|| true
- name: Upload test artifacts
if: always() && steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
uses: actions/upload-artifact@v4
with:
name: e2e-stack-report
path: e2e-stack-artifacts
retention-days: 7
if-no-files-found: warn
- name: Tear down stack
if: always() && steps.scope.outputs.mode != 'none' && steps.api_cred.outputs.available == 'yes'
run: bash e2e-stack/scripts/down.sh