diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index 2765d056e75..7cd5b5fcc4e 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -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-`. + +**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 ` does the same thing, plus +`pnpm preview refresh `, `pnpm preview down ` and `pnpm preview ls`. It +needs `gh` with the codespace scope, the same as `pnpm session`. + +- **Sign in with one click** at `/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 ` 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. diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index e6579124c75..f5e3b6e2c0d 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -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-`) 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//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 `). +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 diff --git a/.github/scripts/codespace-preview.mjs b/.github/scripts/codespace-preview.mjs new file mode 100644 index 00000000000..1e7a32d01a5 --- /dev/null +++ b/.github/scripts/codespace-preview.mjs @@ -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 = ''; +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-\t\t\tlast used `. 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://-.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(); +} diff --git a/.github/scripts/codespace-preview.test.mjs b/.github/scripts/codespace-preview.test.mjs new file mode 100644 index 00000000000..296851508fb --- /dev/null +++ b/.github/scripts/codespace-preview.test.mjs @@ -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/); + }); +}); diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index f4bb6bef39b..eaf7dc3c92e 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -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/** diff --git a/.github/workflows/util-codespace-preview.yml b/.github/workflows/util-codespace-preview.yml new file mode 100644 index 00000000000..45bbece60a8 --- /dev/null +++ b/.github/workflows/util-codespace-preview.yml @@ -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 `` 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 diff --git a/scripts/codespace-env.mjs b/scripts/codespace-env.mjs index 52574f44114..62cc7aba671 100644 --- a/scripts/codespace-env.mjs +++ b/scripts/codespace-env.mjs @@ -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) => diff --git a/scripts/codespace-env.test.mjs b/scripts/codespace-env.test.mjs index 3955f0aaffa..92950e0c910 100644 --- a/scripts/codespace-env.test.mjs +++ b/scripts/codespace-env.test.mjs @@ -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); + }); +}); diff --git a/scripts/preview-labels.mjs b/scripts/preview-labels.mjs new file mode 100644 index 00000000000..15e2ef083d0 --- /dev/null +++ b/scripts/preview-labels.mjs @@ -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 }; +} diff --git a/scripts/preview-labels.test.mjs b/scripts/preview-labels.test.mjs new file mode 100644 index 00000000000..70de69cf63d --- /dev/null +++ b/scripts/preview-labels.test.mjs @@ -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: [] }); + }); +}); diff --git a/scripts/preview-serve.mjs b/scripts/preview-serve.mjs index 22f9ea41c09..53702f7b7e5 100644 --- a/scripts/preview-serve.mjs +++ b/scripts/preview-serve.mjs @@ -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}`); diff --git a/scripts/preview.mjs b/scripts/preview.mjs index d1d547034c5..a66cc2fc700 100644 --- a/scripts/preview.mjs +++ b/scripts/preview.mjs @@ -8,11 +8,12 @@ // pnpm preview down 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; } diff --git a/scripts/serve-ready.mjs b/scripts/serve-ready.mjs index 8a9f2287196..d9cb633961d 100644 --- a/scripts/serve-ready.mjs +++ b/scripts/serve-ready.mjs @@ -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. diff --git a/scripts/serve-ready.test.mjs b/scripts/serve-ready.test.mjs index f311639b924..042b5959257 100644 --- a/scripts/serve-ready.test.mjs +++ b/scripts/serve-ready.test.mjs @@ -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(); + } + }); +});