ci: Add an opt in label that automatically provisions a codespace (#37800)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-09-04 13:19:24 +00:00 committed by GitHub
parent 90d176b969
commit eeec778a88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1054 additions and 55 deletions

View File

@ -48,6 +48,41 @@ create an 8-core Codespace by default.
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
## PR previews (a running instance of someone else's PR)
A preview is a codespace that serves one pull request, so a reviewer can use the
PR instead of reading it. It is a different box from a `pnpm session`: it uses
`.devcontainer/preview/devcontainer.json`, and its display name is
`preview/pr-<number>`.
**From GitHub:** for a PR targeting master, add the `codespace-preview` label to the PR.
[util-codespace-preview.yml](../../.github/workflows/util-codespace-preview.yml)
creates the box, serves the PR head, shares port 5678 with the org, and comments
the URL on the PR. A later push serves the new head in the same box. Remove the
label, or close the PR, to delete the box.
**From your laptop:** `pnpm preview up <pr>` does the same thing, plus
`pnpm preview refresh <pr>`, `pnpm preview down <pr>` and `pnpm preview ls`. It
needs `gh` with the codespace scope, the same as `pnpm session`.
- **Sign in with one click** at `<url>/preview-signin`. It logs you in as the
seeded owner and sends you to the editor. The credentials are
`preview@n8n.io` / `PreviewInstance1`. They are not secrets: the boundary is
the org-visible forwarded port, which needs a GitHub sign-in and n8n org
membership.
- **Configure the instance with `preview:*` labels.** `preview:enterprise` serves
it with a licence, so enterprise features such as SSO and source control are
present. `preview:debug` sets `N8N_LOG_LEVEL=debug`. Adding or removing one
re-serves the box; it never creates or deletes one. From a laptop the labels
apply the same way — `pnpm preview refresh <pr>` reads them from the PR. The
toggles are defined in `scripts/preview-labels.mjs`; add new ones there.
- **A preview sleeps after 30 minutes** of no use and GitHub deletes it after 24
hours. Add the label again to get a new one.
- **A PR from a fork gets no preview.** A codespace's token is scoped to
`n8n-io/n8n`, so it cannot check out a fork head.
- **A PR that predates this tooling has no `scripts/preview-serve.mjs`.** The
serve step says so and stops; rebase the PR on master and retry.
## Agent worker (drive a session from n8n)
`agent-worker.mjs` lets an n8n workflow drive an OpenCode session on the
@ -305,3 +340,7 @@ Compute bills only while the codespace runs: ~$0.72/hr for the 8-core box,
~nothing stopped. Usage draws from a shared monthly org budget, so
`pnpm session stop` when you leave; the idle timeout is the backstop, not the
plan.
Previews draw from the same budget on a 2-core box. Remove the
`codespace-preview` label when the review is done instead of waiting for the
24-hour retention.

111
.github/WORKFLOWS.md vendored
View File

@ -231,10 +231,11 @@ parallelism). See the `--build-via-mcp` section in
### On PR Close/Merge
| Event | Workflow |
|----------------------------|-----------------------------|
| PR closed (any) | `util-notify-pr-status.yml` |
| PR merged to `release/*` | `release-publish.yml` |
| Event | Workflow |
|------------------------------------|--------------------------------|
| PR closed (any) | `util-notify-pr-status.yml` |
| PR merged to `release/*` | `release-publish.yml` |
| PR closed with `codespace-preview` | `util-codespace-preview.yml` |
### Manual Triggers (PR Comments)
@ -244,6 +245,97 @@ parallelism). See the `--build-via-mcp` section in
**Why:** Re-run tests without pushing commits. Useful for flaky test investigation.
### Label Triggers
| Label | Workflow | Effect |
|-----------------------|------------------------------|----------------------------------------------------|
| `codespace-preview` | `util-codespace-preview.yml` | Runs the PR in a Codespace, comments the URL |
| `preview:enterprise` | `util-codespace-preview.yml` | Re-serves the instance with an enterprise licence |
| `preview:debug` | `util-codespace-preview.yml` | Re-serves the instance with `N8N_LOG_LEVEL=debug` |
**Why:** A reviewer gets a running instance of the PR without a Docker build or a
cloud deploy. The workflow calls `scripts/preview.mjs`, which keeps one codespace
for each PR (display name `preview/pr-<number>`) and shares port 5678 with the
organization. A later push serves the new head in the same box. Removing the
label, or closing the PR, deletes the box.
Only a PR from a branch in this repository is eligible: a codespace token is
scoped to `n8n-io/n8n` and cannot check out a fork head.
#### Preview toggles
A `preview:*` label configures an instance that already exists, so adding or
removing one re-serves the box instead of creating or deleting it. It does
nothing on a PR without `codespace-preview`.
The vocabulary lives in `scripts/preview-labels.mjs`, which both ends import:
`preview.mjs` turns the PR's labels into slugs, and `preview-serve.mjs` turns
those slugs into environment inside the box. Add a toggle there, in one place.
Only slugs cross the gap. The `gh codespace ssh` command is a shell string that
appears in the box's process list, so a value is never passed through it —
`preview:enterprise` resolves to a licence key inside the box, not on the runner.
`preview:enterprise` needs a **Codespaces** secret named
`N8N_LICENSE_ACTIVATION_KEY`, scoped to `n8n-io/n8n`. That is a Codespaces
secret, not an Actions secret, and it is unrelated to `CODESPACE_PREVIEW_TOKEN`.
Use the sandbox key: the preview sets tenant `1001` to match, and the default
tenant (`1`) rejects it. Without the secret the preview still serves, unlicensed,
and says so in the log.
Note what the label does and does not control. Codespaces injects the secret into
**every** preview box, so the label decides whether the licence reaches n8n, not
whether the key reaches the box. Anyone who can run PR-head code can read
`/workspaces/.codespaces/shared/.env-secrets`. Previews are limited to branches
in this repository, so that is the set of people who already have write access.
#### The `CODESPACE_PREVIEW_TOKEN` secret
The job needs `CODESPACE_PREVIEW_TOKEN`, a **fine-grained** personal access token,
held in the `codespaces` environment. Set the resource owner to `n8n-io` and limit
repository access to `n8n-io/n8n`. Grant these repository permissions:
| Permission | Level | What it unlocks |
|---|---|---|
| Metadata | Read | Mandatory, selected for you |
| Codespaces | Read and write | Create, list and delete a box |
| Codespaces metadata | Read | `GET .../codespaces/machines`, which resolves the machine type |
| Codespaces lifecycle admin | Read and write | Start a stopped box, which is what `gh codespace ssh` does |
| Contents | Read | Read the repository |
| Pull requests | Read | `gh pr view`, to resolve the head ref and SHA |
`Codespaces metadata` is a **different permission** from `Codespaces`. Without it
the run fails with `HTTP 403: Resource not accessible by personal access token` on
the `machines` endpoint, after the org billing check has already printed a tick —
so the failure looks unrelated to permissions.
An organization owner may have to approve the token. It stays pending until then.
No other credential can do this:
- `GITHUB_TOKEN` has no Codespaces access.
- A GitHub App **installation** token cannot create a codespace at all. The
Codespaces API belongs to a user, not to an installation.
- A **classic** token is refused by org policy:
`` `n8n-io` forbids access via a personal access token (classic) ``. So no
combination of classic scopes works, whatever the API reference says about the
`codespace` scope.
The environment keeps the token away from every other workflow: only a job that
names `codespaces` can read it. **The environment must allow every branch.** A
`pull_request` run has the ref `refs/pull/<n>/merge`, which no deployment branch
policy matches, so a branch rule would block every preview. Add required
reviewers only if a click for each preview is acceptable.
The codespace belongs to whoever owns the token, and shows up in that account's
codespace list. Billing still goes to the organization, because the repository is
organization-owned and has a Codespaces budget. A service account is therefore
better than a person's account for quota attribution, though the token is scoped
to one repository either way.
The job checks out the base branch, never the PR head, so a PR cannot supply the
script that reads that token.
### Other Manual Workflows
| Workflow | Purpose |
@ -572,6 +664,17 @@ Scripts in `.github/scripts/`:
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
### Preview Scripts
| Script | Purpose | Called By |
|---------------------------------|-------------------------------------------------------------------------|--------------------------------|
| `codespace-preview.mjs` | Map a `pull_request` event onto a preview operation, comment the result | `util-codespace-preview.yml` |
| `../../scripts/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
`scripts/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
In `--json` mode it prints one object on stdout and sends all progress to stderr,
so a workflow can read the URL from a run that also streams an in-box build log.
### Branch Replay Scripts
Both keep a long-lived branch that is "base + its own commits" in sync by rebasing those

