Skip to content

01a02039 - Skip draft CI, default E2E, and run tests before E2E #364

01a02039 - Skip draft CI, default E2E, and run tests before E2E

01a02039 - Skip draft CI, default E2E, and run tests before E2E #364

Workflow file for this run

# Full-stack E2E: real frontend + API + Postgres against mocked external providers.
#
# A job-level skip is intentional only for drafts that lack `ci`/`ci:full`.
# A skipped required check counts as passing; that is acceptable only because
# GitHub cannot merge a draft. Ready is handled exclusively by ci-on-ready.yaml
# (adds `ci` and workflow_dispatches this suite; GITHUB_TOKEN cannot trigger
# `labeled`). Never listen to `ready_for_review` here. A human-applied `ci`
# still starts via `labeled`. 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 ready or labeled 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.
#
# Documentation-only PRs (docs/ and *.md) with safe path characters bring up no stack
# (mode=none); when the job is allowed to run, Playwright succeeds with a message.
# 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".
# Draft skip is the only job-level `if:`. API checkout, bootstrap resolution, and
# harness tsc run only when mode != none.
# PRs into main, a bare workflow_dispatch (empty base_ref), and PRs with the `ci:full`
# label always run the full suite. A Ready kick with base_ref follows pull-request
# scope. A change under e2e-stack/ or to .github/workflows/e2e-stack.yml always forces
# full (the workflow must not classify itself as none). Every other remaining change
# after filtering docs/ and *.md is treated as runtime-relevant and runs full. There is
# no selected/partial spec mapping in this repository.
name: Full-stack E2E
on:
pull_request:
branches:
- main
- develop
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
workflow_dispatch:
inputs:
api_ref:
description: 'Git ref of DFXswiss/backend to check out and build against'
required: false
default: 'develop'
type: string
pr_number:
description: PR number when kicked by ci-on-ready (empty = manual full run)
required: false
type: string
base_ref:
description: PR base branch when kicked by ci-on-ready
required: false
type: string
permissions:
contents: read
# Draft-skip runs must not share the working group: concurrency fires before
# the job `if:` and would cancel a live Ready dispatch.
concurrency:
group: e2e-stack-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}-${{ (github.event_name == 'workflow_dispatch' || 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:
# Full history (fetch-depth: 0): the scope step below diffs against the merge base
# with the PR's base branch (`origin/BASE...HEAD`), which a shallow single-commit
# checkout cannot resolve.
- name: Checkout services
uses: actions/checkout@v4
with:
fetch-depth: 0
# Decides in-job whether to bring the stack up — never via a `paths:` filter.
# 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 }}
HAS_API_KEY: ${{ secrets.E2E_API_CHECKOUT_KEY != '' }}
run: |
set -euo pipefail
# Ready kick: 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
# Fork PRs never receive repository secrets. Without E2E_API_CHECKOUT_KEY the
# next step cannot read the companion API and fails as "repository not found".
# Same shape as documentation-only: mode=none, job still runs, check stays
# present. Must be first — a change to this file would otherwise force full
# and then die on checkout. The probe is the secret, not github.head_ref
# (that name is fork-spoofable). workflow_dispatch keeps the secret.
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
# 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
# Safety net: guarantee the base ref exists locally even if the checkout behavior changes.
git fetch --quiet origin "$BASE"
# Deletions stay in the diff on purpose (no --diff-filter=d): a PR that only
# deletes a runtime-relevant file would otherwise produce an empty list, select
# 'none' and bring up no stack at all.
#
# --no-renames: git's rename detection reports only the NEW path of a renamed
# file. With --no-renames a rename is classified as a deletion plus an addition.
CHANGED=$(git diff --no-renames --name-only "origin/$BASE...HEAD")
echo 'Changed files:'
printf '%s\n' "$CHANGED"
# Path-safety guard on the RAW diff, before filtering: git quotes exotic path
# names (core.quotePath), which would distort the classification below - the full
# run is the lossless answer for anything outside the safe character set.
# Herestring instead of a pipe: with pipefail, grep -q quitting early could turn
# a hit into a SIGPIPE failure and silently defuse the guard. grep exit codes 1
# (no match) and >=2 (failure) must not be conflated.
rc=0
grep -qv '^[A-Za-z0-9._/-]*$' <<< "$CHANGED" || rc=$?
if (( rc >= 2 )); then
echo "::error::grep failed while validating changed paths (exit $rc)."
exit 1
fi
if (( rc == 0 )); then
echo 'Full run: a changed path contains characters outside the safe set A-Za-z0-9._/-.'
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
# Harness and this workflow are taken from the PR head. A change to either must
# not be allowed to classify itself as mode=none.
rc=0
e2e_hit=$(grep -E '^(e2e-stack/|\.github/workflows/e2e-stack\.yml$)' <<< "$CHANGED") || rc=$?
if (( rc >= 2 )); then
echo "::error::grep failed while matching e2e-stack or workflow paths (exit $rc)."
exit 1
fi
if (( rc == 0 )); then
echo 'Full run: the PR touches e2e-stack/ or this workflow:'
echo "$e2e_hit"
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
# Drop documentation: a path is documentation if it starts with docs/ or ends
# with .md. Empty remainder (docs-only or originally empty diff) → none. Every
# leftover path is treated as runtime-relevant → full. grep exit 1 (no remaining
# files) is none; >=2 must fail the step.
rc=0
remaining=$(grep -Ev '(^docs/|\.md$)' <<< "$CHANGED") || rc=$?
if (( rc >= 2 )); then
echo "::error::grep failed while filtering documentation paths (exit $rc)."
exit 1
fi
# rc==1: grep found no remaining files. Empty remaining after command
# substitution also covers an originally empty diff (grep may still exit 0
# on a blank herestring line that then collapses to "").
if (( rc == 1 )) || [[ -z "$remaining" ]]; then
echo 'No run: documentation-only or empty diff (no runtime-relevant changes).'
echo "mode=none" >> "$GITHUB_OUTPUT"
exit 0
fi
echo 'Full run: remaining paths are treated as runtime-relevant:'
echo "$remaining"
echo "mode=full" >> "$GITHUB_OUTPUT"
# 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
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
if [ "${API_CRED}" != 'yes' ]; then
echo 'No API checkout credential — not starting the e2e stack. The job ran.'
exit 0
fi
case "$MODE" in
none)
echo 'No runtime-relevant changes in this PR (documentation-only or empty diff) — not running the e2e stack.'
;;
full)
# 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