Repository navigation
402 lines (383 loc) · 21.2 KB
/
Copy pathdesktop-e2e.yml
File metadata and controls
402 lines (383 loc) · 21.2 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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
name: Desktop E2E
# Playwright against the DESKTOP APPLIANCE — the only end-to-end check of what
# a person who downloaded the app actually gets: the PUBLISHED images, an empty
# database, and no seeded account to borrow (tests/desktop, driven by
# playwright.desktop.config.ts). `e2e.yml` deliberately covers the opposite
# configuration — a locally built database, migrated and seeded, with
# jsmith@example.com already in it — so neither workflow substitutes for the
# other, and the first-run bugs this suite exists for (signup impossible on a
# fresh appliance; a splash whose only call to action led to a login page a new
# user could not use) live only in this one.
#
# It brings the stack up exactly the way the Tauri shell does — same three -f
# files, same `--profile production`, same `--scale powersync=0` — so that a
# failure here is a failure of the real product and not of a CI-only
# arrangement. `frontend/apps/desktop/src-tauri/src/stack_manager.rs` is the
# authority for every one of those flags; see the step comments below, which
# name the function each mirrors.
#
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ SOURCE OF TRUTH: frontend/apps/desktop/src-tauri/src/stack_manager.rs ║
# ╚══════════════════════════════════════════════════════════════════════╝
# This workflow HAND-RETYPES two things that file owns. Enforcement now
# exists, but it is ONE-DIRECTIONAL — understand that before trusting it.
# stack_manager.rs's own `#[cfg(test)]` module pins its half against literals,
# in two tests:
# the_generated_env_file_holds_exactly_the_keys_desktop_e2e_yml_mirrors
# the_compose_invocation_and_up_args_are_pinned
# so a change made THERE goes red under `make test-desktop` with a failure
# message naming this file. But nothing parses THIS file: a change made only
# HERE is still silently unenforced, and those tests can only prove the Rust
# side has not moved — never that the copy below was updated to match it.
# The retyped things are:
# * `write_env_file()` — the exact key list and values of the `.env` compose
# interpolates from. Retyped in "Write the desktop compose .env" below.
# * `COMPOSE_FILES` (3 entries, ORDER SIGNIFICANT — the desktop overlay must
# be last, it is what rewrites every `ci/<name>` image to the published
# `companion/<name>` one), plus `compose_command()`'s `--env-file`/
# `--project-name`/`--profile production` and `UP_ARGS`
# (`up -d --remove-orphans --scale powersync=0`). Retyped in FIVE places
# below, spread over FOUR steps — "Start the desktop stack", "Wait for the
# appliance to answer" (which holds two of them, its `ps` and `logs`
# failure dumps), "Upload stack logs", and "Tear down the desktop stack".
# Grep for `--project-name` before you believe you have found them all.
#
# The copies HAVE ALREADY DRIFTED. Known divergences, all believed deliberate
# today. Only 1 and 2 are written down on the Rust side as well (in
# EXPECTED_ENV_KEYS' doc comment); 3 to 5 are recorded nowhere but here:
# 1. This file ADDS `CI_REPO_DIR`, which write_env_file never writes —
# docker-compose.production.yml defaults the api's read-only archive
# mount to `~/Downloads`, which exists on a desktop and not on a runner.
# 2. This file OMITS `LLM_DEFAULT_CHAT_MODEL`, which write_env_file pins to
# the sidecar's DEFAULT_CHAT_MODEL. There is no Ollama sidecar here, so
# naming a model would only make the API ask for one that cannot exist —
# but it also means this suite cannot catch the model-mismatch class of
# bug that pin was added for.
# 3. This file hard-codes `OIDC_ISSUER_URL=https://hub.ci.computer` where
# write_env_file reads `CI_PORTAL_URL` defaulting to DEFAULT_PORTAL_URL.
# Identical today; if that constant ever moves, this diverges silently.
# 4. GATEWAY_URL hard-codes port 8642 where stack_manager.rs has
# `GATEWAY_PORT`, and COMPOSE_PROJECT_NAME hard-codes what `PROJECT_NAME`
# defines.
# 5. Nothing here exercises `apply_llm_env_override()`'s appended
# LLM_API_BASE/LLM_API_KEY (the Apple-on-device-chat branch), so that
# shape of `.env` is untested by this suite.
# THESE MUST BE RECONCILED, not left to rot: every divergence is a way for
# this suite to report green on a configuration no shipped app ever runs,
# which is precisely the failure mode the workflow exists to rule out. The
# tests named above close the "Rust moved and nobody told CI" half; the
# durable fix for the other half still belongs on the Rust side — have
# stack_manager.rs emit its env and compose spec in a form this workflow can
# CONSUME (a `--print-env`-style command, or a generated fixture the workflow
# diffs against) so the copy below stops existing rather than merely being
# watched. Until then: change stack_manager.rs and you MUST re-read this file,
# and change this file and nothing will tell you if you got it wrong.
# GATED TO MANUAL, matching this repo's convention: all thirteen of its
# workflows are `workflow_dispatch`-only as of 2026-09-17 (this file was
# already one of ten when it landed, moving that count from nine to ten; the
# other four exceptions — `pr-gate.yml`, `benchmark-unit.yml` and the two
# tag-triggered release lanes — were deleted or gated since, to cut the GitHub
# Actions bill). docs/CI.md's table is the canonical list; keep this line in
# step with it. CI silence here means "nobody dispatched it", NOT "everything
# passed". Run it
# deliberately:
# gh workflow run desktop-e2e.yml --ref <branch-or-tag>
# gh workflow run desktop-e2e.yml --ref <branch> -f image_tag=2026.8.26
# It is a release gate in the sense of `docs/qa/ci-gate-proposal.md` §3.3 — one
# of the dispatches that should be green before a `v*` tag — not a merge gate.
on:
workflow_dispatch:
inputs:
image_tag:
description: >-
CI-Server image tag to test (e.g. 2026.8.26). Leave empty to resolve
the newest fully-published release the same way the shipped app does.
required: false
type: string
default: ''
permissions:
contents: read
# The root `yarn install` below resolves @companionintelligence/tokens from
# GitHub Packages, which is private. Without this scope GITHUB_TOKEN reads as
# an anonymous user and the fetch step dies with YN0041 Invalid authentication.
packages: read
concurrency:
group: desktop-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
desktop-e2e:
name: Desktop appliance E2E
runs-on: ubuntu-latest
# Generous because the first `up` pulls the whole appliance from GHCR
# (api, database, gateway, object-store, and the two Python sidecars) before
# a single spec runs, and the suite's own per-test timeout is 120s.
timeout-minutes: 60
env:
# The first two are fixed by stack_manager.rs (`PROJECT_NAME`) and by
# `GATEWAY_PORT`, the one port the production compose stack publishes.
# Kept as job-level env so the compose steps, the health check and the
# teardown cannot drift apart from each other.
COMPOSE_PROJECT_NAME: ci-server-desktop
GATEWAY_URL: http://127.0.0.1:8642
# COMPOSE_DIR is read by exactly ONE step — the `.env` writer below, which
# is the only place that needs this path spelled out from
# $GITHUB_WORKSPACE instead of as a step `working-directory:`. Every
# compose step names the same directory literally in its
# `working-directory:`, so if the resources directory ever moves, this
# value and those four literals have to move together; a mismatch would
# write the .env somewhere compose never looks and fail at `--env-file`.
COMPOSE_DIR: frontend/apps/desktop/src-tauri/resources
steps:
# The full appliance image set — api, worker, migrate-database, database,
# object-store, summary-service and hbpe (which carries CUDA wheels) —
# does not fit beside the ~23 GB of preinstalled SDKs a hosted
# ubuntu-latest runner ships. The 2026-09-16 run died mid-`Extracting`
# with `No space left on device` after pulling six of them.
#
# Nothing this job does needs any of these: it installs Node, pulls
# published images and runs Playwright. `$AGENT_TOOLSDIRECTORY` goes too
# — setup-node below simply downloads Node instead of reading it from the
# tool cache, which costs seconds. Done before checkout so a failure
# costs nothing, and `|| true` throughout because a future runner image
# that drops one of these paths must not turn this lane red for it.
- name: Reclaim runner disk space
run: |
echo "Before:"; df -h / | tail -1
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost "$AGENT_TOOLSDIRECTORY" \
/usr/share/swift /usr/local/.ghcup || true
sudo docker image prune --all --force || true
echo "After:"; df -h / | tail -1
# Full history + tags: the tag resolution below walks ancestor release
# tags (`git tag --merged HEAD`), and a shallow clone has none — which
# that script treats as a hard failure under CI rather than baking in an
# unpullable dev-<sha>.
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
# Only needed for @playwright/test itself — nothing in tests/desktop
# imports application code. The stack under test is pulled, not built.
#
# NODE_AUTH_TOKEN is not optional: .yarnrc.yml points the
# `companionintelligence` scope at npm.pkg.github.com, so the canon
# tokens package cannot be fetched anonymously.
- name: Install dependencies
run: yarn install --immutable
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Mirrors `yarn build`'s own pre-step in frontend/apps/desktop: it
# resolves the image tag, writing it to resources/image-tag.txt, and
# copies docker-compose.yml + docker-compose.production.yml AS OF THAT
# RELEASE into src-tauri/resources/ (next to the checked-in
# docker-compose.desktop.yml), so an untagged main runs the compose its
# images were released with, not HEAD's. Using it rather than
# hand-rolling a `git describe` here buys the exact algorithm the shipped
# app was built with, including the GHCR completeness check — every image
# the desktop overlay names must actually exist at the tag, because a
# release can partially publish. `CI` is set on every runner, so any
# degraded outcome is a hard failure instead of a warning; an
# unresolvable or unpublished tag stops the job here rather than 40
# minutes later as a skipped suite.
#
# An explicit `image_tag` input is passed straight through as
# CI_SERVER_IMAGE_TAG, which that script treats as a pin that always wins
# (and still verifies). Empty means "resolve it", since '' is falsy there.
- name: Resolve the CI-Server image tag
id: image
working-directory: frontend/apps/desktop
env:
CI_SERVER_IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
node scripts/sync-compose-resources.cjs
tag="$(tr -d '[:space:]' < src-tauri/resources/image-tag.txt)"
if [ -z "$tag" ]; then
echo "::error::sync-compose-resources.cjs produced an empty image-tag.txt"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Testing ghcr.io/companionintelligence/companion/*:$tag"
# The same file stack_manager.rs::write_env_file generates into the app's
# data directory, with the same keys and the same values, so compose
# interpolates identically here and there. That function is the SOURCE OF
# TRUTH and this is a hand-typed copy of it — see the boxed block in this
# file's header for the full list of ways the two already differ and for
# what would end the duplication. Two additions CI needs and a
# desktop does not:
# - CI_REPO_DIR, because docker-compose.production.yml defaults the api's
# read-only archive mount to `~/Downloads`, which does not exist on a
# runner and is not a path compose expands.
# - absolute paths for both bind mounts, for the same reason
# write_env_file uses one for CI_IMPORT_WORK_DIR: relative sources
# would resolve against the compose project directory, not the
# workspace.
# One key write_env_file writes and this does NOT: LLM_DEFAULT_CHAT_MODEL,
# which it pins to the tag the Tauri shell's own Ollama sidecar pulls.
# There is no sidecar here and nothing serving models, so naming a model
# would only make the API ask for one that cannot exist. tests/desktop
# covers first-run and navigation, not chat, so nothing in the suite
# depends on it — add it here the day a spec does.
#
# No secrets are written — the `generate-secrets` service in the
# production profile mints every DB/auth credential into a Docker volume
# on first boot, exactly as it does for a real install.
- name: Write the desktop compose .env
run: |
set -euo pipefail
import_work="$GITHUB_WORKSPACE/desktop-e2e-import-work"
repo_dir="$GITHUB_WORKSPACE/desktop-e2e-repo"
mkdir -p "$import_work" "$repo_dir"
cat > "$GITHUB_WORKSPACE/$COMPOSE_DIR/.env" <<EOF
COMPOSE_PROJECT_NAME=$COMPOSE_PROJECT_NAME
CI_SERVER_IMAGE_TAG=${{ steps.image.outputs.tag }}
APP_PUBLIC_HOSTNAME=127.0.0.1
CI_IMPORT_WORK_DIR=$import_work
CI_REPO_DIR=$repo_dir
CI_LOCAL_ONLY=true
OIDC_ISSUER_URL=https://hub.ci.computer
APP_BASE_URL=$GATEWAY_URL
EOF
# Byte-for-byte the invocation stack_manager.rs::compose_command +
# UP_ARGS build: `--env-file .env`, `--project-name ci-server-desktop`,
# the three -f files in that order (base, production, desktop overlay —
# the overlay MUST be last, it is what rewrites every `ci/<name>` image to
# the published `companion/<name>` one), `--profile production`, and
# `up -d --remove-orphans --scale powersync=0`.
#
# `--scale powersync=0` is not cosmetic: powersync is in the production
# profile but its PS_STORAGE_URI/PS_JWT_SECRET_B64URL come from env vars
# this secrets model never populates, so left running it crash-loops
# (exit 150) forever. See the long comment at that call site in
# stack_manager.rs for why `--scale` and not a profiles override.
#
# Deliberately NO `docker login`: a downloaded app holds no registry
# credentials, so these images have to be anonymously pullable. If a pull
# 401s here, that is a real finding about the release, not a CI setup gap
# — authenticating would hide exactly the failure this suite exists to
# catch.
- name: Start the desktop stack
working-directory: frontend/apps/desktop/src-tauri/resources
run: |
set -euo pipefail
docker compose --env-file .env --project-name "$COMPOSE_PROJECT_NAME" \
-f docker-compose.yml \
-f docker-compose.production.yml \
-f docker-compose.desktop.yml \
--profile production \
up -d --remove-orphans --scale powersync=0
# THIS STEP IS THE POINT. playwright.desktop.config.ts has no `webServer`
# and tests/desktop/fixtures/appliance.ts skips every spec (an `auto`
# fixture calling testInfo.skip) when nothing answers at the gateway — a
# deliberate choice for a developer who simply has not started the app,
# and a trap in CI, where a stack that never came up produces a run with
# zero executed tests and a green check. So the job asserts the appliance
# is answering BEFORE Playwright is allowed to decide anything, and fails
# here on timeout.
#
# Polls the same URL stack_manager.rs::is_healthy polls — gateway's
# nginx.conf proxies /api/ to the api container's own /health — rather
# than a bare TCP connect, so "the port is bound" cannot be mistaken for
# "the API is serving". 20 minutes because `up -d` returns the moment the
# containers are created, and on a cold runner everything after that is
# image pulls, `generate-secrets`, and `migrate-database` running the
# whole migration history against an empty database.
- name: Wait for the appliance to answer
working-directory: frontend/apps/desktop/src-tauri/resources
run: |
set -euo pipefail
for i in $(seq 1 240); do
if curl -fsS "$GATEWAY_URL/api/health" >/dev/null 2>&1; then
echo "appliance is healthy after $((i * 5))s"
exit 0
fi
sleep 5
done
echo "::error::The desktop stack never answered at $GATEWAY_URL/api/health — failing here on purpose, because tests/desktop would have SKIPPED every spec and reported green."
# All three -f files again: `api`, `worker` and `gateway` exist only in
# docker-compose.production.yml, so a compose invocation that loaded
# the base file alone would print logs for everything except the
# services most likely to be the reason this timed out.
docker compose --env-file .env --project-name "$COMPOSE_PROJECT_NAME" \
-f docker-compose.yml \
-f docker-compose.production.yml \
-f docker-compose.desktop.yml \
--profile production \
ps --all || true
docker compose --env-file .env --project-name "$COMPOSE_PROJECT_NAME" \
-f docker-compose.yml \
-f docker-compose.production.yml \
-f docker-compose.desktop.yml \
--profile production \
logs --no-color --tail 200 || true
exit 1
- name: Install Playwright browser
run: yarn playwright install --with-deps chromium
# `--reporter=list,html` on the command line, not in the config: that file
# ships `reporter: [['list']]` for the local case it was written for, and
# a CI run needs an artifact someone can open. PLAYWRIGHT_HTML_OPEN=never
# stops the html reporter trying to serve the report on a runner with
# nobody to look at it.
- name: Run desktop E2E
env:
PLAYWRIGHT_HTML_OPEN: never
DESKTOP_E2E_BASE_URL: ${{ env.GATEWAY_URL }}
run: yarn e2e:desktop --reporter=list,html
# Both paths: playwright-report/ is the html reporter's output, and
# test-results-desktop/ is this config's own `outputDir`, holding the
# screenshots, videos and traces it keeps on failure.
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-desktop
path: |
playwright-report/
test-results-desktop/
retention-days: 7
if-no-files-found: ignore
# Container logs are usually the only place a first-run failure explains
# itself — a migration that did not apply, a sidecar that never came up —
# and they are gone the moment the runner is recycled.
- name: Upload stack logs
if: always()
working-directory: frontend/apps/desktop/src-tauri/resources
run: |
docker compose --env-file .env --project-name "$COMPOSE_PROJECT_NAME" \
-f docker-compose.yml \
-f docker-compose.production.yml \
-f docker-compose.desktop.yml \
--profile production \
logs --no-color --tail 2000 \
> "$GITHUB_WORKSPACE/desktop-stack.log" 2>&1 || true
- name: Upload stack log artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-stack-logs
path: desktop-stack.log
retention-days: 7
if-no-files-found: ignore
# `-v` because the volumes hold the appliance's database and its generated
# secrets: a re-run on a self-hosted runner must start from an EMPTY
# database or 01-ftue.spec.ts's first-account path skips itself (an
# appliance only ever permits one account — see tests/desktop/fixtures/
# appliance.ts), which is the same silent-green failure the health check
# above exists to prevent.
- name: Tear down the desktop stack
if: always()
working-directory: frontend/apps/desktop/src-tauri/resources
run: |
docker compose --env-file .env --project-name "$COMPOSE_PROJECT_NAME" \
-f docker-compose.yml \
-f docker-compose.production.yml \
-f docker-compose.desktop.yml \
--profile production \
down -v --remove-orphans || true