237
.github/scripts/codespace-preview.mjs vendored Normal file
View File

@ -0,0 +1,237 @@
#!/usr/bin/env node
// Drives a PR preview instance from CI. `scripts/preview.mjs` does the work; this
// script maps the pull_request event onto one of its operations and reports the
// result back to the PR as a single, edited-in-place comment.
//
// labeled -> up create or start the box, then serve the PR head
// synchronize -> refresh re-serve the new head in the box that already exists
// unlabeled -> down delete the box
// closed -> down
//
// `refresh` never creates a box. A box that GitHub already deleted (24 h
// retention) is reported as expired, not as a failure.
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/preview-labels.mjs';
import { ensureEnvVar, postOrUpdateComment } from './github-helpers.mjs';
export const BOT_MARKER = '<!-- codespace-preview -->';
export const PREVIEW_LABEL = 'codespace-preview';
// Copied from `preview.mjs`, which owns them. They cannot be imported: that module
// runs its command switch on import. They appear in the comment so a reviewer knows
// how long the instance lasts without reading the script.
const IDLE_TIMEOUT = '30 minutes';
const RETENTION_PERIOD = '24 hours';
// Resolved against this file, so the script runs the same from any directory.
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/preview.mjs', import.meta.url));
/**
* A `preview:*` label configures an instance that already exists, so toggling one
* re-serves the box rather than creating or deleting it.
*
* @param {string} action The pull_request event action.
* @param {string} [label] `github.event.label.name`, for a labeled/unlabeled event.
* @returns {'up' | 'refresh' | 'down' | undefined}
*/
export function operationFor(action, label) {
switch (action) {
case 'labeled':
case 'unlabeled':
if (label?.startsWith(PREVIEW_LABEL_PREFIX)) return 'refresh';
if (label !== PREVIEW_LABEL) return undefined;
return action === 'labeled' ? 'up' : 'down';
case 'synchronize':
return 'refresh';
case 'closed':
return 'down';
default:
return undefined;
}
}
/**
* `preview.mjs --json` keeps stdout clean, but a truncated or empty run still has
* to be told apart from a good one. Read the last JSON object that carries a url.
*
* @param {string} stdout
* @returns {{pr: number, sha: string, codespace: string, url: string, orgVisible: boolean} | undefined}
*/
export function parsePreviewJson(stdout) {
for (const line of stdout.split('\n').reverse()) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed.url === 'string') return parsed;
} catch {}
}
return undefined;
}
/**
* `preview.mjs ls` prints one tab-separated row for each box:
* `preview/pr-<pr>\t<state>\t<name>\tlast used <iso>`. Compare the whole first
* field, or PR 3 would match the box for PR 37.
*
* @param {string} stdout
* @param {string | number} pr
*/
export function hasPreviewBox(stdout, pr) {
return stdout.split('\n').some((line) => line.split('\t')[0]?.trim() === `preview/pr-${pr}`);
}
/**
* A forwarded Codespaces URL is `https://<codespace>-<port>.app.github.dev`. The
* trailing dot anchors the match, so a codespace name that ends in digits is safe.
*
* @param {string} url
*/
export function portFromUrl(url) {
return new URL(url).hostname.match(/-(\d+)\./)?.[1];
}
/** @param {{url: string, codespace: string, sha: string, orgVisible: boolean, pr: string | number}} preview */
export function readyComment({ url, codespace, sha, orgVisible, pr }) {
// A failed port share is not fatal in preview.mjs, so the box can be up while
// the URL still answers 302 to everyone. Say so instead of implying it works.
const port = portFromUrl(url);
const access = orgVisible
? 'Every n8n org member who is signed in to GitHub can open it.'
: [
`**Port ${port} is still private.** The instance runs, but only its owner can`,
'open the URL. To share it, run:',
'',
'```',
`gh codespace ports visibility ${port}:org -c ${codespace}`,
'```',
].join('\n');
return [
BOT_MARKER,
`### Preview instance for \`${sha.slice(0, 7)}\``,
'',
`**[Open the preview](${url}/preview-signin)** — one click signs you in.`,
'',
'| | |',
'| --- | --- |',
`| URL | ${url} |`,
`| Codespace | \`preview/pr-${pr}\` |`,
`| Sign in | \`preview@n8n.io\` / \`PreviewInstance1\` |`,
'',
access,
'',
`The instance sleeps after ${IDLE_TIMEOUT} of no use and is deleted after ${RETENTION_PERIOD}.`,
`Push a commit to serve it again. Remove the \`${PREVIEW_LABEL}\` label to delete it now.`,
].join('\n');
}
/** @param {{pr: string | number}} context */
export function downComment({ pr }) {
return [
BOT_MARKER,
`### Preview instance deleted`,
'',
`The preview box for PR #${pr} is gone. Add the \`${PREVIEW_LABEL}\` label to get a new one.`,
].join('\n');
}
/** @param {{pr: string | number}} context */
export function expiredComment({ pr }) {
return [
BOT_MARKER,
`### Preview instance expired`,
'',
`The preview box for PR #${pr} no longer exists — GitHub deletes one after ${RETENTION_PERIOD}.`,
`Remove and add the \`${PREVIEW_LABEL}\` label to get a new one.`,
].join('\n');
}
/** @param {{operation: string, runUrl: string, message: string}} context */
export function failureComment({ operation, runUrl, message }) {
return [
BOT_MARKER,
`### Preview instance failed`,
'',
`\`preview ${operation}\` did not finish: ${message}`,
'',
`See [the workflow run](${runUrl}) for the full log. Remove and add the \`${PREVIEW_LABEL}\` label to try again.`,
].join('\n');
}
/**
* Progress and the in-box build log go to stderr, so they stream into the job log
* live. Only the JSON line is captured.
*
* @param {readonly string[]} args
*/
function runPreview(args) {
const result = spawnSync('node', [PREVIEW_SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
});
if (result.error) throw result.error;
return { status: result.status, stdout: result.stdout ?? '' };
}
async function main() {
const pr = ensureEnvVar('PULL_REQUEST_NUMBER');
const action = ensureEnvVar('EVENT_ACTION');
const runUrl = ensureEnvVar('RUN_URL');
const label = process.env.LABEL_NAME;
const operation = operationFor(action, label);
console.log("Operations: ", {
action,
label,
operation
})
if (!operation) {
console.log(
`No preview operation for a "${action}" event on "${label ?? ''}" — nothing to do.`,
);
return;
}
try {
if (operation === 'refresh') {
const list = runPreview(['ls']);
if (list.status !== 0) throw new Error(`\`preview ls\` exited ${list.status}`);
if (!hasPreviewBox(list.stdout, pr)) {
console.log(`No preview box for PR #${pr} — reporting it as expired.`);
await postOrUpdateComment(Number(pr), expiredComment({ pr }), BOT_MARKER);
return;
}
}
const { status, stdout } = runPreview([operation, pr, '--json']);
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
if (operation === 'down') {
await postOrUpdateComment(Number(pr), downComment({ pr }), BOT_MARKER);
return;
}
const preview = parsePreviewJson(stdout);
if (!preview) throw new Error(`\`preview ${operation}\` printed no preview details`);
console.log(`Preview for PR #${pr}: ${preview.url}`);
await postOrUpdateComment(Number(pr), readyComment({ ...preview, pr }), BOT_MARKER);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`::error::Preview ${operation} failed for PR #${pr}: ${message}`);
// Report the failure on the PR too: a label that looks inert is worse than a
// label that says what went wrong.
await postOrUpdateComment(
Number(pr),
failureComment({ operation, runUrl, message }),
BOT_MARKER,
);
process.exitCode = 1;
}
}
// Importable for tests without running the orchestration.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}

