01a02039 - Skip draft CI, default E2E, and run tests before E2E #364
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |