| 1 | name: End-to-end |
| 2 | |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | pull_request: |
| 7 | |
| 8 | permissions: |
| 9 | contents: read |
| 10 | |
| 11 | concurrency: |
| 12 | group: e2e-${{ github.ref }} |
| 13 | cancel-in-progress: true |
| 14 | |
| 15 | jobs: |
| 16 | # The suite in parallel shards of about the same length: the git, pull request and |
| 17 | # Actions file split in three (its real histories and large pushes on their own), agents, security, and |
| 18 | # every other test/e2e/*.test.mjs file (so a new file runs without editing this). |
| 19 | # Only the last shard needs a browser (passkeys). |
| 20 | e2e: |
| 21 | name: End-to-end (${{ matrix.name }}) |
| 22 | runs-on: ubuntu-latest |
| 23 | timeout-minutes: 20 |
| 24 | strategy: |
| 25 | fail-fast: false |
| 26 | matrix: |
| 27 | include: |
| 28 | - name: git, PRs, Actions |
| 29 | shard: core |
| 30 | - name: real histories |
| 31 | shard: histories |
| 32 | - name: large pushes |
| 33 | shard: large |
| 34 | - name: agent reviews, tasks, Today |
| 35 | shard: agents |
| 36 | - name: security |
| 37 | shard: security |
| 38 | - name: passkeys, registry, email |
| 39 | shard: rest |
| 40 | steps: |
| 41 | - uses: actions/checkout@v4 |
| 42 | - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 |
| 43 | - uses: actions/setup-node@v4 |
| 44 | with: |
| 45 | node-version: 22 |
| 46 | cache: pnpm |
| 47 | - run: pnpm install --frozen-lockfile |
| 48 | - run: pnpm -r --if-present build |
| 49 | - if: matrix.shard == 'rest' |
| 50 | run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium |
| 51 | # Agent runs (terminal tasks, and headless runs in the agents shard) are in a bubblewrap jail |
| 52 | # (containers/runner/src/jail.ts); Ubuntu's AppArmor otherwise keeps |
| 53 | # it from creating user namespaces. Diffinery's own runner image already has bwrap and no sudo, running as root. |
| 54 | - if: matrix.shard == 'rest' || matrix.shard == 'agents' |
| 55 | run: | |
| 56 | as_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } |
| 57 | if ! command -v bwrap >/dev/null; then |
| 58 | as_root apt-get install -y --no-install-recommends bubblewrap || { as_root apt-get update -qq && as_root apt-get install -y --no-install-recommends bubblewrap; } |
| 59 | fi |
| 60 | as_root sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true |
| 61 | bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc -- true |
| 62 | - name: Run the shard's tests |
| 63 | working-directory: apps/diffinery |
| 64 | run: | |
| 65 | # Shards of similar length; a new test file runs in "rest" unless it's added to another. |
| 66 | core='/e2e\.test\.mjs$' |
| 67 | agents='/(agentic|review|tasks)\.test\.mjs$' |
| 68 | # security.test.mjs, security-*.test.mjs (security-txt's too) and status.test.mjs. |
| 69 | security='/(security(-[a-z-]+)?|status)\.test\.mjs$' |
| 70 | # The core file's real histories and large pushes take minutes each (jquery's alone 2 to 3 on |
| 71 | # Diffinery's runners): two shards of their own, so no part of the file runs into node's |
| 72 | # per-file timeout, which is the --test-timeout here. |
| 73 | histories='^real histories:' |
| 74 | large='^(large files|large pushes):' |
| 75 | opts=(--test-timeout=600000) |
| 76 | case "${{ matrix.shard }}" in |
| 77 | core) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts+=(--test-skip-pattern="$histories" --test-skip-pattern="$large") ;; |
| 78 | histories) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts=(--test-timeout=900000 --test-name-pattern="$histories") ;; |
| 79 | large) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts+=(--test-name-pattern="$large") ;; |
| 80 | agents) files=$(ls test/e2e/*.test.mjs | grep -E "$agents") ;; |
| 81 | security) files=$(ls test/e2e/*.test.mjs | grep -E "$security") ;; |
| 82 | *) files=$(ls test/e2e/*.test.mjs | grep -vE "$core|$agents|$security") ;; |
| 83 | esac |
| 84 | echo "$files" |
| 85 | # Two files at a time: each starts its own local server on its own port, database and |
| 86 | # directory, so they don't share state, and a file's server mostly keeps one core busy. |
| 87 | node --test --test-concurrency=2 "${opts[@]}" $files |
| 88 | |
| 89 | # Docker as the hosted runner image runs it, with this change's docker wrapper, daemon config |
| 90 | # and agent (its registry relay): this job runs on a hosted runner, so the real dockerd is there. |
| 91 | # A daemon an earlier step started is stopped first, so the first uses below start it afresh. |
| 92 | runner-docker: |
| 93 | name: End-to-end (runner Docker) |
| 94 | runs-on: ubuntu-latest |
| 95 | timeout-minutes: 20 |
| 96 | steps: |
| 97 | - uses: actions/checkout@v4 |
| 98 | - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 |
| 99 | - uses: actions/setup-node@v4 |
| 100 | with: |
| 101 | node-version: 22 |
| 102 | cache: pnpm |
| 103 | - run: pnpm install --frozen-lockfile |
| 104 | - run: pnpm --filter @diffinery/runner build |
| 105 | - name: Use this change's Docker wrapper, daemon config and agent |
| 106 | run: | |
| 107 | set -euo pipefail |
| 108 | as_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } |
| 109 | if [ ! -f /opt/diffinery/agent.mjs ] || ! grep -q "registry relay" /usr/local/bin/docker 2>/dev/null; then |
| 110 | echo "::error::this job needs a Diffinery hosted runner (its docker wrapper and agent)" |
| 111 | exit 1 |
| 112 | fi |
| 113 | as_root pkill -x dockerd || true |
| 114 | as_root pkill -f -- "--registry-relay" || true |
| 115 | for _ in $(seq 1 60); do pgrep -x dockerd >/dev/null || break; sleep 0.5; done |
| 116 | as_root rm -f /run/diffinery-dockerd.ready |
| 117 | as_root cp --remove-destination containers/runner/image/docker /usr/local/bin/docker |
| 118 | as_root cp --remove-destination containers/runner/image/daemon.json /etc/docker/daemon.json |
| 119 | as_root cp --remove-destination containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs |
| 120 | - name: Docker's first uses, several at once |
| 121 | timeout-minutes: 4 |
| 122 | run: | |
| 123 | set -uo pipefail |
| 124 | start=$(date +%s) |
| 125 | docker info >/dev/null & a=$! |
| 126 | docker ps >/dev/null & b=$! |
| 127 | docker version >/dev/null & c=$! |
| 128 | failed=0 |
| 129 | for p in $a $b $c; do wait "$p" || failed=1; done |
| 130 | echo "three docker commands at once, the daemon starting: $(( $(date +%s) - start )) s" |
| 131 | [ "$failed" = 0 ] || { echo "::error::a docker command failed while the daemon started"; exit 1; } |
| 132 | docker run --rm mirror.gcr.io/library/alpine:3.20 echo "container ok" |
| 133 | - name: Runner Docker tests (job containers, services, Docker actions) |
| 134 | timeout-minutes: 10 |
| 135 | working-directory: containers/runner |
| 136 | run: pnpm exec vitest run test/docker.test.ts test/docker-wrapper.test.ts |