View File

@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
BOT_MARKER,
downComment,
expiredComment,
failureComment,
hasPreviewBox,
operationFor,
parsePreviewJson,
portFromUrl,
readyComment,
} from './codespace-preview.mjs';
const PREVIEW = {
pr: 1234,
sha: 'abcdef1234567890abcdef1234567890abcdef12',
codespace: 'psychic-umbrella-q7w6gwx',
url: 'https://psychic-umbrella-q7w6gwx-5678.app.github.dev',
orgVisible: true,
};
describe('operationFor', () => {
const LABEL = 'codespace-preview';
it('creates and deletes on the trigger label', () => {
assert.equal(operationFor('labeled', LABEL), 'up');
assert.equal(operationFor('unlabeled', LABEL), 'down');
});
it('re-serves on a push or a preview: toggle, without creating a box', () => {
assert.equal(operationFor('synchronize'), 'refresh');
// A toggle configures an instance that exists; it must never create or delete one.
assert.equal(operationFor('labeled', 'preview:enterprise'), 'refresh');
assert.equal(operationFor('unlabeled', 'preview:enterprise'), 'refresh');
assert.equal(operationFor('labeled', 'preview:debug'), 'refresh');
});
it('deletes when the PR closes', () => {
assert.equal(operationFor('closed'), 'down');
});
it('ignores a label that is not ours', () => {
assert.equal(operationFor('labeled', 'bug'), undefined);
assert.equal(operationFor('unlabeled', 'Do Not Merge'), undefined);
// A near-miss must not be read as the trigger label.
assert.equal(operationFor('labeled', 'codespace-preview-2'), undefined);
assert.equal(operationFor('labeled', undefined), undefined);
});
it('returns undefined for an action the workflow does not handle', () => {
assert.equal(operationFor('opened'), undefined);
assert.equal(operationFor('reopened'), undefined);
assert.equal(operationFor(''), undefined);
});
});
describe('parsePreviewJson', () => {
it('finds the JSON line among progress and in-box build output', () => {
const stdout = [
'Creating a preview box for PR #1234 (my-branch)…',
'Waiting for psychic-umbrella-q7w6gwx to accept ssh…',
'> n8n@1.0.0 build /workspaces/n8n',
'Tasks: 112 successful, 112 total',
'Ready: the backend answers /healthz on port 5678.',
JSON.stringify(PREVIEW),
].join('\n');
assert.deepEqual(parsePreviewJson(stdout), PREVIEW);
});
it('takes the last preview object when several are printed', () => {
const stale = { ...PREVIEW, sha: '0'.repeat(40) };
const stdout = `${JSON.stringify(stale)}\n${JSON.stringify(PREVIEW)}\n`;
assert.equal(parsePreviewJson(stdout).sha, PREVIEW.sha);
});
it('ignores JSON that is not a preview report', () => {
// A build tool can print its own JSON on the same stream.
assert.equal(parsePreviewJson('{"level":"info","msg":"built"}'), undefined);
});
it('returns undefined for output with no JSON at all', () => {
assert.equal(parsePreviewJson(''), undefined);
assert.equal(parsePreviewJson('Serving abcdef1 on psychic-umbrella-q7w6gwx…'), undefined);
});
});
describe('hasPreviewBox', () => {
const ls = [
'preview/pr-37\tAvailable\tpsychic-umbrella-q7w6gwx\tlast used 2026-09-04T09:00:00Z',
'preview/pr-1234\tShutdown\tvigilant-broccoli-x9x8x9x\tlast used 2026-09-03T18:20:00Z',
].join('\n');
it('finds a box by its exact display name', () => {
assert.equal(hasPreviewBox(ls, 37), true);
assert.equal(hasPreviewBox(ls, '1234'), true);
});
it('does not treat a shorter PR number as a prefix match', () => {
// `preview/pr-3` must not match the row for `preview/pr-37`.
assert.equal(hasPreviewBox(ls, 3), false);
assert.equal(hasPreviewBox(ls, 123), false);
});
it('handles the empty listing preview.mjs prints when nothing exists', () => {
assert.equal(hasPreviewBox('No preview boxes on n8n-io/n8n.', 37), false);
assert.equal(hasPreviewBox('', 37), false);
});
});
describe('portFromUrl', () => {
it('reads the forwarded port', () => {
assert.equal(portFromUrl(PREVIEW.url), '5678');
});
it('is not confused by a codespace name that ends in digits', () => {
assert.equal(portFromUrl('https://opulent-broccoli-9936x9x-5678.app.github.dev'), '5678');
});
});
describe('comment bodies', () => {
const bodies = {
ready: readyComment(PREVIEW),
readyPrivate: readyComment({ ...PREVIEW, orgVisible: false }),
down: downComment({ pr: PREVIEW.pr }),
expired: expiredComment({ pr: PREVIEW.pr }),
failure: failureComment({
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
message: '`preview up` exited 1',
}),
};
it('every body starts with the marker, so postOrUpdateComment edits in place', () => {
for (const [name, body] of Object.entries(bodies)) {
assert.ok(body.startsWith(BOT_MARKER), `${name} must start with the bot marker`);
}
});
it('the ready comment links the one-click sign-in path', () => {
assert.match(bodies.ready, /\/preview-signin\)/);
assert.match(bodies.ready, /abcdef1/);
assert.match(bodies.ready, /preview\/pr-1234/);
});
it('the ready comment does not claim org access when the port share failed', () => {
assert.match(bodies.ready, /Every n8n org member/);
assert.doesNotMatch(bodies.readyPrivate, /Every n8n org member/);
assert.match(
bodies.readyPrivate,
/gh codespace ports visibility 5678:org -c psychic-umbrella-q7w6gwx/,
);
});
it('the failure comment carries the cause and the run link', () => {
assert.match(bodies.failure, /exited 1/);
assert.match(bodies.failure, /actions\/runs\/1/);
});
});

