feat(core): Add PR preview instances on Codespaces (no-changelog) (#37695)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-09-04 09:01:33 +03:00 committed by GitHub
parent 64de0e71db
commit fd105a6dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 823 additions and 54 deletions

View File

@ -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

View File

@ -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/<repo>
"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"
}

View File

@ -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}`);
},
],
},
};

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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 <pr>`.
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));

137
scripts/preview-serve.mjs Normal file
View File

@ -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}.`);

305
scripts/preview.mjs Normal file
View File

@ -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 <pr> create or start the box, then serve the PR head
// pnpm preview refresh <pr> move to the current PR head and restart
// pnpm preview down <pr> 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 <up|refresh|down|ls> [pr] [--json] [--dry-run]');
}

65
scripts/serve-ready.mjs Normal file
View File

@ -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)`,
);
}

View File

@ -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/);
});
});