History

Gate self-host docker publish on the e2e suite

The publish workflow pushed user-visible tags after only building the image, so a startup crash (v1.5.30's missing worker-bundler) or any feature-path break could ship. Add an e2e job between build and merge that pulls each arch's by-digest image and runs the full selfhost-docker vitest project against it on a matching runner; merge (the step that moves latest/version tags) now requires it. Also switch the selfhost-docker target to the same forcedMcpConsent completion the selfhost dev target uses: the app forces prompt=consent on every MCP authorize, so authorize redirects to the relative /mcp-consent screen and mcporter's cookieConsentStrategy throws on the relative URL. The oauth-client-handoff scenarios failed against the production image because of this.

Rhys Sullivan committed 4 months ago

Commit 42829b71 parent 724600c3 files · +119 −7Browse files

Unified · Split

3 files
  1. .github/workflows/publish-selfhost-docker.yml +111 −0
  2. e2e/targets/selfhost-docker.ts +7 −6
  3. e2e/targets/selfhost.ts +1 −1
.github/workflows/publish-selfhost-docker.yml+111−0
@@ -163,10 +163,121 @@ jobs: · 162 unmodified lines
163 if-no-files-found: error163 if-no-files-found: error
164 retention-days: 1164 retention-days: 1
165 165
166 # Release gate: run the full selfhost-docker e2e project against the exact
167 # by-digest image each build leg pushed, before any user-visible tag moves.
168 # Build-only smoke misses startup crashes and feature-path breaks (a package
169 # resolved at runtime but missing from the packaged runtime shipped broken in
170 # v1.5.30 exactly this way); per-arch matters too — the libsql native-addon
171 # bug was arm64-only.
172 e2e:
173 needs:
174 - metadata
175 - build
176 strategy:
177 fail-fast: false
178 matrix:
179 include:
180 - arch: amd64
181 runner: blacksmith-4vcpu-ubuntu-2404
182 - arch: arm64
183 runner: blacksmith-4vcpu-ubuntu-2404-arm
184 runs-on: ${{ matrix.runner }}
185 timeout-minutes: 40
186 permissions:
187 contents: read
188 packages: read
189
190 steps:
191 - name: Checkout release tag
192 uses: actions/checkout@v4
193 with:
194 ref: ${{ needs.metadata.outputs.release_tag }}
195 persist-credentials: false
196
197 - uses: oven-sh/setup-bun@v2
198 with:
199 bun-version: 1.3.11
200
201 - name: Cache Bun package cache
202 uses: actions/cache@v4
203 with:
204 path: ~/.bun/install/cache
205 key: ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
206 restore-keys: |
207 ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-
208
209 # Scenario helpers spawn Node sidecars; pin the same Node 24 runtime the
210 # other workflows use.
211 - uses: actions/setup-node@v4
212 with:
213 node-version: 24
214
215 - run: bun install --frozen-lockfile
216
217 - name: Cache Playwright browsers
218 uses: actions/cache@v4
219 with:
220 path: ~/.cache/ms-playwright
221 key: ${{ runner.os }}-${{ runner.arch }}-playwright-1.60.0
222 restore-keys: |
223 ${{ runner.os }}-${{ runner.arch }}-playwright-
224
225 # Install from e2e so bunx resolves ITS pinned playwright (the version
226 # the tests run against) rather than floating to the latest.
227 - name: Install Playwright Chromium
228 run: bunx playwright install --with-deps chromium chromium-headless-shell
229 working-directory: e2e
230
231 - name: Log in to GHCR
232 uses: docker/login-action@v3
233 with:
234 registry: ghcr.io
235 username: ${{ github.actor }}
236 password: ${{ secrets.GITHUB_TOKEN }}
237
238 - name: Download image digest
239 uses: actions/download-artifact@v4
240 with:
241 name: digests-${{ matrix.arch }}
242 path: ${{ runner.temp }}/digests
243
244 - name: Resolve image reference
245 id: image_ref
246 shell: bash
247 env:
248 IMAGE: ${{ needs.metadata.outputs.image }}
249 run: |
250 set -euo pipefail
251 digest_file="$(find "$RUNNER_TEMP/digests" -maxdepth 1 -type f | head -n 1)"
252 if [ -z "$digest_file" ]; then
253 echo "No image digest was downloaded."
254 exit 1
255 fi
256 ref="${IMAGE}@sha256:$(basename "$digest_file")"
257 echo "ref=$ref" >> "$GITHUB_OUTPUT"
258 docker pull "$ref"
259
260 - name: Run selfhost-docker scenarios against the published digest
261 env:
262 E2E_SELFHOST_DOCKER_IMAGE: ${{ steps.image_ref.outputs.ref }}
263 run: bunx vitest run --project selfhost-docker --retry=2
264 working-directory: e2e
265
266 - name: Upload run artifacts
267 if: failure()
268 uses: actions/upload-artifact@v4
269 with:
270 name: e2e-runs-selfhost-docker-${{ matrix.arch }}
271 path: |
272 e2e/runs/
273 e2e/selfhost-docker.boot.log
274 retention-days: 7
275
166 merge:276 merge:
167 needs:277 needs:
168 - metadata278 - metadata
169 - build279 - build
280 - e2e
170 runs-on: blacksmith-4vcpu-ubuntu-2404281 runs-on: blacksmith-4vcpu-ubuntu-2404
171 permissions:282 permissions:
172 packages: write283 packages: write
e2e/targets/selfhost-docker.ts+7−6
@@ -6,12 +6,10 @@ · 5 unmodified lines
6// lives in setup/selfhost-docker.globalsetup.ts.6// lives in setup/selfhost-docker.globalsetup.ts.
7import { Effect } from "effect";7import { Effect } from "effect";
8 8
9import { cookieConsentStrategy } from "@executor-js/mcporter";
10
11import { e2ePort } from "../src/ports";9import { e2ePort } from "../src/ports";
12import type { Identity, Target } from "../src/target";10import type { Identity, Target } from "../src/target";
13import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot";11import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot";
14import { SELFHOST_ADMIN, signInSession } from "./selfhost";12import { forcedMcpConsent, SELFHOST_ADMIN, signInSession } from "./selfhost";
15 13
16export const SELFHOST_DOCKER_PORT = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5);14export const SELFHOST_DOCKER_PORT = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5);
17export const SELFHOST_DOCKER_BASE_URL =15export const SELFHOST_DOCKER_BASE_URL =
@@ -35,11 +33,14 @@ export const selfhostDockerTarget = (): Target => ({ · 17 unmodified lines
35 cookies,33 cookies,
36 };34 };
37 }),35 }),
36 // Same forced-consent completion as the selfhost dev target: the app forces
37 // `prompt=consent` on every MCP authorize, so authorize redirects to the
38 // relative `/mcp-consent?consent_code=...` screen instead of straight to the
39 // callback — mcporter's cookieConsentStrategy can't parse that redirect.
38 mcpConsent: (identity: Identity) =>40 mcpConsent: (identity: Identity) =>
39 cookieConsentStrategy({41 forcedMcpConsent(SELFHOST_DOCKER_BASE_URL, {
40 appBaseUrl: SELFHOST_DOCKER_BASE_URL,
41 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,42 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,
42 password: identity.credentials?.password ?? SELFHOST_ADMIN.password,43 password: identity.credentials?.password || SELFHOST_ADMIN.password,
43 }),44 }),
44 // A real deployment cycle, not a warm `docker restart`: graceful stop45 // A real deployment cycle, not a warm `docker restart`: graceful stop
45 // (SIGTERM + docker's grace), remove the container, start a NEW one from46 // (SIGTERM + docker's grace), remove the container, start a NEW one from
e2e/targets/selfhost.ts+1−1
@@ -50,7 +50,7 @@ export const signInSession = async ( · 49 unmodified lines
50// mcporter's `cookieConsentStrategy` only handles the old direct-code redirect,50// mcporter's `cookieConsentStrategy` only handles the old direct-code redirect,
51// so this completes the screen the way the page does: sign in, drive authorize,51// so this completes the screen the way the page does: sign in, drive authorize,
52// then POST the same `/api/auth/oauth2/consent` grant the Allow button fires.52// then POST the same `/api/auth/oauth2/consent` grant the Allow button fires.
53const forcedMcpConsent =53export const forcedMcpConsent =
54 (baseUrl: string, credentials: { readonly email: string; readonly password: string }) =>54 (baseUrl: string, credentials: { readonly email: string; readonly password: string }) =>
55 async ({ authorizationUrl }: { authorizationUrl: string }): Promise<{ code: string }> => {55 async ({ authorizationUrl }: { authorizationUrl: string }): Promise<{ code: string }> => {
56 const origin = new URL(baseUrl).origin;56 const origin = new URL(baseUrl).origin;