View File

@ -122,6 +122,8 @@ jobs:
OWNERS
scripts/*.test.mjs
scripts/codespace-env.mjs
scripts/preview.mjs
scripts/preview*
scripts/serve-ready.mjs
scripts/licenses/**
scripts/mutation-health/**

View File

@ -0,0 +1,86 @@
name: 'Util: Codespace Preview'
run-name: Codespace preview for PR ${{ github.event.pull_request.number }}
# Runs a preview instance of a pull request in a GitHub Codespace, then reports
# the URL back to the PR in one comment that later runs edit in place.
#
# Triggers, all opt-in through the `codespace-preview` label:
# - labeled -> `preview up`, which creates or starts the box and serves the head
# - synchronize -> `preview refresh`, which serves the new head in the same box
# - unlabeled -> `preview down`
# - closed -> `preview down`, so a merged PR does not hold a box for 24 h
#
# Output: a `<!-- codespace-preview -->` comment on the PR with the instance URL,
# a one-click sign-in link, and the commit it serves.
on:
pull_request:
types:
- labeled
- unlabeled
- synchronize
- closed
branches:
- master
permissions: {}
concurrency:
# Never cancel a run in progress. Cancelling during `gh codespace create` leaves
# a box that nothing tracks and the org still pays for. Keyed on the PR, so the
# up, refresh and down runs for one PR happen one after another.
group: codespace-preview-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
preview:
name: Preview instance
# `github.event.label.name` is the label that just changed. `labels.*.name` is
# the set of labels the PR carries now, which is what a push, a close, or a
# `preview:*` toggle needs. A toggle only matters on a PR that already has a
# preview, so it is gated on `codespace-preview` being present.
if: |
github.repository == 'n8n-io/n8n' &&
github.event.pull_request.head.repo.full_name == github.repository &&
(
((github.event.action == 'labeled' || github.event.action == 'unlabeled') &&
github.event.label.name == 'codespace-preview') ||
((github.event.action == 'labeled' || github.event.action == 'unlabeled') &&
startsWith(github.event.label.name, 'preview:') &&
contains(github.event.pull_request.labels.*.name, 'codespace-preview')) ||
((github.event.action == 'synchronize' || github.event.action == 'closed') &&
contains(github.event.pull_request.labels.*.name, 'codespace-preview'))
)
runs-on: ubuntu-latest
environment: codespaces
timeout-minutes: 45
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout base branch
uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
ref: ${{ github.event.pull_request.base.ref }}
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
- name: Run the preview operation
env:
# `gh` prefers GH_TOKEN over GITHUB_TOKEN. So the `gh` calls in
# preview.mjs use the Codespaces token, and Octokit comments as the
# Actions token.
# TODO: move CODESPACE_PREVIEW_TOKEN to a service account.
GH_TOKEN: ${{ secrets.CODESPACE_PREVIEW_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
EVENT_ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/scripts/codespace-preview.mjs

View File

@ -32,6 +32,32 @@ export function codespaceEnv(name, sharedDir = SHARED) {
return line?.slice(name.length + 1).trim() || undefined;
}
/**
* Returns a Codespaces user or repository secret, or undefined.
*
* Secrets live in a different file from `codespaceEnv`'s, base64-encoded, and
* Codespaces exports them to VS Code sessions only. A tmux or ssh shell gets
* nothing. `.devcontainer/codespaces/codespaces-env.sh` decodes them for
* interactive bash; this is the same rule for a Node caller, so keep the two in
* step: skip a key with a character outside [A-Za-z0-9_], skip a value that does
* not decode, and let the last line win.
*/
export function codespaceSecret(name, sharedDir = SHARED) {
if (process.env[name]) return process.env[name];
if (/[^A-Za-z0-9_]/.test(name)) return undefined;
const line = readShared(sharedDir, '.env-secrets')
.split('\n')
.findLast((l) => l.startsWith(`${name}=`));
if (!line) return undefined;
try {
return Buffer.from(line.slice(name.length + 1).trim(), 'base64').toString('utf8') || undefined;
} catch {
return undefined;
}
}
export const codespaceName = (sharedDir) => codespaceEnv('CODESPACE_NAME', sharedDir);
export const forwardingDomain = (sharedDir) =>

