From fd105a6dc2cd51b5e5fd64c2efad57e6801d2325 Mon Sep 17 00:00:00 2001 From: Matsu Date: Fri, 4 Sep 2026 09:01:33 +0300 Subject: [PATCH] feat(core): Add PR preview instances on Codespaces (no-changelog) (#37695) Co-authored-by: Claude Opus 5 (1M context) --- .devcontainer/preview/Dockerfile | 32 ++ .devcontainer/preview/devcontainer.json | 37 +++ .devcontainer/preview/preview-signin-hook.cjs | 81 +++++ .github/scripts/package.json | 2 +- .github/workflows/ci-pull-requests.yml | 7 + package.json | 2 + scripts/dev-up.mjs | 66 +--- scripts/preview-serve.mjs | 137 ++++++++ scripts/preview.mjs | 305 ++++++++++++++++++ scripts/serve-ready.mjs | 65 ++++ scripts/serve-ready.test.mjs | 143 ++++++++ 11 files changed, 823 insertions(+), 54 deletions(-) create mode 100644 .devcontainer/preview/Dockerfile create mode 100644 .devcontainer/preview/devcontainer.json create mode 100644 .devcontainer/preview/preview-signin-hook.cjs create mode 100644 scripts/preview-serve.mjs create mode 100644 scripts/preview.mjs create mode 100644 scripts/serve-ready.mjs create mode 100644 scripts/serve-ready.test.mjs diff --git a/.devcontainer/preview/Dockerfile b/.devcontainer/preview/Dockerfile new file mode 100644 index 00000000000..ead395ec8d1 --- /dev/null +++ b/.devcontainer/preview/Dockerfile @@ -0,0 +1,32 @@ +ARG NODE_VERSION=24 + +# Debian to match the agent image's toolchain. No Playwright, no agent CLIs and no +# Docker here: a preview only builds the repo and serves it. +FROM node:${NODE_VERSION}-bookworm + +# tmux holds the backend between `preview refresh` runs. The rest is for triage. +RUN apt-get update && apt-get install -y --no-install-recommends \ + tmux sudo less jq \ + && rm -rf /var/lib/apt/lists/* + +# Shared with the agent config, so there is one copy of each shim. The build context +# is .devcontainer/, set in devcontainer.json, which is why these paths start with +# codespaces/. git and gh must survive Codespaces' token rotation; the stock helpers +# read a snapshot of $GITHUB_TOKEN and go stale in ssh and tmux shells. +COPY codespaces/codespaces-env.sh /usr/local/lib/codespaces-env.sh +COPY codespaces/gitcredential-refresh.sh /usr/local/bin/gitcredential-refresh.sh +COPY codespaces/gh-shim.sh /usr/local/bin/gh +RUN chmod +x /usr/local/bin/gitcredential-refresh.sh /usr/local/bin/gh + +# Interactive shells for a human debugging the box. +RUN echo '. /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc + +RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node +RUN mkdir -p /workspaces && chown node:node /workspaces +RUN corepack enable + +ENV PNPM_HOME=/home/node/.local/share/pnpm +ENV PATH=$PNPM_HOME/bin:$PATH + +USER node +RUN mkdir -p $PNPM_HOME ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global diff --git a/.devcontainer/preview/devcontainer.json b/.devcontainer/preview/devcontainer.json new file mode 100644 index 00000000000..9c5bbc6f780 --- /dev/null +++ b/.devcontainer/preview/devcontainer.json @@ -0,0 +1,37 @@ +{ + "name": "n8n (PR preview)", + // No compose: a preview needs no Postgres sidecar. n8n defaults to sqlite + // (packages/@n8n/config/src/configs/database.config.ts), which is what a + // throwaway instance wants. Context is .devcontainer/ so the Dockerfile can + // reuse the token-rotation shims in codespaces/ instead of copying them. + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + // Codespaces clones the repo into /workspaces/ + "workspaceFolder": "/workspaces/n8n", + // The floor, so the 2-core machine stays available. This config exists mainly to + // ask for less than the agent one: the measured footprint is ~15gb, the + // editor-ui build peaks near 3gb, and scripts/agent-setup.mjs already builds the + // whole repo inside 6gb. + "hostRequirements": { + "cpus": 2, + "memory": "8gb", + "storage": "32gb" + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + // gh codespace ssh needs an SSH server inside the container + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "forwardPorts": [5678], + // Runs in prebuilds, so a fresh preview starts with deps installed and the build + // warm. Concurrency and heap are capped for the 2-core box, matching the + // reasoning in scripts/agent-setup.mjs. A failing onCreateCommand is fatal — + // Codespaces discards the container — so the build only ever tails its log. + "onCreateCommand": "corepack install && pnpm install && (NODE_OPTIONS=--max-old-space-size=6144 pnpm exec turbo run build --concurrency=2 > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log)", + // Codespaces regenerates the managed /etc/gitconfig on each start, and the ssh + // command shells `pnpm preview` uses are neither login nor interactive, so + // profile.d and bashrc never fire for them. Register the helper here instead. + "postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh" +} diff --git a/.devcontainer/preview/preview-signin-hook.cjs b/.devcontainer/preview/preview-signin-hook.cjs new file mode 100644 index 00000000000..de3a7cfce69 --- /dev/null +++ b/.devcontainer/preview/preview-signin-hook.cjs @@ -0,0 +1,81 @@ +/** + * One-click sign-in for preview instances. Loaded through EXTERNAL_HOOK_FILES by + * scripts/preview-serve.mjs, so it is never part of a packaged n8n. + * + * The route performs an ordinary login on the visitor's behalf: it calls + * /rest/login with the preview instance's own seeded credentials and forwards the + * session cookie that endpoint returns. The visitor gets exactly the session a + * normal sign-in would give them, so this is a convenience over the login form + * rather than a different way in. + * + * Only enable this on a throwaway instance whose port is org-visible. It refuses to + * register unless N8N_PREVIEW_SIGNIN is exactly "1", so loading the file by + * accident does nothing. + * + * Calling /rest/login rather than AuthService keeps this working across changes to + * n8n's internals, and means it can never grant more than a real login would. + * + * Two things make the route reachable: + * - `n8n.ready` fires after configure(), so the SPA catch-all is already + * registered. N8N_ADDITIONAL_NON_UI_ROUTES must list this path, or + * historyApiHandler answers with index.html before the route is reached. + * - the handler is registered on server.app, which AbstractServer exposes. + */ +const ROUTE = '/preview-signin'; + +module.exports = { + n8n: { + ready: [ + async function previewSignin(server) { + if (process.env.N8N_PREVIEW_SIGNIN !== '1') return; + + const email = process.env.PREVIEW_OWNER_EMAIL; + const password = process.env.PREVIEW_OWNER_PASSWORD; + if (!email || !password) { + console.warn( + `[preview-signin] PREVIEW_OWNER_EMAIL or PREVIEW_OWNER_PASSWORD is unset — ${ROUTE} not registered`, + ); + return; + } + + const port = process.env.N8N_PORT ?? '5678'; + + server.app.get(ROUTE, async (_req, res) => { + const fallback = `Sign in as ${email} instead.`; + try { + const login = await fetch(`http://127.0.0.1:${port}/rest/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ emailOrLdapLoginId: email, password }), + signal: AbortSignal.timeout(15_000), + }); + + if (!login.ok) { + res + .status(502) + .type('text') + .send(`Preview sign-in failed: /rest/login returned ${login.status}. ${fallback}`); + return; + } + + // Hand the browser exactly the cookies the login endpoint issued. + const cookies = login.headers.getSetCookie(); + if (cookies.length === 0) { + res + .status(502) + .type('text') + .send(`Preview sign-in failed: no session cookie was issued. ${fallback}`); + return; + } + res.setHeader('set-cookie', cookies); + res.redirect('/'); + } catch (error) { + res.status(502).type('text').send(`Preview sign-in failed: ${error.message}. ${fallback}`); + } + }); + + console.log(`[preview-signin] ${ROUTE} is live — it signs the visitor in as ${email}`); + }, + ], + }, +}; diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 459534df86a..59a5026e525 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -6,7 +6,7 @@ }, "packageManager": "pnpm@11.22.0", "scripts": { - "test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs", + "test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs", "generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance --spec-version 1.6 -o ../../sbom-source.cdx.json ../../compiled/", "enrich-sbom": "node ../../scripts/licenses/enrich-sbom.mjs ../../sbom-source.cdx.json", "render-licenses-md": "node ../../scripts/licenses/render-licenses-md.mjs ../../sbom-source.cdx.json ../../packages/cli/THIRD_PARTY_LICENSES.md ../../compiled/node_modules", diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index c90710f2722..f4bb6bef39b 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -74,6 +74,8 @@ jobs: !scripts/agent-setup.mjs !scripts/backend-module/** !scripts/cloud-session.mjs + !scripts/preview.mjs + !scripts/preview-serve.mjs !scripts/licenses/** !scripts/mutation-health/** !scripts/sync-agent-skill-links.mjs @@ -89,6 +91,8 @@ jobs: !scripts/agent-setup.mjs !scripts/backend-module/** !scripts/cloud-session.mjs + !scripts/preview.mjs + !scripts/preview-serve.mjs !scripts/licenses/** !scripts/mutation-health/** !scripts/sync-agent-skill-links.mjs @@ -116,6 +120,9 @@ jobs: .github/scripts/** .devcontainer/codespaces/agent-worker*.mjs OWNERS + scripts/*.test.mjs + scripts/codespace-env.mjs + scripts/serve-ready.mjs scripts/licenses/** scripts/mutation-health/** cubic.yaml diff --git a/package.json b/package.json index 1c0800de294..90271136dbb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset", "clean": "turbo run clean", "session": "node scripts/cloud-session.mjs", + "preview": "node scripts/preview.mjs", + "preview:serve": "node scripts/preview-serve.mjs", "session:opencode": "node scripts/cloud-session.mjs --opencode", "session:shell": "node scripts/cloud-session.mjs --shell", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", diff --git a/scripts/dev-up.mjs b/scripts/dev-up.mjs index 7e6f89a042e..40b4bbe1914 100644 --- a/scripts/dev-up.mjs +++ b/scripts/dev-up.mjs @@ -8,18 +8,22 @@ // dev:be serves the editor from the `dist` build. A frontend change appears only // after `--build` and this restart. For live frontend hot reload, use // `pnpm dev:fe:editor` on 8080. That path needs `pnpm session tunnel 5678 8080`. +// +// For a PR preview instead of your own session, use `pnpm preview up `. import { execFileSync, spawn } from 'node:child_process'; import { existsSync, openSync } from 'node:fs'; -import { setTimeout as sleep } from 'node:timers/promises'; -import { codespaceName, forwardingDomain } from './codespace-env.mjs'; +import { + reportUp, + serveHealthPath, + servePort, + shareWithOrg, + waitForHealth, +} from './serve-ready.mjs'; const build = process.argv.includes('--build'); -const port = process.env.N8N_PORT ?? '5678'; -// Probe the same health path the backend serves (defaults to /healthz). -const healthPath = process.env.N8N_ENDPOINT_HEALTH - ? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}` - : '/healthz'; +const port = servePort(); +const healthPath = serveHealthPath(); const LOG = '/tmp/n8n-dev-be.log'; const BUILD_LOG = '/tmp/dev-up-build.log'; @@ -44,53 +48,9 @@ console.log(`Starting backend (pnpm dev:be; log: ${LOG})…`); const fd = openSync(LOG, 'a'); spawn('pnpm', ['dev:be'], { detached: true, stdio: ['ignore', fd, fd] }).unref(); -const deadline = Date.now() + 120_000; -let healthy = false; -while (Date.now() < deadline) { - try { - const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, { - signal: AbortSignal.timeout(5000), - }); - if (res.ok) { - healthy = true; - break; - } - } catch {} - await sleep(3000); -} - -const name = codespaceName(); -const url = name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`; -if (!healthy) { +if (!(await waitForHealth(port, healthPath))) { console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${LOG}`); process.exit(1); } -// In a codespace, share the port with the org. Then any n8n member can open the -// URL. GitHub makes every port private again at each start, so set it here. If -// `gh` fails, print the reason and continue. -let orgShared = false; -let shareError; -if (name) { - try { - execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], { - stdio: ['ignore', 'ignore', 'pipe'], - }); - orgShared = true; - } catch (error) { - const stderr = error.stderr?.toString().trim(); - shareError = (stderr || error.message).split('\n').pop(); - } -} - -console.log(`\nUp: ${url}`); -if (name) - console.log( - orgShared - ? '(org-visible — any n8n member signed into GitHub can open it)' - : `(still private: ${shareError} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`, - ); -else if (existsSync('/workspaces/.codespaces')) - console.log( - `(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`, - ); +reportUp(port, shareWithOrg(port)); diff --git a/scripts/preview-serve.mjs b/scripts/preview-serve.mjs new file mode 100644 index 00000000000..22f9ea41c09 --- /dev/null +++ b/scripts/preview-serve.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// Serve the checked-out tree as a preview instance, from inside a codespace. +// Idempotent: it stops any previous preview backend first, so `preview up` and +// `preview refresh` share this one entry point. +// +// pnpm preview:serve +// +// dev:up detaches the backend, which leaves nothing for a refresh to stop. So +// this owns the lifecycle instead: dev:be runs in a named tmux session. +import { execFileSync, spawnSync } from 'node:child_process'; +import { openSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { serveHealthPath, servePort, waitForHealth } from './serve-ready.mjs'; + +const SESSION = 'n8n-preview'; +const BUILD_LOG = '/tmp/preview-build.log'; +const BE_LOG = '/tmp/preview-be.log'; +// The preview box has 2 cores and 8gb. Cap both, for the reason spelled out in +// scripts/agent-setup.mjs: unbounded turbo concurrency is what exhausts a small box. +const CONCURRENCY = process.env.PREVIEW_BUILD_CONCURRENCY ?? '2'; +// A fresh instance opens on the owner-setup wall, which is friction for a reviewer +// who only wants to look at the UI. Running with no auth is not an option: the +// `skipInstanceOwnerSetup` setting was removed in migration 1681134145997. So seed a +// known owner instead. These are not secrets — the security boundary is the +// org-visible forwarded port, which already requires a GitHub sign-in and n8n org +// membership. Never point this at anything publicly reachable. +// The password must satisfy passwordSchema: 8-64 chars, >=1 digit, >=1 uppercase. +const OWNER_EMAIL = process.env.PREVIEW_OWNER_EMAIL ?? 'preview@n8n.io'; +const OWNER_PASSWORD = process.env.PREVIEW_OWNER_PASSWORD ?? 'PreviewInstance1'; +const HEAP_MB = process.env.PREVIEW_BUILD_HEAP_MB ?? '6144'; +// Resolve from this file, not cwd, so the tmux command lands in the right tree. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const port = servePort(); +const healthPath = serveHealthPath(); + +const tmux = (...args) => spawnSync('tmux', args, { stdio: 'ignore' }); + +// Stop the old backend before rebuilding: it frees the port, and it stops the +// previous build being served alongside newly written assets. +tmux('kill-session', '-t', SESSION); + +console.log(`Building (turbo cache — fast when warm; log: ${BUILD_LOG})…`); +const buildFd = openSync(BUILD_LOG, 'w'); +try { + execFileSync('pnpm', ['exec', 'turbo', 'run', 'build', `--concurrency=${CONCURRENCY}`], { + cwd: repoRoot, + stdio: ['ignore', buildFd, buildFd], + // Appended last so it wins over an inherited value: node takes the final flag. + env: { + ...process.env, + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--max-old-space-size=${HEAP_MB}`] + .filter(Boolean) + .join(' '), + }, + }); +} catch { + console.error(`Build failed — see ${BUILD_LOG}:`); + execFileSync('tail', ['-n', '30', BUILD_LOG], { stdio: 'inherit' }); + process.exit(1); +} + +// One-click sign-in, so a reviewer lands in the editor rather than a login form. +// N8N_ADDITIONAL_NON_UI_ROUTES is required: `n8n.ready` runs after the SPA catch-all +// is registered, so without it historyApiHandler answers /preview-signin with +// index.html. dev:be runs turbo with --env-mode=loose, which passes these through. +// +// Set with tmux `-e` rather than interpolated into the command: tmux does not +// inherit the caller's environment for arbitrary variables (the same trap the +// codespaces README documents), and this keeps the values out of a shell string. +const SIGNIN_ROUTE = 'preview-signin'; +const signinEnv = [ + `EXTERNAL_HOOK_FILES=${repoRoot}/.devcontainer/preview/preview-signin-hook.cjs`, + `N8N_ADDITIONAL_NON_UI_ROUTES=${SIGNIN_ROUTE}`, + 'N8N_PREVIEW_SIGNIN=1', + `PREVIEW_OWNER_EMAIL=${OWNER_EMAIL}`, + `PREVIEW_OWNER_PASSWORD=${OWNER_PASSWORD}`, +].flatMap((pair) => ['-e', pair]); + +console.log(`Starting backend in tmux session '${SESSION}' (log: ${BE_LOG})…`); +const started = tmux( + 'new', + '-d', + '-s', + SESSION, + ...signinEnv, + `cd ${repoRoot} && exec pnpm dev:be > ${BE_LOG} 2>&1`, +); +if (started.status !== 0) { + console.error(`Could not start tmux session '${SESSION}'.`); + process.exit(1); +} + +if (!(await waitForHealth(port, healthPath))) { + console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${BE_LOG}:`); + execFileSync('tail', ['-n', '30', BE_LOG], { stdio: 'inherit' }); + process.exit(1); +} + +// Promote the shell user into the owner so the instance opens on a login page with +// known credentials, not the setup wizard. Idempotent: on a refresh the sqlite file +// survives, the owner already exists, and the endpoint rejects the second attempt. +async function seedOwner() { + let res; + try { + res = await fetch(`http://127.0.0.1:${port}/rest/owner/setup`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + email: OWNER_EMAIL, + firstName: 'Preview', + lastName: 'User', + password: OWNER_PASSWORD, + }), + signal: AbortSignal.timeout(15_000), + }); + } catch (error) { + console.error(`Could not reach the owner setup endpoint: ${error.message}`); + return; + } + + if (res.ok) { + console.log(`Seeded the instance owner: ${OWNER_EMAIL} / ${OWNER_PASSWORD}`); + return; + } + // Already set up is the normal refresh case, not a failure. + console.log( + `Owner already set up (HTTP ${res.status}) — sign in as ${OWNER_EMAIL} / ${OWNER_PASSWORD}`, + ); +} + +await seedOwner(); +console.log(`One-click sign-in path: /${SIGNIN_ROUTE}`); + +// The host shares the port and prints the URL: its gh holds the codespace scope. +console.log(`Ready: the backend answers ${healthPath} on port ${port}.`); diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 00000000000..d1d547034c5 --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +// Preview instances for pull requests: one codespace per PR, created from the +// master prebuild, serving the PR head behind the org's GitHub auth. +// Requires `gh` with the codespace scope (gh auth refresh -s codespace). +// +// pnpm preview up create or start the box, then serve the PR head +// pnpm preview refresh move to the current PR head and restart +// pnpm preview down delete the box +// pnpm preview ls list preview boxes +// +// --json print {pr, sha, codespace, url} for a workflow to consume +// --dry-run resolve the PR and print the commands, touching nothing +import { execFileSync, spawnSync } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { shareWithOrg } from './serve-ready.mjs'; + +const REPO = 'n8n-io/n8n'; +// Must match a config that has a prebuild, or create takes ~20 min instead of a +// couple. Prebuilds are per-devcontainer-path, so a new config is cold until one is +// configured for it in repo settings and has run. Override to borrow another +// config's prebuild, e.g. PREVIEW_DEVCONTAINER=.devcontainer/codespaces/devcontainer.json +// with PREVIEW_MACHINE=premiumLinux, which is what that config's hostRequirements needs. +const DEVCONTAINER = process.env.PREVIEW_DEVCONTAINER ?? '.devcontainer/preview/devcontainer.json'; +// The preview devcontainer asks for 2 cpus / 8gb / 32gb, which keeps the smallest +// type available. Set PREVIEW_MACHINE=standardLinux32gb to compare against 4 cores. +const MACHINE = process.env.PREVIEW_MACHINE ?? 'basicLinux32gb'; +const IDLE_TIMEOUT = '30m'; +const RETENTION_PERIOD = '24h'; +// Only used to build the URL printed here. The in-box script reads the codespace's +// own N8N_PORT and prints the authoritative URL, so override both or neither. +const PORT = process.env.N8N_PORT ?? '5678'; +const PREFIX = 'preview/pr-'; +// GitHub hands VS Code a per-codespace forwarding domain, but it is not readable +// from here. Every box uses this one; the in-box script prints the resolved value. +const FORWARDING_DOMAIN = 'app.github.dev'; + +// The display name is the lookup key, so a preview can never be confused with a +// developer's own `pnpm session` box on the same repo. +const displayNameFor = (pr) => `${PREFIX}${pr}`; + +const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' }); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function ghJson(args, retry = false) { + try { + return JSON.parse(execFileSync('gh', args, { encoding: 'utf8' }).trim()); + } catch (error) { + if (!retry && error.message.includes('This API operation needs the "codespace" scope')) { + console.log('Requesting codespace scope…'); + ghTty('auth', 'refresh', '-h', 'github.com', '-s', 'codespace'); + return ghJson(args, true); + } + throw new Error(error.message); + } +} + +const listPreviews = () => + ghJson(['codespace', 'list', '-R', REPO, '--json', 'name,displayName,state,lastUsedAt']).filter( + (cs) => cs.displayName?.startsWith(PREFIX), + ); + +// Match on the display name. `list[0]` would take whichever box comes first, +// including one that belongs to a developer's session. +const findPreview = (pr) => listPreviews().find((cs) => cs.displayName === displayNameFor(pr)); + +function prHead(pr) { + const head = ghJson([ + 'pr', + 'view', + pr, + '-R', + REPO, + '--json', + 'headRefName,headRefOid,isCrossRepository,state', + ]); + if (head.isCrossRepository) + fail( + `PR #${pr} comes from a fork. A codespace's token is scoped to ${REPO}, so it cannot check out a fork head.`, + ); + if (head.state !== 'OPEN') console.log(`Note: PR #${pr} is ${head.state}.`); + return head; +} + +// Check out the exact head SHA, not the branch tip: the branch can move while this +// runs, and the preview has to match the SHA the PR comment points at. +const serveCommand = (head) => + [ + 'cd /workspaces/n8n', + `git fetch origin ${head.headRefName}`, + `git checkout --detach ${head.headRefOid}`, + // The serve script comes from the checked-out PR head, so a PR cut before this + // tooling landed on master does not have it. Say so, instead of letting pnpm + // report a missing script. + // An `||` here would also fire when an earlier step in this && chain failed, + // reporting the wrong cause. `if` keeps the test self-contained. + 'if [ ! -f scripts/preview-serve.mjs ]; then echo "This PR predates the preview tooling. Rebase it on master and retry."; exit 1; fi', + // Cheap when nothing changed. Skipping it is how a preview ends up running + // against stale dependencies after a lockfile change. + 'pnpm install --frozen-lockfile', + 'pnpm preview:serve', + ].join(' && '); + +const createArgs = (pr, head) => [ + 'codespace', + 'create', + '-R', + REPO, + '-b', + head.headRefName, + '-d', + displayNameFor(pr), + '--devcontainer-path', + DEVCONTAINER, + '-m', + MACHINE, + '--idle-timeout', + IDLE_TIMEOUT, + '--retention-period', + RETENTION_PERIOD, +]; + +function report(pr, head, codespace, json, orgVisible = false) { + const url = `https://${codespace}-${PORT}.${FORWARDING_DOMAIN}`; + if (json) + console.log( + JSON.stringify({ pr: Number(pr), sha: head.headRefOid, codespace, url, orgVisible }), + ); + else console.log(`\nPreview for PR #${pr}: ${url}${orgVisible ? ' (org-visible)' : ''}`); +} + +// `gh codespace create` returns when the record exists, not when the box can accept +// a connection, and sshd lags the Available state by a few seconds more. Probe with a +// no-op so a connection problem never looks like a failed build. +async function waitForSsh(name, timeoutMs = 600_000) { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + let lastError = 'no attempt completed'; + while (Date.now() < deadline) { + const probe = spawnSync('gh', ['codespace', 'ssh', '-c', name, '--', 'true'], { + stdio: ['ignore', 'ignore', 'pipe'], + encoding: 'utf8', + }); + if (probe.status === 0) return; + lastError = (probe.stderr ?? '').trim().split('\n').pop() || `exit ${probe.status}`; + if (attempt++ === 0) console.log(`Waiting for ${name} to accept ssh…`); + await sleep(5000); + } + fail( + `${name} did not accept ssh within ${Math.round(timeoutMs / 1000)}s — last error: ${lastError}`, + ); +} + +// `waitForHealth` polls 127.0.0.1 inside the box, which goes green before the +// Codespaces agent notices the listener and registers the forward. Sharing a port +// that is not forwarded yet fails, so keep trying until it is. +async function shareWhenForwarded(port, name, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + let share = shareWithOrg(port, name); + let waited = false; + while (!share.shared && Date.now() < deadline) { + if (!waited) { + console.log(`Waiting for port ${port} to be forwarded before sharing it…`); + waited = true; + } + await sleep(3000); + share = shareWithOrg(port, name); + } + return share; +} + +async function serve(pr, cs, head, { json, dryRun }) { + const command = serveCommand(head); + if (dryRun) { + console.log(`Would ssh to ${cs.name} and run:\n ${command}`); + console.log(`Would then share port ${PORT} with the org.`); + report(pr, head, cs.name, json); + return; + } + + await waitForSsh(cs.name); + + console.log(`Serving ${head.headRefOid.slice(0, 7)} on ${cs.name}…`); + const { status } = ghTty('codespace', 'ssh', '-c', cs.name, '--', command); + if (status !== 0) fail('Serving failed — see the output above.'); + + // Share from here rather than inside the box: this gh already held the codespace + // scope to create it, while the codespace's own token is only repo-scoped. + // GitHub also makes every forwarded port private again at each container start. + const share = await shareWhenForwarded(PORT, cs.name); + if (!share.shared) + console.error( + `Port ${PORT} is still private: ${share.error} — retry with \`gh codespace ports visibility ${PORT}:org -c ${cs.name}\``, + ); + report(pr, head, cs.name, json, share.shared); +} + +async function up(pr, options) { + const head = prHead(pr); + let cs = findPreview(pr); + + if (!cs) { + if (options.dryRun) { + console.log(`Would create a box for PR #${pr} (${head.headRefName}):`); + console.log(` gh ${createArgs(pr, head).join(' ')}`); + console.log(`Then ssh to it and run:\n ${serveCommand(head)}`); + // No box yet, so there is no name and no URL to report. + console.log('The URL is only known once the box exists.'); + return; + } + console.log(`Creating a preview box for PR #${pr} (${head.headRefName})…`); + // Run interactive: the devcontainer asks for access to the private skills + // repo, so gh prints an authorization URL and waits for an answer. + const { status } = ghTty(...createArgs(pr, head)); + if (status !== 0) + fail('Codespace create failed. Authorize the permissions prompt above, then retry.'); + cs = findPreview(pr); + if (!cs) fail(`Created a box for PR #${pr} but could not find it — run \`pnpm preview ls\`.`); + } else if (cs.state !== 'Available') { + // There is no `gh codespace start`; ssh starts a stopped box. + console.log(`${cs.name} is ${cs.state} — ssh will start it (~30-60 s)…`); + } + + await serve(pr, cs, head, options); +} + +async function refresh(pr, options) { + const cs = findPreview(pr); + if (!cs) fail(`No preview box for PR #${pr}. Run \`pnpm preview up ${pr}\` first.`); + await serve(pr, cs, prHead(pr), options); +} + +function down(pr, { dryRun }) { + const cs = findPreview(pr); + if (!cs) { + console.log(`No preview box for PR #${pr}.`); + return; + } + if (dryRun) { + console.log(`Would delete ${cs.name} (PR #${pr}).`); + return; + } + const { status } = ghTty('codespace', 'delete', '-c', cs.name, '--force'); + if (status !== 0) process.exit(status ?? 1); + console.log(`Deleted ${cs.name} (PR #${pr})`); +} + +function ls() { + const previews = listPreviews(); + if (!previews.length) { + console.log(`No preview boxes on ${REPO}.`); + return; + } + for (const cs of previews) + console.log(`${cs.displayName}\t${cs.state}\t${cs.name}\tlast used ${cs.lastUsedAt}`); +} + +const args = process.argv.slice(2); +const options = { json: args.includes('--json'), dryRun: args.includes('--dry-run') }; +const [cmd, pr] = args.filter((arg) => !arg.startsWith('--')); + +/** + * Grabs the given PR number from command or tries to default to + * the PR number of the branch currently checked out. + * */ +async function requirePr() { + const noPrSpecified = !/^\d+$/.test(pr ?? '') + + if (pr && noPrSpecified) { + fail(`Invalid PR number format. Usage example: \`pnpm preview ${cmd} 1234\``) + } + + if (noPrSpecified) { + const prInfoForCurrentBranch = ghJson(['pr', 'view', "--json", "number"]); + if (prInfoForCurrentBranch && Number.isInteger(prInfoForCurrentBranch.number)) { + return prInfoForCurrentBranch.number; + } + } + + if (noPrSpecified) { + fail(`Give a PR number, e.g. \`pnpm preview ${cmd} 1234\` or check out a branch that has an open PR and run \`pnpm preview ${cmd}\`.`) + } + return pr; +} + +switch (cmd) { + case 'up': + await up(await requirePr(), options); + break; + case 'refresh': + await refresh(await requirePr(), options); + break; + case 'down': + down(await requirePr(), options); + break; + case 'ls': + ls(); + break; + default: + fail('Usage: pnpm preview [pr] [--json] [--dry-run]'); +} diff --git a/scripts/serve-ready.mjs b/scripts/serve-ready.mjs new file mode 100644 index 00000000000..8a9f2287196 --- /dev/null +++ b/scripts/serve-ready.mjs @@ -0,0 +1,65 @@ +// Shared serve plumbing for `dev:up` and `preview:serve`: wait for the backend to +// answer its health endpoint, share the port with the org when we are on a +// codespace, and print the URL. +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { codespaceName, forwardingDomain } from './codespace-env.mjs'; + +const CODESPACES_DIR = '/workspaces/.codespaces'; + +export const servePort = () => process.env.N8N_PORT ?? '5678'; + +// Probe the same health path the backend serves (defaults to /healthz). +export const serveHealthPath = () => + process.env.N8N_ENDPOINT_HEALTH + ? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}` + : '/healthz'; + +export async function waitForHealth(port, healthPath, timeoutMs = 120_000, intervalMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) return true; + } catch {} + await sleep(intervalMs); + } + return false; +} + +// In a codespace, share the port with the org. Then any n8n member can open the +// URL. GitHub makes every port private again at each start, so set it here. If +// `gh` fails, report the reason and let the caller carry on. +export function shareWithOrg(port, name = codespaceName()) { + if (!name) return { shared: false }; + try { + execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + return { name, shared: true }; + } catch (error) { + const stderr = error.stderr?.toString().trim(); + return { name, shared: false, error: (stderr || error.message).split('\n').pop() }; + } +} + +export const serveUrl = (port, name) => + name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`; + +export function reportUp(port, { name, shared, error }, codespacesDir = CODESPACES_DIR) { + console.log(`\nUp: ${serveUrl(port, name)}`); + if (name) + console.log( + shared + ? '(org-visible — any n8n member signed into GitHub can open it)' + : `(still private: ${error} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`, + ); + else if (existsSync(codespacesDir)) + console.log( + `(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`, + ); +} diff --git a/scripts/serve-ready.test.mjs b/scripts/serve-ready.test.mjs new file mode 100644 index 00000000000..f311639b924 --- /dev/null +++ b/scripts/serve-ready.test.mjs @@ -0,0 +1,143 @@ +// node --test scripts/serve-ready.test.mjs +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, afterEach, describe, it } from 'node:test'; + +import { reportUp, serveHealthPath, servePort, serveUrl, waitForHealth } from './serve-ready.mjs'; + +const NAME = 'psychic-umbrella-wqj9pvw9p939vp6'; +const DOMAIN = 'app.github.dev'; + +const originalEnv = { ...process.env }; +afterEach(() => { + process.env = { ...originalEnv }; +}); +after(() => { + process.env = originalEnv; +}); + +/** Serves `status` on every request; resolves once it has a port. */ +function healthServer(status) { + const server = createServer((_req, res) => { + res.writeHead(status).end(''); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })); + }); +} + +/** Runs `fn` with console.log captured, and returns the lines it wrote. */ +function captureLog(fn) { + const lines = []; + const original = console.log; + console.log = (line) => lines.push(String(line)); + try { + fn(); + } finally { + console.log = original; + } + return lines; +} + +describe('servePort', () => { + it('defaults to 5678', () => { + delete process.env.N8N_PORT; + assert.equal(servePort(), '5678'); + }); + + it('honours N8N_PORT', () => { + process.env.N8N_PORT = '5679'; + assert.equal(servePort(), '5679'); + }); +}); + +describe('serveHealthPath', () => { + it('defaults to /healthz', () => { + delete process.env.N8N_ENDPOINT_HEALTH; + assert.equal(serveHealthPath(), '/healthz'); + }); + + // The backend config takes the path without a slash, so both spellings must work. + it('adds the leading slash when the config omits it', () => { + process.env.N8N_ENDPOINT_HEALTH = 'alive'; + assert.equal(serveHealthPath(), '/alive'); + }); + + it('does not double the leading slash', () => { + process.env.N8N_ENDPOINT_HEALTH = '/alive'; + assert.equal(serveHealthPath(), '/alive'); + }); +}); + +describe('serveUrl', () => { + it('builds the forwarded codespace URL', () => { + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = DOMAIN; + assert.equal(serveUrl('5678', NAME), `https://${NAME}-5678.${DOMAIN}`); + }); + + it('falls back to localhost off a codespace', () => { + assert.equal(serveUrl('5678', undefined), 'http://localhost:5678'); + }); +}); + +describe('waitForHealth', () => { + it('resolves true once the endpoint answers ok', async () => { + const { server, port } = await healthServer(200); + try { + assert.equal(await waitForHealth(port, '/healthz', 5000, 10), true); + } finally { + server.close(); + } + }); + + // A booting backend answers 503 before it is ready; that must not count as up. + it('does not accept a non-ok status', async () => { + const { server, port } = await healthServer(503); + try { + assert.equal(await waitForHealth(port, '/healthz', 60, 10), false); + } finally { + server.close(); + } + }); + + it('resolves false when nothing is listening', async () => { + assert.equal(await waitForHealth(1, '/healthz', 60, 10), false); + }); +}); + +describe('reportUp', () => { + it('reports an org-shared port as reachable by the org', () => { + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = DOMAIN; + const lines = captureLog(() => reportUp('5678', { name: NAME, shared: true })); + assert.match(lines[0], new RegExp(`Up: https://${NAME}-5678\\.${DOMAIN}`)); + assert.match(lines[1], /org-visible/); + }); + + // The share can fail while the backend is up, so say so and give the retry. + it('reports a failed share with the retry command', () => { + const lines = captureLog(() => + reportUp('5678', { name: NAME, shared: false, error: 'missing scope' }), + ); + assert.match(lines[1], /still private: missing scope/); + assert.match(lines[1], new RegExp(`gh codespace ports visibility 5678:org -c ${NAME}`)); + }); + + it('prints only the localhost URL off a codespace', () => { + const lines = captureLog(() => + reportUp('5678', { shared: false }, join(tmpdir(), 'no-such-codespaces-dir')), + ); + assert.deepEqual(lines, ['\nUp: http://localhost:5678']); + }); + + // The bug this helper exists for: a tmux-started process can see an empty box + // name, so the port is never shared. Saying "localhost" alone would hide that. + it('flags a codespace whose box name did not resolve', () => { + const dir = mkdtempSync(join(tmpdir(), 'codespaces-marker-')); + const lines = captureLog(() => reportUp('5678', { shared: false }, dir)); + assert.equal(lines[0], '\nUp: http://localhost:5678'); + assert.match(lines[1], /the box name did not resolve, so 5678 was not shared/); + }); +});