@impeccable/reviews 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cloudflare-access.js +36 -17
- package/dist/cloudflare.js +9 -4
- package/dist/host-questions.js +17 -2
- package/dist/hosted.js +88 -27
- package/dist/main.js +1 -0
- package/dist/netlify-password.js +10 -14
- package/dist/netlify.js +8 -2
- package/dist/playbook/cases/cloudflare-previews.md +3 -3
- package/dist/playbook/cases/first-preview-failure.md +0 -1
- package/dist/playbook/cases/live-app-address.md +1 -1
- package/dist/playbook/cases/monorepo.md +5 -6
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +8 -7
- package/dist/playbook/cases/pull-requests.md +4 -5
- package/dist/playbook/cases/server-previews.md +3 -3
- package/dist/playbook/cases/vercel-previews.md +3 -3
- package/dist/playbook/cases/vercel-setup.md +2 -2
- package/dist/playbook.js +4 -4
- package/dist/sandbox.js +1 -2
- package/dist/tools.js +182 -42
- package/dist/ui/look.js +5 -5
- package/dist/ui/run-state.js +0 -5
- package/dist/ui/screens.js +2 -2
- package/dist/vercel.js +4 -2
- package/package.json +1 -1
- package/dist/playbook/cases/static-site.md +0 -16
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npx @impeccable/reviews
|
|
|
15
15
|
|
|
16
16
|
`npm install -g @impeccable/reviews` puts the `impeccable-reviews` command on your PATH instead.
|
|
17
17
|
|
|
18
|
-
Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
|
|
18
|
+
Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the account or team and the project or site it used on each host whose preview it opened, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
|
|
19
19
|
|
|
20
20
|
Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (every question can be skipped); on the run screen, Esc twice (or Ctrl+C) stops the run. A screen taller than the terminal scrolls with ↑↓ and PgUp PgDn (↑↓ still move among a question's choices). The screens are black and white, with emphasis by intensity: greys on a terminal that takes true colour and reports its background, bold and dim elsewhere, and under `NO_COLOR`. The mark at the top is the logo's own image in a terminal that shows images (iTerm2, kitty, Ghostty, WezTerm; never under tmux), a square with its slash beside the letter-spaced wordmark elsewhere, and plain text on a dumb terminal.
|
|
21
21
|
|
|
@@ -104,9 +104,9 @@ const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.ever
|
|
|
104
104
|
* a retry, and two failed retries end it. The new sign-in asks for the scopes it held as well, so whatever else the
|
|
105
105
|
* person uses cf for goes on working.
|
|
106
106
|
*/
|
|
107
|
-
async function reach(deps) {
|
|
107
|
+
async function reach(deps, read) {
|
|
108
108
|
const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
|
|
109
|
-
let status =
|
|
109
|
+
let status = read;
|
|
110
110
|
if (granted(status))
|
|
111
111
|
return through(status);
|
|
112
112
|
if (status.cli === 'missing' || status.cli === 'unknown')
|
|
@@ -196,6 +196,8 @@ async function covering(way, url) {
|
|
|
196
196
|
return unread ? stop(way, unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
|
|
197
197
|
return { apps };
|
|
198
198
|
}
|
|
199
|
+
/** Why no token is made where several applications cover the address. */
|
|
200
|
+
const unclear = (count, host) => `${count} Access applications cover ${host}, so which one's policy should allow the token is not clear.`;
|
|
199
201
|
/**
|
|
200
202
|
* As the person, the way they reach Cloudflare's API: on the one application in front of the address, the service
|
|
201
203
|
* token, then a Service Auth policy that allows it. An application on reusable policies (every one the dashboard makes
|
|
@@ -208,7 +210,7 @@ async function create(way, deps, found) {
|
|
|
208
210
|
const api = way.call;
|
|
209
211
|
const url = new URL(deps.url);
|
|
210
212
|
if (found.length > 1)
|
|
211
|
-
return { reason:
|
|
213
|
+
return { reason: unclear(found.length, url.host) };
|
|
212
214
|
const { account, app } = found[0];
|
|
213
215
|
const base = `/accounts/${account}/access`;
|
|
214
216
|
let links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
|
|
@@ -313,10 +315,6 @@ export async function passCloudflareAccess(deps) {
|
|
|
313
315
|
covered ||= 'apps' in found;
|
|
314
316
|
return found;
|
|
315
317
|
};
|
|
316
|
-
const build = async (way) => {
|
|
317
|
-
const found = await find(way);
|
|
318
|
-
return 'apps' in found ? create(way, deps, found.apps) : found;
|
|
319
|
-
};
|
|
320
318
|
/** A token that opened the address, saved for reviews. */
|
|
321
319
|
const keep = async (token, how, made = {}) => {
|
|
322
320
|
const failed = await deps.save(token);
|
|
@@ -325,16 +323,37 @@ export async function passCloudflareAccess(deps) {
|
|
|
325
323
|
/** Why the run's own way ended without a token that opens the address, for the paste screen's skip, and how to make one by hand there. */
|
|
326
324
|
let stopped;
|
|
327
325
|
let manual;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
// cf signed in here, with whatever scopes, reads the Access application in front of the address before anything is asked, since reading takes no write
|
|
327
|
+
// (#357): none covering it asks nothing, and the consent names the one that does. Signed out, or refused the read, the consent comes first, as its yes opens
|
|
328
|
+
// the sign-in or the API token screen that finding it takes.
|
|
329
|
+
const status = await cfStatus(deps.cf, deps.signal);
|
|
330
|
+
const reads = status.cli === 'signed-in' && (!status.scopes || status.scopes.some((scope) => /^access\.(read|write)$/.test(scope)));
|
|
331
|
+
const early = reads ? await find(throughCf(deps.cf, status.accounts, deps.signal)) : undefined;
|
|
332
|
+
const apps = early && 'apps' in early ? early.apps : undefined;
|
|
333
|
+
const named = apps?.length === 1 ? apps[0] : undefined;
|
|
334
|
+
/** An application as the consent names it. */
|
|
335
|
+
const described = ({ account, app }) => ({ id: String(app.id), application: typeof app.name === 'string' && app.name ? app.name : url.host, account });
|
|
336
|
+
const SKIPPED = 'The person skipped creating a service token.';
|
|
337
|
+
if (early && 'reason' in early)
|
|
338
|
+
stopped = early.reason;
|
|
339
|
+
else if (apps && apps.length > 1)
|
|
340
|
+
stopped = unclear(apps.length, url.host);
|
|
341
|
+
else if (!(await deps.consent(named && described(named))))
|
|
342
|
+
stopped = SKIPPED;
|
|
335
343
|
else {
|
|
336
|
-
|
|
337
|
-
|
|
344
|
+
/** Read anew, by a new sign-in or the API token: an application other than the one the yes named is asked about before anything is made on it. */
|
|
345
|
+
const rebuild = async (way) => {
|
|
346
|
+
const found = await find(way);
|
|
347
|
+
if (!('apps' in found))
|
|
348
|
+
return found;
|
|
349
|
+
const target = found.apps.length === 1 ? found.apps[0] : undefined;
|
|
350
|
+
if (named && target && (String(target.app.id) !== String(named.app.id) || target.account !== named.account) && !(await deps.consent(described(target))))
|
|
351
|
+
return { reason: SKIPPED };
|
|
352
|
+
return create(way, deps, found.apps);
|
|
353
|
+
};
|
|
354
|
+
const reached = await reach(deps, status);
|
|
355
|
+
// The sign-in that read the applications makes the token on the one it found, or answers the same refusal, without reading them again; a new sign-in reads its accounts.
|
|
356
|
+
let made = 'way' in reached ? (early && granted(status) ? ('apps' in early ? await create(reached.way, deps, early.apps) : early) : await rebuild(reached.way)) : 'stopped' in reached ? { reason: reached.stopped } : reached;
|
|
338
357
|
let apiToken = false;
|
|
339
358
|
if ('fallback' in made) {
|
|
340
359
|
// Where cf's sign-in cannot make the token, an API token the person makes for the run can.
|
|
@@ -344,7 +363,7 @@ export async function passCloudflareAccess(deps) {
|
|
|
344
363
|
else {
|
|
345
364
|
deps.remember(key);
|
|
346
365
|
apiToken = true;
|
|
347
|
-
made = await
|
|
366
|
+
made = await rebuild(withApiToken(key, deps));
|
|
348
367
|
}
|
|
349
368
|
}
|
|
350
369
|
if (!('token' in made)) {
|
package/dist/cloudflare.js
CHANGED
|
@@ -306,8 +306,9 @@ export async function cfStatus(cf, signal) {
|
|
|
306
306
|
if (status?.authenticated !== true)
|
|
307
307
|
return { cli: 'unknown', accounts: [] };
|
|
308
308
|
const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
|
|
309
|
-
const
|
|
310
|
-
|
|
309
|
+
const listed = list(status.accounts).filter((a) => typeof a.id === 'string' && /^[0-9a-f]{32}$/.test(a.id));
|
|
310
|
+
const names = Object.fromEntries(listed.flatMap((a) => (typeof a.name === 'string' && a.name && a.name.length <= 100 ? [[a.id, a.name]] : [])));
|
|
311
|
+
return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts: listed.map((a) => a.id), ...(Object.keys(names).length ? { names } : {}) };
|
|
311
312
|
}
|
|
312
313
|
/** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
|
|
313
314
|
const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
|
|
@@ -322,7 +323,7 @@ const PAGES = 4;
|
|
|
322
323
|
* built from another repository is not this one's.
|
|
323
324
|
*/
|
|
324
325
|
async function fromCf(named, cf, repository, settings, signal) {
|
|
325
|
-
const { cli, scopes, accounts } = await cfStatus(cf, signal);
|
|
326
|
+
const { cli, scopes, accounts, names } = await cfStatus(cf, signal);
|
|
326
327
|
const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
|
|
327
328
|
if (cli !== 'signed-in' || !pages)
|
|
328
329
|
return { cli, rows: [], failed: cli === 'unknown' };
|
|
@@ -361,7 +362,8 @@ async function fromCf(named, cf, repository, settings, signal) {
|
|
|
361
362
|
subdomain = typeof ours.project.subdomain === 'string' && new RegExp(`^${LABEL}\\.pages\\.dev$`).test(ours.project.subdomain) ? ours.project.subdomain : undefined;
|
|
362
363
|
}
|
|
363
364
|
}
|
|
364
|
-
const
|
|
365
|
+
const accountName = account ? names?.[account] : undefined;
|
|
366
|
+
const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(accountName ? { accountName } : {}), ...(subdomain ? { subdomain } : {}) };
|
|
365
367
|
if (!project)
|
|
366
368
|
return { cli, rows: [], failed: false, ...settled };
|
|
367
369
|
const ours = (d) => {
|
|
@@ -450,6 +452,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
450
452
|
: cf.cli !== 'signed-in' ? `Which Cloudflare Pages project builds ${where} cannot be told: cf here is not signed in with Pages access, and only a project's settings name the folder it builds (its root directory).`
|
|
451
453
|
: (cf.matching?.length ?? 0) > 1 ? `Several Cloudflare Pages projects build ${where} (${cf.matching.join(', ')}), so which one is this app's cannot be told.`
|
|
452
454
|
: !cf.project ? `No Cloudflare Pages project of this repository builds ${where} (its root directory), as far as cf here can see.` : undefined;
|
|
455
|
+
// The name of the account the project was found in goes to the last screen alone, never into the evidence (#360).
|
|
456
|
+
if (cf.accountName && !unmatched)
|
|
457
|
+
deps.onAccountName?.(cf.accountName);
|
|
453
458
|
if (unmatched)
|
|
454
459
|
github.previews = [];
|
|
455
460
|
// A preview's address names the project's own host, which a taken name suffixes (crewline-x1y.pages.dev), not always its name.
|
package/dist/host-questions.js
CHANGED
|
@@ -81,6 +81,10 @@ const previewsOff = (id, name, where) => ({
|
|
|
81
81
|
skipLabel: 'Skip',
|
|
82
82
|
},
|
|
83
83
|
});
|
|
84
|
+
/** The consent to a Vercel bypass secret, saying whose previews Vercel's login keeps and where the secret is revoked. */
|
|
85
|
+
const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection.`;
|
|
86
|
+
/** The consent to a Cloudflare service token, saying which Access application keeps whose previews, and whose policy allows the token. */
|
|
87
|
+
const accessHint = (keeps, policy) => `Impeccable reviews each pull request on its preview, and ${keeps} behind a login.\n\nCloudflare's way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in ${policy} policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.`;
|
|
84
88
|
/** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one, and to making a site behind Netlify's team login public. */
|
|
85
89
|
export const HOST_SCREENS = {
|
|
86
90
|
'netlify-signin-retry': { id: 'netlify-signin-retry', kind: 'confirm', title: 'Sign in to Netlify', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
|
|
@@ -138,7 +142,7 @@ export const HOST_SCREENS = {
|
|
|
138
142
|
id: 'vercel-bypass',
|
|
139
143
|
kind: 'confirm',
|
|
140
144
|
title: 'Your Vercel previews are protected',
|
|
141
|
-
hint: '
|
|
145
|
+
hint: bypassHint('yours', "the project's"),
|
|
142
146
|
prompt: 'Create the secret for Impeccable?',
|
|
143
147
|
confirmLabel: 'Create',
|
|
144
148
|
skipLabel: 'Skip',
|
|
@@ -193,7 +197,7 @@ export const HOST_SCREENS = {
|
|
|
193
197
|
id: 'cloudflare-access',
|
|
194
198
|
kind: 'confirm',
|
|
195
199
|
title: 'Your Cloudflare previews are protected',
|
|
196
|
-
hint: '
|
|
200
|
+
hint: accessHint('Cloudflare Access keeps yours', "your Access application's"),
|
|
197
201
|
prompt: 'Create the token for Impeccable?',
|
|
198
202
|
confirmLabel: 'Create',
|
|
199
203
|
skipLabel: 'Skip',
|
|
@@ -224,6 +228,17 @@ export function sentTo(screen, url) {
|
|
|
224
228
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
225
229
|
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
226
230
|
}
|
|
231
|
+
/** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
|
|
232
|
+
export function vercelBypassConsent(project, app) {
|
|
233
|
+
const screen = HOST_SCREENS['vercel-bypass'];
|
|
234
|
+
if (!project)
|
|
235
|
+
return screen;
|
|
236
|
+
return { ...screen, hint: bypassHint(app ? `${app}'s previews, built by the Vercel project ${project},` : `the previews of the Vercel project ${project}`, `${project}'s`) };
|
|
237
|
+
}
|
|
238
|
+
/** The consent to a service token, asked once the Access application in front of the preview was found (#357): that application, its Cloudflare account, and whose previews at which host it keeps. */
|
|
239
|
+
export function cloudflareAccessConsent(found, host, app) {
|
|
240
|
+
return { ...HOST_SCREENS['cloudflare-access'], hint: accessHint(`the Access application "${found.application}", in the Cloudflare account ${found.account}, keeps ${app ? `${app}'s previews` : 'yours'} (${host})`, `"${found.application}"'s`) };
|
|
241
|
+
}
|
|
227
242
|
/** The one consent to making the Access application's own policies reusable, which the run's way through cf needs to add the token's policy (#327): the application named. */
|
|
228
243
|
export function cloudflareAccessReusable(application) {
|
|
229
244
|
return {
|
package/dist/hosted.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
|
|
4
|
-
import { basicCredentials } from './netlify-password.js';
|
|
5
4
|
import { gitEnvironment } from './sandbox.js';
|
|
6
5
|
import { BYPASS_HEADER } from './vercel-bypass.js';
|
|
7
6
|
/**
|
|
@@ -138,6 +137,24 @@ export function netlifySiteOf(url) {
|
|
|
138
137
|
const { hostname } = new URL(url);
|
|
139
138
|
return NETLIFY.exec(hostname)?.[1] ?? LIVE.netlify.exec(hostname)?.[1] ?? null;
|
|
140
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The project and team a Vercel deployment's own address names, `<project>-<hash>-<team>.vercel.app` (Vercel's generated
|
|
142
|
+
* address, its hash nine letters and digits), for the last screen (#360): only where one split fits, and that split's
|
|
143
|
+
* project is `project` where a host tool named it; null otherwise, a project's own address included, and any address
|
|
144
|
+
* with `-git-` in it, which a branch's alias `<project>-git-<branch>-<team>` has, so a branch's word is never a hash.
|
|
145
|
+
*/
|
|
146
|
+
export function vercelDeploymentOf(url, project) {
|
|
147
|
+
if (!URL.canParse(url))
|
|
148
|
+
return null;
|
|
149
|
+
const parsed = new URL(url);
|
|
150
|
+
if (!plainHttps(parsed) || !LIVE.vercel.test(parsed.hostname) || parsed.hostname.includes('-git-'))
|
|
151
|
+
return null;
|
|
152
|
+
const parts = parsed.hostname.slice(0, -'.vercel.app'.length).split('-');
|
|
153
|
+
const splits = parts
|
|
154
|
+
.flatMap((part, i) => (i > 0 && i < parts.length - 1 && /^[a-z0-9]{9}$/.test(part) ? [{ project: parts.slice(0, i).join('-'), team: parts.slice(i + 1).join('-') }] : []))
|
|
155
|
+
.filter((split) => project === undefined || split.project === project.toLowerCase());
|
|
156
|
+
return splits.length === 1 ? splits[0] : null;
|
|
157
|
+
}
|
|
141
158
|
/** The address pattern a proven preview stands for, where the host reports none to GitHub: Netlify's by pull request, Cloudflare's by the branch's alias. A live address stands for none. */
|
|
142
159
|
function patternOf(hostname) {
|
|
143
160
|
const site = NETLIFY.exec(hostname)?.[1];
|
|
@@ -169,17 +186,56 @@ async function checkoutHolds(root, words) {
|
|
|
169
186
|
return false;
|
|
170
187
|
}
|
|
171
188
|
}
|
|
172
|
-
|
|
189
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
190
|
+
/** The address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
|
|
191
|
+
const wholeOrigin = (origin) => `(^|[^A-Za-z0-9.-])${escapeRegExp(origin)}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`;
|
|
192
|
+
/** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin, whole. */
|
|
173
193
|
export async function checkoutNames(root, origin, folder = '') {
|
|
174
|
-
const escaped = origin.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
175
194
|
try {
|
|
176
|
-
await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e',
|
|
195
|
+
await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
|
|
177
196
|
return true;
|
|
178
197
|
}
|
|
179
198
|
catch {
|
|
180
199
|
return false;
|
|
181
200
|
}
|
|
182
201
|
}
|
|
202
|
+
/** Whether a line names an app's name or folder whole, not inside a longer name, a host name or a path, such as `apps/web` in `archived/apps/web`. */
|
|
203
|
+
const namesWhole = (line, words) => new RegExp(`(^|[^\\p{L}\\p{N}_./-])${escapeRegExp(words)}(?![\\p{L}\\p{N}_-]|[./][\\p{L}\\p{N}])`, 'iu').test(line);
|
|
204
|
+
/**
|
|
205
|
+
* Whether a line of the checkout's root README names the address's origin, whole, beside one app, by its name or its
|
|
206
|
+
* folder, and no other app: a row of a table of the apps' live addresses, or an item of such a list (#367). The
|
|
207
|
+
* addresses on the line are left out of the match, so a host name made of an app's name names no app.
|
|
208
|
+
*/
|
|
209
|
+
export async function readmeNamesApp(root, origin, app, others) {
|
|
210
|
+
let lines;
|
|
211
|
+
try {
|
|
212
|
+
lines = (await execFileAsync('git', ['grep', '-h', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', ':(glob,icase)README*'], { cwd: root, timeout: 30_000, env: gitEnvironment() })).stdout;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
// Each name or folder with every app it belongs to, so a name two apps share names both; the longest first, each taken
|
|
218
|
+
// out once found, so "Web" inside "Web admin" is not that app named.
|
|
219
|
+
const names = new Map();
|
|
220
|
+
for (const each of [app, ...others])
|
|
221
|
+
for (const words of [each.name, each.folder])
|
|
222
|
+
if (words)
|
|
223
|
+
names.set(words.toLowerCase(), [...(names.get(words.toLowerCase()) ?? []), each]);
|
|
224
|
+
const longestFirst = [...names].sort(([x], [y]) => y.length - x.length);
|
|
225
|
+
return lines.split('\n').some((line) => {
|
|
226
|
+
// The addresses end where a table cell or a tag does; a path's parentheses, and a link's closing one, go with them.
|
|
227
|
+
let text = line.replace(/https?:\/\/[^\s|<>[\]"'`]+/gi, ' ');
|
|
228
|
+
const named = new Set();
|
|
229
|
+
for (const [words, apps] of longestFirst) {
|
|
230
|
+
if (!namesWhole(text, words))
|
|
231
|
+
continue;
|
|
232
|
+
for (const each of apps)
|
|
233
|
+
named.add(each);
|
|
234
|
+
text = text.replace(new RegExp(escapeRegExp(words), 'giu'), ' ');
|
|
235
|
+
}
|
|
236
|
+
return named.size === 1 && named.has(app);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
183
239
|
/** What a visitor never sees of a page: comments, scripts, styles and templates. */
|
|
184
240
|
const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
185
241
|
/** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
|
|
@@ -223,36 +279,43 @@ async function pageText(res, max = 1_000_000) {
|
|
|
223
279
|
}
|
|
224
280
|
return Buffer.concat(chunks).toString('utf8');
|
|
225
281
|
}
|
|
282
|
+
/** Whether a page is a password form and nothing else: one form whose only field a person fills is a password, as Netlify's lock is. */
|
|
283
|
+
function passwordForm(html) {
|
|
284
|
+
const seen = html.replace(UNSEEN, ' ');
|
|
285
|
+
const fields = [...seen.matchAll(/<input\b[^>]*>/gi)].map(([tag]) => /\stype\s*=\s*["']?([\w-]+)/i.exec(tag)?.[1]?.toLowerCase() ?? 'text').filter((type) => !['hidden', 'submit', 'button', 'reset', 'image'].includes(type));
|
|
286
|
+
return (seen.match(/<form\b/gi) ?? []).length === 1 && fields.join() === 'password';
|
|
287
|
+
}
|
|
226
288
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
289
|
+
* Signs in with Netlify's site password the way its form does, and the way reviews do (pristine's site-password.ts):
|
|
290
|
+
* a stranger's knock on the address's own origin, then, only where the answer is the lock's password form, the
|
|
291
|
+
* password posted there as its one field. Netlify answers a redirect back with a cookie for that host alone, which
|
|
292
|
+
* opens every page of it; a wrong password gets the form again and no cookie. The session's cookies, as a request
|
|
293
|
+
* sends them; empty where nothing stood in front or the form did not take the password.
|
|
230
294
|
*/
|
|
231
|
-
async function
|
|
295
|
+
async function siteSession(target, password, open, signal) {
|
|
296
|
+
const front = new URL('/', target.origin);
|
|
232
297
|
try {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return false;
|
|
238
|
-
const res = await open(target, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', cookie: cookies.join('; ') } });
|
|
239
|
-
if (res.status < 200 || res.status >= 300) {
|
|
240
|
-
await res.body?.cancel().catch(() => { });
|
|
241
|
-
return false;
|
|
298
|
+
const knock = await open(front, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html' } });
|
|
299
|
+
if ((knock.status !== 401 && knock.status !== 403) || !passwordForm(await pageText(knock, 64_000))) {
|
|
300
|
+
await knock.body?.cancel().catch(() => { });
|
|
301
|
+
return '';
|
|
242
302
|
}
|
|
243
|
-
|
|
303
|
+
const posted = await open(front, { method: 'POST', redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password }).toString() });
|
|
304
|
+
await posted.body?.cancel().catch(() => { });
|
|
305
|
+
if (posted.status >= 400)
|
|
306
|
+
return '';
|
|
307
|
+
return posted.headers.getSetCookie().map((cookie) => cookie.split(';')[0].trim()).filter((pair) => /^[^\s;,=]+=[^\s;,]*$/.test(pair)).join('; ');
|
|
244
308
|
}
|
|
245
309
|
catch {
|
|
246
|
-
return
|
|
310
|
+
return '';
|
|
247
311
|
}
|
|
248
312
|
}
|
|
249
313
|
/**
|
|
250
314
|
* Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
|
|
251
315
|
* `words` that a file of the checkout at `root` holds, so the page is this app's and not another site's. With `bypass`, a
|
|
252
316
|
* Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
|
|
253
|
-
* `password`, a Netlify site password, a Netlify address is opened with it
|
|
254
|
-
*
|
|
255
|
-
* the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
|
|
317
|
+
* `password`, a Netlify site password, a Netlify address is opened signed in with it on Netlify's own form, as reviews
|
|
318
|
+
* sign in, the password posted only where the address's origin answers with that form; with `access`, a
|
|
256
319
|
* Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
|
|
257
320
|
* to another host. With `live`, the address is a live app's rather than a preview's: any https address is opened, a
|
|
258
321
|
* host's lock is told by its domain, or by the host named in `live` for a custom domain the host tool answered as the
|
|
@@ -276,10 +339,12 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
276
339
|
const opened = deps.bypass && host === 'vercel';
|
|
277
340
|
const withPassword = deps.password !== undefined && host === 'netlify';
|
|
278
341
|
const withToken = deps.access !== undefined && host === 'cloudflare';
|
|
342
|
+
// Behind Netlify's site password: the session its form gives, carried on every hop of the address's own origin.
|
|
343
|
+
const session = withPassword ? await siteSession(new URL(url), deps.password, open, signal) : '';
|
|
279
344
|
const headers = {
|
|
280
345
|
accept: 'text/html',
|
|
281
346
|
...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}),
|
|
282
|
-
...(
|
|
347
|
+
...(session ? { cookie: session } : {}),
|
|
283
348
|
...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
|
|
284
349
|
};
|
|
285
350
|
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
@@ -307,10 +372,6 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
307
372
|
const named = (host === 'netlify' && netlifyLock(res.headers.get('www-authenticate'), page)) || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
|
|
308
373
|
if (!gate || !named)
|
|
309
374
|
return unproven(res.status === 401 ? "The address asked for the app's own sign-in: try a page a visitor opens without signing in." : `The address answered ${res.status}.`, res.status);
|
|
310
|
-
// A password the header did not get past: Netlify's own form says whether it is the site's password at all.
|
|
311
|
-
if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
|
|
312
|
-
return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
|
|
313
|
-
}
|
|
314
375
|
return { ...unproven(`The address asked for ${lock}'s login or password${still}.`, res.status), gate, ...(host === 'netlify' && NETLIFY_TEAM_LOGIN.test(page) ? { teamLogin: true } : {}) };
|
|
315
376
|
}
|
|
316
377
|
if (res.status < 200 || res.status >= 300) {
|
package/dist/main.js
CHANGED
|
@@ -290,6 +290,7 @@ export async function main(argv) {
|
|
|
290
290
|
{ label: 'Web page', url: page },
|
|
291
291
|
],
|
|
292
292
|
todo: end.todo,
|
|
293
|
+
...(report.hosts?.length ? { used: report.hosts } : {}),
|
|
293
294
|
warnings: [...removed, ...left, ...(await unopened(Boolean(testPr))), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
|
|
294
295
|
}, hand);
|
|
295
296
|
// The web page has the report; when it missed it, or the run fell short of all set up, whose why it holds, the report stays here as well.
|
package/dist/netlify-password.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Getting past Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password"
|
|
3
|
-
* on credit-based Pro) the
|
|
4
|
-
*
|
|
3
|
+
* on credit-based Pro) the way Netlify takes it: on its own form, which answers a session cookie for the preview's host
|
|
4
|
+
* (impeccable-reviews#374); no request header carries it. Reviews sign in the same way, before a preview's first page,
|
|
5
|
+
* with the password saved as the lock's key (pristine's `protection: { method: "password" }`). The Netlify
|
|
5
6
|
* CLI on this machine, signed in as the person, reads the site's settings in the command's own process: what
|
|
6
7
|
* `netlify api` prints is those settings, the password among them, so the agent's shell never runs it (fence.ts), and
|
|
7
8
|
* no password goes back to the model. The person consents once, on the tool's screen, before the password is stored;
|
|
@@ -10,10 +11,6 @@
|
|
|
10
11
|
* Netlify's team login (a private site) has no way in for tools, so on the person's yes the run makes the site public
|
|
11
12
|
* through the CLI; where Netlify refuses, the person changes Visitor access on Netlify's page and the run tries again.
|
|
12
13
|
*/
|
|
13
|
-
/** The header reviews send the password in. */
|
|
14
|
-
export const PASSWORD_HEADER = 'Authorization';
|
|
15
|
-
/** The password as the header carries it: basic credentials with an empty username, since Netlify asks for none. */
|
|
16
|
-
export const basicCredentials = (password) => `Basic ${Buffer.from(`:${password}`).toString('base64')}`;
|
|
17
14
|
/** The CLI's read of a site by the address every deploy preview of it carries (`site_id` takes the site's default domain), an authenticated call of Netlify's API. */
|
|
18
15
|
export const readSiteCall = (site) => ['api', 'getSite', '--data', JSON.stringify({ site_id: `${site}.netlify.app` })];
|
|
19
16
|
/**
|
|
@@ -27,7 +24,7 @@ const record = (value) => (value !== null && typeof value === 'object' && !Array
|
|
|
27
24
|
/** A site's name and id as Netlify makes them. */
|
|
28
25
|
const NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
29
26
|
const ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
30
|
-
/** A password as the
|
|
27
|
+
/** A password as the form takes it: one line, of a length a person would type. */
|
|
31
28
|
const password = (value) => (typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value) ? value : null);
|
|
32
29
|
/** The site's protection, read through the CLI, which is what makes the site the person's; null when it could not say: signed out, no site of that name in a team of the person's, or a site read back under another name. */
|
|
33
30
|
export async function readSite(run, site) {
|
|
@@ -63,12 +60,11 @@ export async function passNetlifyLock(deps) {
|
|
|
63
60
|
const from = site ? { site: site.name } : {};
|
|
64
61
|
const finish = async (password, how) => {
|
|
65
62
|
deps.remember(password);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// locked: Netlify's lock stood all the same, and its form did not take the password either. Any other failure is the proof's to say: the form took it and the header did not, or the page past the lock is another, or did not answer.
|
|
63
|
+
// Netlify's form, which an app's own password form looks like, gets the password only on a site whose settings show one: the CLI read this one as the person's.
|
|
64
|
+
const proof = await deps.prove(password);
|
|
65
|
+
// locked: Netlify's lock stood all the same, so its form did not take the password. Any other failure is the proof's to say: the page past the lock is another, or did not answer.
|
|
70
66
|
if (!proof.proven)
|
|
71
|
-
return { reason: proof.reason ?? 'The address did not open with the password.', locked: proof.gate !== undefined
|
|
67
|
+
return { reason: proof.reason ?? 'The address did not open with the password.', locked: proof.gate !== undefined };
|
|
72
68
|
const failed = await deps.save(password);
|
|
73
69
|
return failed ? { status: 'save-failed', reason: failed, protection: 'password', ...from } : { status: 'saved', password: how, protection: 'password', ...from, value: password };
|
|
74
70
|
};
|
|
@@ -88,7 +84,7 @@ export async function passNetlifyLock(deps) {
|
|
|
88
84
|
return done;
|
|
89
85
|
// The site's own password, as Netlify gives it: nothing typed would be righter. Where the lock stood all the same, the page asks for the app's own sign-in past it,
|
|
90
86
|
// which a page a visitor opens gets past, or Netlify did not answer its form; else the proof's reason says what stood in the way.
|
|
91
|
-
return { status: 'refused', reason: done.locked ? "Netlify's own site password
|
|
87
|
+
return { status: 'refused', reason: done.locked ? "Netlify's own site password did not open the address on Netlify's form: the page asks for the app's own sign-in past the lock, or Netlify did not answer. Prove a page a visitor opens without signing in, and call this tool again." : done.reason, protection: 'password', ...from };
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
90
|
else {
|
|
@@ -133,7 +129,7 @@ async function openToVisitors(deps, site) {
|
|
|
133
129
|
return read;
|
|
134
130
|
// A site read as public, or one the CLI cannot read once the person changed it on Netlify's page: the preview says.
|
|
135
131
|
if (read ? !read.teamLogin : tries > 0) {
|
|
136
|
-
const proof = await deps.prove(undefined
|
|
132
|
+
const proof = await deps.prove(undefined);
|
|
137
133
|
if (proof.proven)
|
|
138
134
|
return { status: 'opened', madePublic: ranPublic ? 'run' : 'person', ...from };
|
|
139
135
|
// Past the login, a page that is not the app's: the proof says why.
|
package/dist/netlify.js
CHANGED
|
@@ -145,6 +145,7 @@ async function netlifySites(netlify) {
|
|
|
145
145
|
async function fromCli(owner, repo, netlify, only, directory) {
|
|
146
146
|
const { cli } = await netlifyStatus(netlify);
|
|
147
147
|
const addresses = new Map();
|
|
148
|
+
const teams = {};
|
|
148
149
|
if (cli !== 'signed-in')
|
|
149
150
|
return { cli, sites: [], addresses, previews: [], failed: cli === 'unknown' };
|
|
150
151
|
const slug = `${owner}/${repo}`.toLowerCase();
|
|
@@ -162,6 +163,8 @@ async function fromCli(owner, repo, netlify, only, directory) {
|
|
|
162
163
|
if (directory !== undefined && folderOf(record(site.build_settings)?.base) !== directory)
|
|
163
164
|
continue;
|
|
164
165
|
sites.push(site.name);
|
|
166
|
+
if (typeof site.account_name === 'string' && site.account_name && site.account_name.length <= 100)
|
|
167
|
+
teams[site.name] = site.account_name;
|
|
165
168
|
const fact = netlifyPreviewsFact(site);
|
|
166
169
|
if (fact)
|
|
167
170
|
facts.push(fact);
|
|
@@ -186,7 +189,7 @@ async function fromCli(owner, repo, netlify, only, directory) {
|
|
|
186
189
|
failed = true;
|
|
187
190
|
}
|
|
188
191
|
const pullRequestPreviews = pickPreviewsFact(facts);
|
|
189
|
-
return { cli: 'signed-in', sites, addresses, previews, failed, ...(pullRequestPreviews ? { pullRequestPreviews } : {}) };
|
|
192
|
+
return { cli: 'signed-in', sites, addresses, ...(Object.keys(teams).length ? { teams } : {}), previews, failed, ...(pullRequestPreviews ? { pullRequestPreviews } : {}) };
|
|
190
193
|
}
|
|
191
194
|
/** The site whose address `production` is: the newest preview's, else the first the evidence names. */
|
|
192
195
|
export const liveSite = (evidence) => evidence.previews[0]?.site ?? evidence.sites[0];
|
|
@@ -204,7 +207,10 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
|
|
|
204
207
|
: !cli.sites.length && !cli.failed ? `No Netlify site of this repository builds ${where} (its base directory), as far as the Netlify CLI here can see.` : undefined;
|
|
205
208
|
const github = deps.directory === undefined ? read : { ...read, previews: unmatched ? [] : read.previews.filter((p) => cli.sites.includes(p.site)) };
|
|
206
209
|
if (unmatched)
|
|
207
|
-
Object.assign(cli, { sites: [], previews: [], addresses: new Map(), pullRequestPreviews: undefined });
|
|
210
|
+
Object.assign(cli, { sites: [], previews: [], addresses: new Map(), teams: undefined, pullRequestPreviews: undefined });
|
|
211
|
+
// Each site's team goes to the last screen alone, never into the evidence (#360).
|
|
212
|
+
if (cli.teams)
|
|
213
|
+
deps.onTeams?.(cli.teams);
|
|
208
214
|
// A deploy of an older commit than its pull request's head says nothing of the head's own preview: a commit status, which names the head, outranks it, and a wait for that pull request's preview ignores it.
|
|
209
215
|
const older = (p) => p.from === 'netlify cli' && Boolean(p.sha) && github.heads.has(p.pullRequest) && github.heads.get(p.pullRequest) !== p.sha;
|
|
210
216
|
// Of a pull request's status and the CLI's deploy of the same commit the later change is the truer, a status winning a tie; an older commit's deploy comes next, a bot's comment, which names no commit and changes no state, last.
|
|
@@ -11,15 +11,15 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
11
11
|
|
|
12
12
|
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
|
|
13
13
|
- **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
14
|
-
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews
|
|
14
|
+
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Cloudflare address answered.
|
|
15
15
|
- **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
|
|
16
16
|
1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
|
|
17
17
|
2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
|
|
18
18
|
3. **A Worker, not a Pages project** (`worker` in the answer, and no `pullRequestPreviews`): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), which gives pull requests no preview, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
|
|
19
19
|
4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
|
|
20
|
-
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews
|
|
20
|
+
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty.
|
|
21
21
|
- **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
|
|
22
|
-
- **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews
|
|
22
|
+
- **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
23
23
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
|
|
24
24
|
- The evidence is read only: link, deploy and change nothing on Cloudflare beyond what impeccable_enable_previews and impeccable_cloudflare_connect do on the person's yes. The Cloudflare sign-ins are those tools' own, on the person's yes. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
|
|
25
25
|
- The report names the address that answered with this app under "What we found" (and the Worker, where Cloudflare builds one), and the pattern saved under "What was configured, and by whom", with the pull-request previews turned on when they were (`by` says whether the run or the person did), the Pages project created or connected (`created` says which; name its `subdomain` address) and the access given to Cloudflare's GitHub app, since the account changed. The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
|
|
@@ -22,7 +22,6 @@ A rerun: the setup pull request an earlier run opened has a preview job that fai
|
|
|
22
22
|
| setup | `runtime` in the state; correct `runtimeVersion`. Nothing was installed yet. |
|
|
23
23
|
| install | The lockfile and the package manager's pinned version. |
|
|
24
24
|
| prepare, build | The script's name, the package runner and the app's folder. |
|
|
25
|
-
| upload | The real build output and its entry page; correct `outputDir`. |
|
|
26
25
|
| seed | The migration or seed command and the throwaway database. |
|
|
27
26
|
| start | `startCommand`, `port`, `env` and readiness. |
|
|
28
27
|
| tunnel | A repository already on Impeccable's tunnel: the app refusing the forwarded Host, allowed in the framework's host check for previews only (Rails `config.hosts`, Django `ALLOWED_HOSTS`, Vite `preview.allowedHosts`), not Impeccable's own access gate: a 403 on the bare link alone is not a broken preview. |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Every run that saves a preview method, for the state's `before` item: the address of the live app, which every review then shows beside the change. The item reads `optional` until an address is saved, so the checkup never waits on it; the run sets it all the same, from what the host or the repository already says, and asks nobody: the host's own address of the connected project changes nothing of the person's.
|
|
3
|
+
Every run that saves a preview method, for the state's `before` item: the address of the live app, which every review then shows beside the change. The item reads `optional` until an address is saved, so the checkup never waits on it; the run sets it all the same, from what the host or the repository already says, and asks nobody: the host's own address of the connected project changes nothing of the person's.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|