View File

@ -4,7 +4,12 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { codespaceEnv, codespaceName, forwardingDomain } from './codespace-env.mjs';
import {
codespaceEnv,
codespaceName,
codespaceSecret,
forwardingDomain,
} from './codespace-env.mjs';
const NAME = 'psychic-umbrella-wqj9pvw9p939vp6';
@ -77,3 +82,38 @@ describe('forwardingDomain', () => {
assert.equal(forwardingDomain(shared({})), 'app.github.dev');
});
});
describe('codespaceSecret', () => {
const b64 = (value) => Buffer.from(value, 'utf8').toString('base64');
it('decodes a base64 value', () => {
delete process.env.SANDBOX_KEY;
const dir = shared({ '.env-secrets': `SANDBOX_KEY=${b64('licence-abc')}\n` });
assert.equal(codespaceSecret('SANDBOX_KEY', dir), 'licence-abc');
});
it('prefers a value already in the environment', () => {
process.env.SANDBOX_KEY = 'from-env';
const dir = shared({ '.env-secrets': `SANDBOX_KEY=${b64('from-file')}\n` });
assert.equal(codespaceSecret('SANDBOX_KEY', dir), 'from-env');
delete process.env.SANDBOX_KEY;
});
// codespaces-env.sh lets a later line win, as a shell would.
it('takes the last line for a repeated key', () => {
delete process.env.SANDBOX_KEY;
const dir = shared({
'.env-secrets': `SANDBOX_KEY=${b64('old')}\nSANDBOX_KEY=${b64('new')}\n`,
});
assert.equal(codespaceSecret('SANDBOX_KEY', dir), 'new');
});
it('returns undefined for a missing key, a missing file, and a rejected name', () => {
delete process.env.SANDBOX_KEY;
const dir = shared({ '.env-secrets': `OTHER=${b64('x')}\n` });
assert.equal(codespaceSecret('SANDBOX_KEY', dir), undefined);
assert.equal(codespaceSecret('SANDBOX_KEY', shared({})), undefined);
// codespaces-env.sh skips a key with a character outside [A-Za-z0-9_].
assert.equal(codespaceSecret('BAD-KEY', dir), undefined);
});
});

View File

