mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
90d176b969
commit
eeec778a88
|
|
@ -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
111
.github/WORKFLOWS.md
vendored
|
|
@ -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
237
.github/scripts/codespace-preview.mjs
vendored
Normal 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();
|
||||
}
|
||||
162
.github/scripts/codespace-preview.test.mjs
vendored
Normal file
162
.github/scripts/codespace-preview.test.mjs
vendored
Normal 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/);
|
||||
});
|
||||
});
|
||||
2
.github/workflows/ci-pull-requests.yml
vendored
2
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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/**
|
||||
|
|
|
|||
86
.github/workflows/util-codespace-preview.yml
vendored
Normal file
86
.github/workflows/util-codespace-preview.yml
vendored
Normal 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
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
71
scripts/preview-labels.mjs
Normal file
71
scripts/preview-labels.mjs
Normal 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 };
|
||||
}
|
||||
79
scripts/preview-labels.test.mjs
Normal file
79
scripts/preview-labels.test.mjs
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user