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.
Unified · Split
| @@ -163,10 +163,121 @@ jobs: · 162 unmodified lines | |||
| 163 | if-no-files-found: error | 163 | if-no-files-found: error |
| 164 | retention-days: 1 | 164 | 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 | - metadata | 278 | - metadata |
| 169 | - build | 279 | - build |
| 280 | - e2e | ||
| 170 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 281 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 171 | permissions: | 282 | permissions: |
| 172 | packages: write | 283 | packages: write |
| @@ -6,12 +6,10 @@ · 5 unmodified lines | |||
| 6 | // lives in setup/selfhost-docker.globalsetup.ts. | 6 | // lives in setup/selfhost-docker.globalsetup.ts. |
| 7 | import { Effect } from "effect"; | 7 | import { Effect } from "effect"; |
| 8 | 8 | ||
| 9 | import { cookieConsentStrategy } from "@executor-js/mcporter"; | ||
| 10 | |||
| 11 | import { e2ePort } from "../src/ports"; | 9 | import { e2ePort } from "../src/ports"; |
| 12 | import type { Identity, Target } from "../src/target"; | 10 | import type { Identity, Target } from "../src/target"; |
| 13 | import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot"; | 11 | import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot"; |
| 14 | import { SELFHOST_ADMIN, signInSession } from "./selfhost"; | 12 | import { forcedMcpConsent, SELFHOST_ADMIN, signInSession } from "./selfhost"; |
| 15 | 13 | ||
| 16 | export const SELFHOST_DOCKER_PORT = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5); | 14 | export const SELFHOST_DOCKER_PORT = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5); |
| 17 | export const SELFHOST_DOCKER_BASE_URL = | 15 | export 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 stop | 45 | // A real deployment cycle, not a warm `docker restart`: graceful stop |
| 45 | // (SIGTERM + docker's grace), remove the container, start a NEW one from | 46 | // (SIGTERM + docker's grace), remove the container, start a NEW one from |
| @@ -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. |
| 53 | const forcedMcpConsent = | 53 | export 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; |