@ -0,0 +1,71 @@
// The `preview:*` label vocabulary, shared by both ends of a preview.
//
// `preview.mjs` runs on a laptop or a CI runner and turns the PR's labels into a
// slug list. `preview-serve.mjs` runs inside the box and turns that list into
// environment for the backend. Both import this file, so the two cannot drift.
//
// Only slugs cross the gap. A secret is read in the box, never passed in the
// `gh codespace ssh` command, which is a shell string and shows up in the box's
// process list.
export const PREVIEW_LABEL_PREFIX = 'preview:';
const PREFIX = PREVIEW_LABEL_PREFIX;
// The sandbox tenant. The default (1) is production self-hosted, which rejects a
// sandbox key. Matches packages/testing/containers/services/n8n.ts.
const LICENSE_TENANT_ID = '1001';
const LICENSE_KEY_SECRET = 'N8N_LICENSE_ACTIVATION_KEY';
/**
* Slugs of the `preview:*` labels on a PR.
*
* The result is interpolated into a shell command, so the shape test is a safety
* boundary rather than tidiness: anything that is not a plain lowercase slug is
* dropped.
*
* @param {Array<{name?: string}> | undefined} labels `gh pr view --json labels`
* @returns {string[]}
*/
export function previewSlugs(labels) {
return (labels ?? [])
.map((label) => label?.name)
.filter((name) => typeof name === 'string' && name.startsWith(PREFIX))
.map((name) => name.slice(PREFIX.length))
.filter((slug) => /^[a-z0-9][a-z0-9-]*$/.test(slug));
}
/**
* Environment for a slug list, plus the warnings a caller should print.
*
* @param {string[]} slugs
* @param {(name: string) => string | undefined} readSecret
* @returns {{ env: string[], warnings: string[] }} env as `KEY=VALUE` pairs
*/
export function envForSlugs(slugs, readSecret) {
const env = [];
const warnings = [];
for (const slug of slugs) {
switch (slug) {
case 'enterprise': {
const key = readSecret(LICENSE_KEY_SECRET);
// Serve unlicensed rather than fail: a preview without enterprise
// features is still worth reviewing.
if (!key) {
warnings.push(
`preview:enterprise needs the ${LICENSE_KEY_SECRET} codespace secret, which this box does not have. Serving without a licence.`,
);
break;
}
env.push(`N8N_LICENSE_TENANT_ID=${LICENSE_TENANT_ID}`, `${LICENSE_KEY_SECRET}=${key}`);
break;
}
case 'debug':
env.push('N8N_LOG_LEVEL=debug');
break;
default:
warnings.push(`Ignoring preview:${slug} — no such preview toggle.`);
}
}
return { env, warnings };
}

View File

@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { envForSlugs, previewSlugs } from './preview-labels.mjs';
const labels = (...names) => names.map((name) => ({ name }));
const noSecret = () => undefined;
const withSecret = (value) => (name) => (name === 'N8N_LICENSE_ACTIVATION_KEY' ? value : undefined);
describe('previewSlugs', () => {
it('keeps preview: labels and strips the prefix', () => {
assert.deepEqual(
previewSlugs(labels('codespace-preview', 'preview:enterprise', 'preview:debug', 'bug')),
['enterprise', 'debug'],
);
});
// The slugs are interpolated into the `gh codespace ssh` command, so this
// filter is a safety boundary rather than tidiness.
it('drops a slug that is not a plain lowercase slug', () => {
assert.deepEqual(
previewSlugs(
labels(
'preview:foo;rm -rf /',
'preview:$(whoami)',
'preview:has space',
'preview:UPPER',
'preview:',
'preview:-leading-dash',
),
),
[],
);
});
it('survives missing or malformed input', () => {
assert.deepEqual(previewSlugs(undefined), []);
assert.deepEqual(previewSlugs([]), []);
assert.deepEqual(previewSlugs([{}, { name: null }]), []);
});
it('does not treat the trigger label as a toggle', () => {
assert.deepEqual(previewSlugs(labels('codespace-preview')), []);
});
});
describe('envForSlugs', () => {
it('gives an enterprise preview the sandbox tenant and the key', () => {
const { env, warnings } = envForSlugs(['enterprise'], withSecret('sandbox-key'));
assert.deepEqual(env, ['N8N_LICENSE_TENANT_ID=1001', 'N8N_LICENSE_ACTIVATION_KEY=sandbox-key']);
assert.deepEqual(warnings, []);
});
// An unlicensed preview is still worth reviewing, so a missing secret warns
// rather than failing the serve.
it('warns and stays unlicensed when the secret is absent', () => {
const { env, warnings } = envForSlugs(['enterprise'], noSecret);
assert.deepEqual(env, []);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /N8N_LICENSE_ACTIVATION_KEY codespace secret/);
});
it('maps debug to the log level', () => {
assert.deepEqual(envForSlugs(['debug'], noSecret).env, ['N8N_LOG_LEVEL=debug']);
});
it('warns about a slug it does not know, and keeps the rest', () => {
const { env, warnings } = envForSlugs(['debug', 'teleport'], noSecret);
assert.deepEqual(env, ['N8N_LOG_LEVEL=debug']);
assert.match(warnings[0], /preview:teleport/);
});
it('returns nothing for no slugs', () => {
assert.deepEqual(envForSlugs([], noSecret), { env: [], warnings: [] });
});
});

View File

@ -10,9 +10,12 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { openSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { serveHealthPath, servePort, waitForHealth } from './serve-ready.mjs';
import { codespaceSecret } from './codespace-env.mjs';
import { envForSlugs } from './preview-labels.mjs';
import { serveHealthPath, servePort, waitForHealth, waitForReady } from './serve-ready.mjs';
const SESSION = 'n8n-preview';
const BUILD_LOG = '/tmp/preview-build.log';
@ -78,12 +81,25 @@ const signinEnv = [
`PREVIEW_OWNER_PASSWORD=${OWNER_PASSWORD}`,
].flatMap((pair) => ['-e', pair]);
// `preview.mjs` passes the PR's preview:* label slugs. Resolve them here, where a
// codespace secret is readable, and apply them before the sign-in variables so a
// toggle can never displace the preview's own wiring.
const slugs = (process.env.PREVIEW_LABELS ?? '').split(',').filter(Boolean);
const { env: labelEnv, warnings } = envForSlugs(slugs, codespaceSecret);
for (const warning of warnings) console.warn(warning);
if (labelEnv.length)
// Names only: one of these values is a licence key.
console.log(
`Applying preview labels: ${slugs.join(', ')} → ${labelEnv.map((pair) => pair.split('=')[0]).join(', ')}`,
);
console.log(`Starting backend in tmux session '${SESSION}' (log: ${BE_LOG})…`);
const started = tmux(
'new',
'-d',
'-s',
SESSION,
...labelEnv.flatMap((pair) => ['-e', pair]),
...signinEnv,
`cd ${repoRoot} && exec pnpm dev:be > ${BE_LOG} 2>&1`,
);
@ -101,10 +117,27 @@ if (!(await waitForHealth(port, healthPath))) {
// 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;
// The setup endpoint's status cannot tell success from failure: it answers 400 both
// when an owner exists and when the payload is wrong, and 404 while the REST routes
// are still mounting. `showSetupOnFirstLoad` drives the wall a reviewer actually
// hits, so check that instead. Undefined means the answer is not readable yet.
async function setupWallIsUp() {
try {
res = await fetch(`http://127.0.0.1:${port}/rest/owner/setup`, {
const res = await fetch(`http://127.0.0.1:${port}/rest/settings`, {
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return undefined;
const body = await res.json();
return body?.data?.userManagement?.showSetupOnFirstLoad;
} catch {
return undefined;
}
}
async function postOwnerSetup() {
try {
const res = await fetch(`http://127.0.0.1:${port}/rest/owner/setup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@ -115,21 +148,43 @@ async function seedOwner() {
}),
signal: AbortSignal.timeout(15_000),
});
return `HTTP ${res.status} ${(await res.text()).slice(0, 300)}`;
} catch (error) {
console.error(`Could not reach the owner setup endpoint: ${error.message}`);
return;
return `request failed: ${error.message}`;
}
}
if (res.ok) {
console.log(`Seeded the instance owner: ${OWNER_EMAIL} / ${OWNER_PASSWORD}`);
return;
// Retry anyway, as a backstop for anything readiness does not cover, and report
// every attempt: a silent seed failure hands the reviewer a URL that lands on /setup.
async function seedOwner(attempts = 5, delayMs = 3000) {
for (let attempt = 1; attempt <= attempts; attempt++) {
const result = await postOwnerSetup();
console.log(`Owner setup attempt ${attempt}/${attempts} — ${result}`);
if ((await setupWallIsUp()) === false) {
console.log(`Owner is set up — sign in as ${OWNER_EMAIL} / ${OWNER_PASSWORD}`);
return;
}
if (attempt < attempts) {
await sleep(delayMs);
}
}
// 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}`,
console.error(
`Owner seeding FAILED: /rest/settings still reports showSetupOnFirstLoad. A reviewer will land on /setup, and /${SIGNIN_ROUTE} will not work. See ${BE_LOG}.`,
);
}
// /healthz says nothing about the REST API, so seeding straight after it raced the
// controllers and got `Cannot POST /rest/owner/setup`. Wait for readiness first.
if (!(await waitForReady(port, healthPath))) {
console.error(
`Backend did not answer ${healthPath}/readiness within 3 min. It is not fully initialized, so the owner seeding below is likely to fail — check ${BE_LOG}.`,
);
execFileSync('tail', ['-n', '30', BE_LOG], { stdio: 'inherit'});
process.exit(1);
}
await seedOwner();
console.log(`One-click sign-in path: /${SIGNIN_ROUTE}`);

View File

@ -8,11 +8,12 @@
// pnpm preview down <pr> delete the box
// pnpm preview ls list preview boxes
//
// --json print {pr, sha, codespace, url} for a workflow to consume
// --json print {pr, sha, codespace, url} on stdout (progress goes to stderr)
// --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 { previewSlugs } from './preview-labels.mjs';
import { shareWithOrg } from './serve-ready.mjs';
const REPO = 'n8n-io/n8n';
@ -25,7 +26,7 @@ const DEVCONTAINER = process.env.PREVIEW_DEVCONTAINER ?? '.devcontainer/preview/
// 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 IDLE_TIMEOUT = '2h';
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.
@ -35,26 +36,65 @@ const PREFIX = 'preview/pr-';
// from here. Every box uses this one; the in-box script prints the resolved value.
const FORWARDING_DOMAIN = 'app.github.dev';
// The location defaults to the GH runner location, which might send it to
// use a region that doesn't have prebuilds set up. Hard code to Europe but allow overriding.
const LOCATION = process.env.PREVIEW_LOCATION || 'WestEurope';
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('--'));
// 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' });
// In --json mode stdout carries one machine-readable line and nothing else, so
// progress has to go to stderr. A caller parsing stdout gets no other output.
const log = (...parts) => (options.json ? console.error(...parts) : console.log(...parts));
// Send the child's stdout to our stderr in --json mode: `gh codespace ssh` inherits
// stdio, so the whole in-box build would otherwise land on the JSON channel.
const ghTty = (...args) =>
spawnSync('gh', args, { stdio: ['inherit', options.json ? 2 : 'inherit', 'inherit'] });
function fail(message) {
console.error(message);
process.exit(1);
}
// A token failure names an endpoint, not a fix, and two of these are not about
// scopes at all. Map each to the thing that resolves it.
function tokenErrorHint(message) {
const [org] = REPO.split('/');
if (message.includes('forbids access via a personal access token (classic)'))
return `${org} refuses classic tokens. Use a fine-grained one — see the CODESPACE_PREVIEW_TOKEN section of .github/WORKFLOWS.md.`;
if (message.includes('Resource not accessible by personal access token')) {
let missing = 'Codespaces (read and write)';
if (message.includes('/codespaces/machines')) missing = 'Codespaces metadata (read)';
else if (/\/(start|stop)\b/.test(message))
missing = 'Codespaces lifecycle admin (read and write)';
return `This fine-grained token is missing the ${missing} permission — see the CODESPACE_PREVIEW_TOKEN section of .github/WORKFLOWS.md.`;
}
return undefined;
}
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…');
// `gh auth refresh` opens a browser and waits for an answer, so it can only
// run against a terminal. Without one it would hang the caller forever.
if (!process.stdin.isTTY)
fail(
'This token has no codespace scope, and `gh auth refresh` needs a terminal. Supply a token that already carries the scope.',
);
log('Requesting codespace scope…');
ghTty('auth', 'refresh', '-h', 'github.com', '-s', 'codespace');
return ghJson(args, true);
}
const hint = tokenErrorHint(error.message);
if (hint) fail(hint);
throw new Error(error.message);
}
}
@ -76,20 +116,25 @@ function prHead(pr) {
'-R',
REPO,
'--json',
'headRefName,headRefOid,isCrossRepository,state',
'headRefName,headRefOid,isCrossRepository,state,labels',
]);
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}.`);
if (head.state !== 'OPEN') 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) =>
[
const serveCommand = (head) => {
// Pass the slugs, never the environment they stand for: this string reaches the
// box's process list, and preview:enterprise resolves to a licence key there.
const slugs = previewSlugs(head.labels);
const labelEnv = slugs.length ? `PREVIEW_LABELS=${slugs.join(',')} ` : '';
return [
'cd /workspaces/n8n',
`git fetch origin ${head.headRefName}`,
`git checkout --detach ${head.headRefOid}`,
@ -102,8 +147,9 @@ const serveCommand = (head) =>
// 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',
`${labelEnv}pnpm preview:serve`,
].join(' && ');
};
const createArgs = (pr, head) => [
'codespace',
@ -122,6 +168,8 @@ const createArgs = (pr, head) => [
IDLE_TIMEOUT,
'--retention-period',
RETENTION_PERIOD,
'--location',
LOCATION,
];
function report(pr, head, codespace, json, orgVisible = false) {
@ -147,7 +195,7 @@ async function waitForSsh(name, timeoutMs = 600_000) {
});
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…`);
if (attempt++ === 0) log(`Waiting for ${name} to accept ssh…`);
await sleep(5000);
}
fail(
@ -164,7 +212,7 @@ async function shareWhenForwarded(port, name, timeoutMs = 120_000) {
let waited = false;
while (!share.shared && Date.now() < deadline) {
if (!waited) {
console.log(`Waiting for port ${port} to be forwarded before sharing it…`);
log(`Waiting for port ${port} to be forwarded before sharing it…`);
waited = true;
}
await sleep(3000);
@ -176,15 +224,15 @@ async function shareWhenForwarded(port, name, timeoutMs = 120_000) {
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.`);
log(`Would ssh to ${cs.name} and run:\n ${command}`);
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}…`);
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.');
@ -205,14 +253,14 @@ async function up(pr, options) {
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)}`);
log(`Would create a box for PR #${pr} (${head.headRefName}):`);
log(` gh ${createArgs(pr, head).join(' ')}`);
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.');
log('The URL is only known once the box exists.');
return;
}
console.log(`Creating a preview box for PR #${pr} (${head.headRefName})…`);
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));
@ -222,7 +270,7 @@ async function up(pr, options) {
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)…`);
log(`${cs.name} is ${cs.state} — ssh will start it (~30-60 s)…`);
}
await serve(pr, cs, head, options);
@ -237,16 +285,16 @@ async function refresh(pr, options) {
function down(pr, { dryRun }) {
const cs = findPreview(pr);
if (!cs) {
console.log(`No preview box for PR #${pr}.`);
log(`No preview box for PR #${pr}.`);
return;
}
if (dryRun) {
console.log(`Would delete ${cs.name} (PR #${pr}).`);
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})`);
log(`Deleted ${cs.name} (PR #${pr})`);
}
function ls() {
@ -259,30 +307,32 @@ function ls() {
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.
* */
* 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 ?? '')
const noPrSpecified = !/^\d+$/.test(pr ?? '');
if (pr && noPrSpecified) {
fail(`Invalid PR number format. Usage example: \`pnpm preview ${cmd} 1234\``)
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;
}
// `gh pr view` exits non-zero when the branch has no PR, which is not an
// error here: fall through to the message below instead of a stack trace.
try {
const prInfoForCurrentBranch = ghJson(['pr', 'view', '--json', 'number']);
if (prInfoForCurrentBranch && Number.isInteger(prInfoForCurrentBranch.number)) {
return prInfoForCurrentBranch.number;
}
} catch {}
}
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}\`.`)
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;
}

View File

@ -31,6 +31,13 @@ export async function waitForHealth(port, healthPath, timeoutMs = 120_000, inter
return false;
}
// `/healthz` answers ok as soon as the process listens: it checks neither the
// database nor the routes. n8n unblocks `/healthz/readiness` from markAsReady(),
// after the migrations run and the controllers mount, so it is the only safe gate
// for anything that then calls the REST API.
export const waitForReady = (port, healthPath, timeoutMs = 180_000, intervalMs = 3000) =>
waitForHealth(port, `${healthPath}/readiness`, timeoutMs, intervalMs);
// 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.

View File

@ -6,7 +6,14 @@ 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';
import {
reportUp,
serveHealthPath,
servePort,
serveUrl,
waitForHealth,
waitForReady,
} from './serve-ready.mjs';
const NAME = 'psychic-umbrella-wqj9pvw9p939vp6';
const DOMAIN = 'app.github.dev';
@ -141,3 +148,38 @@ describe('reportUp', () => {
assert.match(lines[1], /the box name did not resolve, so 5678 was not shared/);
});
});
describe('waitForReady', () => {
/** Records the paths requested, and answers 200 only on /healthz/readiness. */
function readinessServer() {
const paths = [];
const server = createServer((req, res) => {
paths.push(req.url);
res.writeHead(req.url === '/healthz/readiness' ? 200 : 503).end('');
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port, paths }));
});
}
it('probes the readiness path, not the health path', async () => {
const { server, port, paths } = await readinessServer();
try {
assert.equal(await waitForReady(port, '/healthz', 5000, 10), true);
assert.deepEqual(paths, ['/healthz/readiness']);
} finally {
server.close();
}
});
// A backend that has migrated but not mounted its controllers answers 503 here,
// while /healthz already answers ok. Seeding then races the REST routes.
it('does not accept the 503 a booting backend serves', async () => {
const { server, port } = await healthServer(503);
try {
assert.equal(await waitForReady(port, '/healthz', 60, 10), false);
} finally {
server.close();
}
});
});