@impeccable/reviews 0.1.33 → 0.1.35
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/dist/host-previews.js +3 -1
- package/dist/host-questions.js +37 -3
- package/dist/hosted.js +24 -3
- package/dist/main.js +9 -2
- package/dist/playbook/cases/vercel-previews.md +2 -1
- package/dist/playbook/cases/vercel-setup.md +5 -3
- package/dist/playbook.js +1 -1
- package/dist/preflight.js +58 -7
- package/dist/tools.js +205 -87
- package/dist/vercel.js +58 -1
- package/package.json +1 -1
package/dist/host-previews.js
CHANGED
|
@@ -9,7 +9,9 @@ import { readSiteCall } from './netlify-password.js';
|
|
|
9
9
|
* setting is ever changed: a project whose builds are stopped altogether
|
|
10
10
|
* (Netlify `stop_builds`, Cloudflare `deployments_enabled` false) is reported
|
|
11
11
|
* and left as it is. Vercel builds every branch once Git is connected, so its
|
|
12
|
-
* fact is read from vercel.json and the project (vercel.ts) and
|
|
12
|
+
* fact is read from vercel.json and the project (vercel.ts), and the one
|
|
13
|
+
* setting written is the project's deployment_status Events, without which
|
|
14
|
+
* Vercel records none of its deployments on GitHub.
|
|
13
15
|
*/
|
|
14
16
|
/** The branch the test drive pushes, whose build is the preview the setting must allow. */
|
|
15
17
|
export const TEST_DRIVE_BRANCH = 'impeccable/test-drive';
|
package/dist/host-questions.js
CHANGED
|
@@ -45,6 +45,15 @@ export const HOST_SIGNED_IN = {
|
|
|
45
45
|
confirmLabel: 'Set up',
|
|
46
46
|
},
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* The Vercel setup screen with its CLI signed in already as `user`: it names that account and where the new project goes,
|
|
50
|
+
* the team the run takes (`team`), the one the person picks next (`pick`) or, with no team, the account itself, so the yes
|
|
51
|
+
* is to that account and team (REN-1250).
|
|
52
|
+
*/
|
|
53
|
+
export function vercelSignedIn(user, where) {
|
|
54
|
+
const into = where.team ? `in the team ${where.team}` : where.pick ? 'in the team you pick next' : 'of that account';
|
|
55
|
+
return { ...HOST_SIGNED_IN['vercel-setup'], hint: `Impeccable reviews each pull request on a preview. Vercel can host those previews: the run sets them up with the Vercel CLI on this machine, signed in as ${user}, in a new project ${into}. What is live stays as it is.` };
|
|
56
|
+
}
|
|
48
57
|
/**
|
|
49
58
|
* A host's CLI that is not on this machine (#396), asked before anything installs it: the package, the version and the
|
|
50
59
|
* global install with npm. There is no Skip, since the host's setup cannot go on without its CLI: Esc is the person's own install.
|
|
@@ -137,6 +146,25 @@ export const HOST_SCREENS = {
|
|
|
137
146
|
...buildWait('render', 'Render', 'set Pull Request Previews to Automatic under the service\'s Previews settings'),
|
|
138
147
|
...previewsOff('netlify', 'Netlify', 'turn Deploy Previews on under Branches and deploy contexts → Configure'),
|
|
139
148
|
...previewsOff('cloudflare', 'Cloudflare', 'set Preview branch control to All non-Production branches under Builds & deployments'),
|
|
149
|
+
// Vercel builds the pushes and the pull requests, but with deployment_status Events off it tells GitHub nothing of them, and GitHub's record is where Impeccable finds them (REN-1248): the one consent to turning it on as the person, for a project this run did not create (the tool names the project in the prompt).
|
|
150
|
+
'vercel-previews': {
|
|
151
|
+
id: 'vercel-previews',
|
|
152
|
+
kind: 'confirm',
|
|
153
|
+
title: 'Vercel, but GitHub never hears of the previews',
|
|
154
|
+
hint: "Vercel builds this repository's pull requests, but the project's deployment_status Events setting is off, so GitHub gets no record of the previews, and that record is where Impeccable finds them. The run turns it on as you; nothing else on the project changes.",
|
|
155
|
+
prompt: 'Turn on deployment_status Events?',
|
|
156
|
+
confirmLabel: 'Turn on',
|
|
157
|
+
skipLabel: 'Skip',
|
|
158
|
+
},
|
|
159
|
+
'vercel-previews-retry': {
|
|
160
|
+
id: 'vercel-previews-retry',
|
|
161
|
+
kind: 'confirm',
|
|
162
|
+
title: 'Vercel, but GitHub never hears of the previews',
|
|
163
|
+
hint: 'deployment_status Events is still off. On the page that opened, turn it on under Git.',
|
|
164
|
+
prompt: 'Changed it?',
|
|
165
|
+
confirmLabel: 'Try again',
|
|
166
|
+
skipLabel: 'Skip',
|
|
167
|
+
},
|
|
140
168
|
// Render's consent is its one screen (renderConfirm): only the retry is its own.
|
|
141
169
|
'render-previews-retry': previewsOff('render', 'Render', "set Pull Request Previews to Automatic under the service's Previews settings")['render-previews-retry'],
|
|
142
170
|
// No Pages project builds the repository's pull requests: the one consent to connecting the repository to the project the checkout names, or to creating one for it where Cloudflare builds a Worker instead (the tool names the project in the prompt), and the sign-in cf needs for either.
|
|
@@ -292,9 +320,15 @@ export function vercelBypassConsent(project, app) {
|
|
|
292
320
|
return screen;
|
|
293
321
|
return { ...screen, hint: bypassHint(app ? `${app}'s previews, built by the Vercel project ${project},` : `the previews of the Vercel project ${project}`, `${project}'s`) };
|
|
294
322
|
}
|
|
295
|
-
/**
|
|
296
|
-
|
|
297
|
-
|
|
323
|
+
/**
|
|
324
|
+
* The consent to a service token, naming, once it was found (#357), the Access application in front of the preview, its
|
|
325
|
+
* Cloudflare account, and whose previews at which host it keeps; `why` first, where it replaces a token saved for those
|
|
326
|
+
* previews that stopped opening them (REN-1194).
|
|
327
|
+
*/
|
|
328
|
+
export function cloudflareAccessConsent(found, host, app, why) {
|
|
329
|
+
const screen = HOST_SCREENS['cloudflare-access'];
|
|
330
|
+
const hint = found ? accessHint(`the Access application "${found.application}", in the Cloudflare account ${found.account}, keeps ${app ? `${app}'s previews` : 'yours'} (${host})`, `"${found.application}"'s`) : screen.hint;
|
|
331
|
+
return { ...screen, hint: why ? `${why}\n\n${hint}` : hint };
|
|
298
332
|
}
|
|
299
333
|
/** 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. */
|
|
300
334
|
export function cloudflareAccessReusable(application) {
|
package/dist/hosted.js
CHANGED
|
@@ -15,6 +15,24 @@ const execFileAsync = promisify(execFile);
|
|
|
15
15
|
/** How many deployments, and how many pull requests, the evidence reads: the newest. */
|
|
16
16
|
const LIMIT = 20;
|
|
17
17
|
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
18
|
+
/** Vercel's commit statuses on a commit, the newest of each context its bot posted. */
|
|
19
|
+
function vercelStatusesOf(statuses, sha) {
|
|
20
|
+
const newest = new Map();
|
|
21
|
+
// GitHub lists a commit's statuses newest first.
|
|
22
|
+
for (const s of statuses.map(record)) {
|
|
23
|
+
const context = s?.context;
|
|
24
|
+
if (typeof context !== 'string' || !/^Vercel(?: – [A-Za-z0-9._-]{1,100})?$/.test(context) || record(s.creator)?.login !== 'vercel[bot]' || newest.has(context))
|
|
25
|
+
continue;
|
|
26
|
+
const said = typeof s.description === 'string' ? s.description.trim() : '';
|
|
27
|
+
// As pristine reads them: a skip says so ("Skipped", "Canceled by Ignored Build Step"); any other success is a completed build.
|
|
28
|
+
const state = s.state === 'pending' ? 'building' : s.state === 'failure' || s.state === 'error' ? 'failed' : s.state === 'success' && /^(Skipped|Canceled by Ignored Build Step)\b/i.test(said) ? 'skipped' : s.state === 'success' ? 'built' : null;
|
|
29
|
+
if (!state)
|
|
30
|
+
continue;
|
|
31
|
+
const target = typeof s.target_url === 'string' && /^https:\/\/vercel\.com\/[^\s?#]{1,300}$/.test(s.target_url) ? s.target_url : null;
|
|
32
|
+
newest.set(context, { sha, context, state, targetUrl: target });
|
|
33
|
+
}
|
|
34
|
+
return [...newest.values()];
|
|
35
|
+
}
|
|
18
36
|
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
19
37
|
const positive = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
20
38
|
/** An https address without credentials, a query or a fragment, as origin and path; null for anything else. */
|
|
@@ -40,11 +58,11 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
|
|
|
40
58
|
const runGh = deps.runGh ?? (async (args) => (await execFileAsync('gh', args, {
|
|
41
59
|
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: deps.signal, env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
42
60
|
})).stdout);
|
|
43
|
-
const list = async (route) => {
|
|
61
|
+
const list = async (route, limit = LIMIT) => {
|
|
44
62
|
const data = JSON.parse(await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
|
|
45
63
|
if (!Array.isArray(data))
|
|
46
64
|
throw new Error(`${route} is not a list`);
|
|
47
|
-
return data.slice(0,
|
|
65
|
+
return data.slice(0, limit);
|
|
48
66
|
};
|
|
49
67
|
const base = `/repos/${owner}/${repo}`;
|
|
50
68
|
try {
|
|
@@ -75,7 +93,10 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
|
|
|
75
93
|
pullRequests = numbers(await list(`${base}/commits/${d.sha}/pulls`).catch(() => ((unsaid = true), [])));
|
|
76
94
|
return { sha: d.sha, environment, state, url, vercel: record(d.creator)?.login === 'vercel[bot]', pullRequests };
|
|
77
95
|
}));
|
|
78
|
-
|
|
96
|
+
const kept = read.filter((d) => d !== null);
|
|
97
|
+
// A project with its deployments on GitHub turned off still posts its commit status: read for every branch, since another project's deployment of the head says nothing of this one's, and the wait keeps the project's own.
|
|
98
|
+
const statuses = typeof head === 'string' ? vercelStatusesOf(await list(`${base}/commits/${head}/statuses?per_page=100`, 100).catch(() => []), head) : [];
|
|
99
|
+
return { deployments: kept, ...(statuses.length ? { vercelStatuses: statuses } : {}), ...(unsaid ? { error: 'GitHub could not say which pull requests some previews belong to: read again.' } : {}) };
|
|
79
100
|
}
|
|
80
101
|
catch {
|
|
81
102
|
return unread;
|
package/dist/main.js
CHANGED
|
@@ -13,7 +13,7 @@ import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAu
|
|
|
13
13
|
import { checkoutJournal } from './leftovers.js';
|
|
14
14
|
import { createLog, UserError } from './log.js';
|
|
15
15
|
import { firstMessage, playbook, setupContext } from './playbook.js';
|
|
16
|
-
import { backToStartBranch, checkoutFrontEnd, currentBranch, onOrigin, parseFlags, readCheckout, skipWorkingFolderForCommands, stateSaysNoFrontEnd, USAGE } from './preflight.js';
|
|
16
|
+
import { backToStartBranch, checkoutFrontEnd, currentBranch, dropEmptyRunBranches, localBranches, onOrigin, parseFlags, readCheckout, skipWorkingFolderForCommands, stateSaysNoFrontEnd, USAGE } from './preflight.js';
|
|
17
17
|
import { startSandbox } from './sandbox.js';
|
|
18
18
|
import { scanRecorder } from './scan.js';
|
|
19
19
|
import { createReporter, reportingOff } from './report.js';
|
|
@@ -129,12 +129,16 @@ export async function main(argv) {
|
|
|
129
129
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
130
130
|
const slug = `${checkout.owner}/${checkout.repo}`;
|
|
131
131
|
const startBranch = currentBranch(checkout.root);
|
|
132
|
+
// The branches the person had, which the end never removes (REN-1245).
|
|
133
|
+
const startBranches = localBranches(checkout.root);
|
|
132
134
|
// What the run's write and edit tools change, from before the agent's first step: the end takes back what no commit carries (#421).
|
|
133
135
|
const journal = checkoutJournal(checkout.root);
|
|
134
136
|
backToStart = () => {
|
|
135
137
|
const kept = journal.undo();
|
|
136
138
|
const branch = backToStartBranch(checkout.root, startBranch);
|
|
137
|
-
|
|
139
|
+
// A branch the run made and left empty goes too, whoever took the checkout back to its branch.
|
|
140
|
+
const empty = dropEmptyRunBranches(checkout.root, startBranch, startBranches);
|
|
141
|
+
return [...kept, ...(branch ? [branch] : []), ...empty, ...journal.appeared(startBranch)];
|
|
138
142
|
};
|
|
139
143
|
// From the checkout's top, where the sandbox lays its own protections of a checkout.
|
|
140
144
|
process.chdir(checkout.root);
|
|
@@ -317,6 +321,9 @@ export async function main(argv) {
|
|
|
317
321
|
const end = ending(report);
|
|
318
322
|
// The person's no left no app with a preview Impeccable can open: the screen says why alone, with Close, and the report follows it here (#413).
|
|
319
323
|
const ended = Boolean(report.ended);
|
|
324
|
+
// The run's own words for why stay off that screen, here and in the report (REN-1245).
|
|
325
|
+
if (ended && report.reason)
|
|
326
|
+
log.write('setup ended', { reason: report.reason });
|
|
320
327
|
// One pull request a screen, each opened in the browser as its screen appears: the test drive (each app's, on a repository with several), then the setup pull request. Only the last of them goes on the clipboard, which would keep no other (#418); the others offer Copy link [C].
|
|
321
328
|
const hand = { open: openBrowser, copy: copyText };
|
|
322
329
|
const last = !ended && setupPr ? -1 : (drives?.length ?? 1) - 1;
|
|
@@ -14,7 +14,8 @@ It applies as well **before the first pull request**: Vercel deploys the reposit
|
|
|
14
14
|
- **The live app**: with the checkout linked to the project (`.vercel/project.json`) and a production deployment of it on GitHub's record, the evidence answers the project's own domain as `production`; live-app-address says how it becomes the before, in step 2's write. Unlinked, GitHub's record names no address of it that is open.
|
|
15
15
|
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
16
16
|
- **Behind Vercel's login** (`gate: "vercel-protection"`): Deployment Protection 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.
|
|
17
|
-
- **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence
|
|
17
|
+
- **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence. `enabled: false` from vercel.json (`git.deploymentEnabled` turns the branch off, the repository's own setting): push no test drive on Vercel. Set the preview up as server-previews says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason` and the vercel.json rule to lift. `enabled: false` with `cause: "previews"` (deployment_status Events off: Vercel builds the pushes but records no deployment on GitHub, where Impeccable finds its previews): before the test drive's push, impeccable_enable_previews (`host: "vercel"`) asks the person on one screen and turns it on. `enabled`, go on; `skipped` is a no that ends the setup, as the playbook says; `refused`, go on, since the wait finds Vercel's build by its commit status, and put "Turn on deployment_status Events under the Vercel project's Settings → Git" in `left`. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
|
|
18
|
+
- **`githubDeployments: false` in the answer of the wait for a pushed branch**: as vercel-setup's "No preview for the pushed test drive" says.
|
|
18
19
|
- **Another app, or no answer from any of the three**: Vercel'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 Vercel address answered.
|
|
19
20
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Vercel builds its preview from the push, which pull-requests' step 4 waits for and proves the test drive's page on before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the install and build Vercel runs (`vercel.json`'s `installCommand` and `buildCommand`, else the package's), as plain commands (`pnpm install`, then `pnpm run build`) and not with impeccable_verify_preview, which is for a preview Impeccable starts itself, so a page that does not build is never pushed.
|
|
20
21
|
- The evidence is GitHub's: link, deploy and change nothing on Vercel, and save no address pattern, since Vercel reports each preview's address to GitHub itself.
|
|
@@ -11,7 +11,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
11
11
|
- **Skip**: a no that ends the setup, as the playbook says: say under "What was skipped, and why" that Vercel was found and the sign-in was skipped. The steps below do not apply.
|
|
12
12
|
2. **Create the project, then link it** (run each Vercel command plain, on its own: its answer is short, a pipe to `tail` is refused, and a command chained after it leaves the project unrecorded): `vercel project add <the folder's name> --team <the team>` creates the project in that team, under the name `vercel link` gives a project (the checkout folder's name, lowercase); then `vercel link --yes --team <the team>`, in the checkout's root (never with `--cwd` or after a `cd`), links the folder to the project of that name, which is now the run's. The CLI names no team on its own without a terminal, so the option is given whenever there is a team. Done when the link prints the linked project.
|
|
13
13
|
- **`vercel project add` fails** (the name is in use: the project is the person's, not this run's): run no link; the Vercel setup ends there, and with the checkout linked to no project its CLI stops and the hold on pushes goes. Go on as "A step fails before the test drive is pushed" says, naming the project under "What was skipped, and why". When `.vercel/project.json` was in the checkout before the run, that link is not this run's, and the hold on pushes stays, by design: no test drive, as "No preview for the pushed test drive" says, and the report says under "What was skipped, and why" that the person removes `.vercel/project.json` or links the project themselves and runs the setup again.
|
|
14
|
-
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, when that project is the one step 2 created or one connected to this repository already, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. Done when it answered `connected: true` with `production: "exists"` or `locked: true
|
|
14
|
+
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, when that project is the one step 2 created or one connected to this repository already, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. It also turns on the project's deployment_status Events where it is off: Vercel records its deployments on GitHub only with it on, a project the CLI creates starts with it off, and GitHub's record is where the wait finds the test drive's preview. The tool turns it on at once for the project step 2 created, and after one screen for any other (`deploymentEvents` in the answer). Done when it answered `connected: true` with `production: "exists"` or `locked: true`, and no `deploymentEvents` skipped; a project connected by the link already is, and it says so.
|
|
15
15
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say (on a repository with several apps, where the run sets up more than one, that app's own test drive, on `impeccable/test-drive-<appKey>` with its page in that app alone, as the monorepo case's step 6 says), with the changes vercel-previews' reference gives: from the default branch, no setup pull request, built once with plain commands. Stage the new page and the page that links to it by name: `vercel link` writes `.vercel/` and a `.env.local` that holds a Vercel token, and may add `.vercel` to `.gitignore`, and none of that goes into the pull request. Its push starts the project's first deployment. Done when `git push -u origin impeccable/test-drive` (the app's own branch, where it has one) succeeded.
|
|
16
16
|
5. **Its preview, proven and saved, then its pull request** as pull-requests' step 4 (Vercel) and step 5 say: the wait for the pushed head's deployment, the test drive's page proven on the address it lists (past Vercel's login as preview-protection says), impeccable_write to `preview` as vercel-previews' step 2 says, then the pull request and its recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
|
|
17
17
|
|
|
@@ -20,13 +20,15 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
20
20
|
- **The live app**: a project created here has none yet, which the report says; one that deploys production already (`production: "exists"`) answers its address as `productionUrl`, and live-app-address says how it becomes the before.
|
|
21
21
|
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
22
22
|
- **A step fails before the test drive is pushed** (the sign-in not completed, the project not created, the repository not connected, or step 3 answering that the linked project is not this run's: `vercel link` linked one of the person's projects, named like the folder, which is left as it is): the Vercel setup ends there. Set the preview up as server-previews says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report. The sign-in, the team or the GitHub app's access skipped is a no that ends the setup, as the playbook says.
|
|
23
|
-
- **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence
|
|
23
|
+
- **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence. `enabled: false` from vercel.json (`git.deploymentEnabled` turns the branch off, the repository's own setting): the Vercel setup ends before step 4, as "A step fails before the test drive is pushed" says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason` and the vercel.json rule to lift. `enabled: false` with `cause: "previews"` (deployment_status Events off) is step 3's to turn on. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
|
|
24
|
+
- **`deploymentEvents` from step 3**: `enabled`, go on. `skipped` is a no that ends the setup, as the playbook says: push nothing. `refused` (Vercel kept the run and the person from changing it): go on with step 4, since the wait finds Vercel's build by its commit status, and put "Turn on deployment_status Events under the Vercel project's Settings → Git" in `left`, since Impeccable's reviews find each later preview on GitHub's record.
|
|
24
25
|
- **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
|
|
25
26
|
- **On a repository with several apps**, the project step 2 creates builds the repository's root: impeccable_vercel_connect connects and locks it only where an app this run sets up is the root's, and otherwise answers so, and the Vercel setup ends there for that app, as "A step fails before the test drive is pushed" says.
|
|
26
27
|
- **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`). The screen skipped is a no that ends the setup, as the playbook says.
|
|
27
28
|
- **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
|
|
28
29
|
- **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed. When the cause is in the test drive's own change, correct it, commit, push once more and wait for that build; else the report names the cause.
|
|
29
|
-
- **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
|
|
30
|
+
- **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, that is the end of the wait (a second call reads once and waits no more): say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
|
|
31
|
+
- **`githubDeployments: false` in the wait's answer**: Vercel's commit status on the head settled it, and GitHub records no deployment of it; `reason` says, in Vercel's words, whether it built, failed or skipped the branch. A deployment listed is the test drive's build: prove it as usual. Whatever came of it, the report's reason and `left` say what Vercel did and name deployment_status Events under the project's Settings → Git as the one change, and say Vercel built the branch whenever `reason` does. "No preview was built" is the wording of a branch with no deployment and no commit status of Vercel's.
|
|
30
32
|
- **Behind Vercel's login** (`gate: "vercel-protection"`) with nothing getting past it: preview-protection says what the report names.
|
|
31
33
|
- **The first deployment** of a new project is called Production, whatever branch it builds. Locked in step 3, and still so when its deployment is read, it is the test drive's preview: impeccable_hosted_previews lists it, and the proof opens it past Vercel's login as preview-protection says. Unlocked by then, or another project's, the evidence lists no address for it and the proof refuses it: no test drive, as "No preview for the pushed test drive" says, and the report says under "What is not verified" that Vercel serves the test drive's build at the project's own address (`vercel project ls` prints it) without a login until the default branch deploys, and that Vercel Authentication for All Deployments, under the project's Settings → Deployment Protection, puts its login back in front. A project that deployed production before the run (`production: "exists"`) builds the push as a Preview, and keeps its addresses as they were.
|
|
32
34
|
- **Behind Vercel's login** (`gate: "vercel-protection"`): a project the CLI creates starts with Vercel Authentication on, and step 3 locks it at every address, so expect it, and go on as the preview-protection case says.
|
package/dist/playbook.js
CHANGED
|
@@ -34,7 +34,7 @@ Set the repository up with whatever the host's CLI can do (Netlify's, Vercel's,
|
|
|
34
34
|
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\`, \`workflowStanding\` and \`hosting\` (each app's host, live address and the previews GitHub recorded, as Impeccable already holds them: a start, which the host tools take and the proofs still prove; absent from an older setup API), whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
|
|
35
35
|
2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
|
|
36
36
|
3. **Set up the preview**, starting with the host \`hosting\` names for the app (its case and its tool first; GitHub's record decides, so a named host whose tool lists nothing sends you on to the next): as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare's (a Pages project's, or a Worker's), as the render-previews case says when impeccable_render_previews answers a Render site, as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
37
|
-
4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
|
|
37
|
+
4. **Set up the sign-in** as the sign-in case says, when the app has its own. When step 2 found no sign-in of any kind in the app (no form, OAuth, magic link or single sign-on) and the state's \`signin\` item, or its \`perApp\` entry for the app, reads \`sign-in-unknown\`, choose No sign-in yourself: impeccable_write to \`preview\` with \`{"auth": {"method": "none"}}\`, and the app's \`appKey\` in a monorepo. It never goes in \`left\`. Done when that case's steps are done, or No sign-in is saved.
|
|
38
38
|
5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
|
|
39
39
|
6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
|
|
40
40
|
7. **Report**: impeccable_progress with every task's final status, then impeccable_report as the report section says. Done when impeccable_report answered. The web page follows Impeccable's review, so never wait for it.
|
package/dist/preflight.js
CHANGED
|
@@ -314,22 +314,70 @@ export function currentBranch(root) {
|
|
|
314
314
|
return '';
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
+
/** A branch the run makes its pull requests on: impeccable/setup, impeccable/test-drive or an app's impeccable/test-drive-<app key>, with -2, -3 where an earlier run took it. */
|
|
318
|
+
const RUN_BRANCH = /^impeccable\/(?:setup(?:-\d+)?|test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?)$/;
|
|
319
|
+
/** The checkout's local branches; null when git cannot say. */
|
|
320
|
+
export function localBranches(root) {
|
|
321
|
+
try {
|
|
322
|
+
return new Set(execFileSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: gitEnvironment() }).split('\n').filter(Boolean));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Once the checkout is back on `start`: each of the run's branches that was not in the checkout at its start (`before`),
|
|
330
|
+
* is on no remote, and holds no commit of its own (its tip is `start`'s), as a branch made before a no that ended the
|
|
331
|
+
* setup is (REN-1245), is removed, so the checkout ends as it started. A branch the person had is never touched: with
|
|
332
|
+
* `before` unread (null), none is removed. The done screen's lines for the ones git would not remove.
|
|
333
|
+
*/
|
|
334
|
+
export function dropEmptyRunBranches(root, start, before) {
|
|
335
|
+
if (!before || !start || currentBranch(root) !== start)
|
|
336
|
+
return [];
|
|
337
|
+
const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: gitEnvironment() }).trim();
|
|
338
|
+
const lines = [];
|
|
339
|
+
let tip;
|
|
340
|
+
try {
|
|
341
|
+
tip = git(['rev-parse', '--verify', '-q', `refs/heads/${start}`]);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
for (const branch of localBranches(root) ?? []) {
|
|
347
|
+
if (before.has(branch) || !RUN_BRANCH.test(branch) || onOrigin(root, branch))
|
|
348
|
+
continue;
|
|
349
|
+
try {
|
|
350
|
+
if (git(['rev-parse', '--verify', '-q', `refs/heads/${branch}`]) !== tip)
|
|
351
|
+
continue;
|
|
352
|
+
// -d, never -D: git removes it only as merged into the checkout's branch.
|
|
353
|
+
git(['branch', '-d', branch]);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
lines.push(`${branch}, a branch the setup made, is still in your checkout: git branch -d ${branch} removes it.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
317
361
|
/**
|
|
318
362
|
* The run makes its pull requests on branches of its own, and git leaves the checkout on the last it made: the end goes back
|
|
319
363
|
* to the branch the run started on (`start`), so what the person commits next does not land on the test drive. Only from one
|
|
320
|
-
* of the run's branches, and only with no change to a tracked file, which would go along
|
|
321
|
-
*
|
|
364
|
+
* of the run's branches, and only with no change to a tracked file, which would go along, but to a .gitignore the two
|
|
365
|
+
* branches hold alike, which goes along named; git refuses where an untracked file would be overwritten. The run's own
|
|
366
|
+
* files no commit carries are taken back before this (checkoutJournal). Null when the checkout is back with nothing
|
|
367
|
+
* carried, or never left; else the line the done screen shows.
|
|
322
368
|
*/
|
|
323
369
|
export function backToStartBranch(root, start) {
|
|
324
370
|
const now = currentBranch(root);
|
|
325
|
-
if (now === start ||
|
|
371
|
+
if (now === start || !RUN_BRANCH.test(now))
|
|
326
372
|
return null;
|
|
327
373
|
if (!start)
|
|
328
374
|
return `Your checkout is on ${now}, the branch the setup made.`;
|
|
329
375
|
const git = (args, input) => execFileSync('git', args, { cwd: root, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: gitEnvironment() });
|
|
330
376
|
try {
|
|
331
377
|
// A content filter is a program git runs outside the sandbox as it reads or writes a file, from settings that may name a script in the checkout a sandboxed command could have changed: a checkout with any file a filter takes, here or on the start branch, is left to the person. Neither read runs one.
|
|
332
|
-
|
|
378
|
+
// The files the run's branch and the start branch hold differently.
|
|
379
|
+
const differ = git(['diff', '--name-only', '-z', '--no-ext-diff', '--no-textconv', 'HEAD', start, '--']).split('\0').filter(Boolean);
|
|
380
|
+
const paths = [...new Set([...git(['ls-files', '-z']).split('\0'), ...differ].filter(Boolean))].map((path) => `${path}\0`).join('');
|
|
333
381
|
const listed = git(['check-attr', '--stdin', '-z', 'filter'], paths).split('\0');
|
|
334
382
|
// path, attribute, value, ...
|
|
335
383
|
for (let i = 2; i < listed.length; i += 3)
|
|
@@ -341,12 +389,15 @@ export function backToStartBranch(root, start) {
|
|
|
341
389
|
throw new Error('a content filter on the start branch');
|
|
342
390
|
// Named, so the done screen says which files keep the checkout here (#421).
|
|
343
391
|
const changed = git(['status', '--porcelain=v1', '-z', '--untracked-files=no', '--no-renames']).split('\0').filter(Boolean).map((line) => line.slice(3));
|
|
344
|
-
|
|
345
|
-
|
|
392
|
+
// A .gitignore both branches hold alike, which a host's link changed (vercel link adds .vercel and .env*, before the test drive's branch is made), goes along to the start branch as it is, named (REN-1250): taking those lines back would show the link's token file, .env.local, to git add. Any other change keeps the checkout here.
|
|
393
|
+
const carried = changed.filter((path) => (path === '.gitignore' || path.endsWith('/.gitignore')) && !differ.includes(path));
|
|
394
|
+
const held = changed.filter((path) => !carried.includes(path));
|
|
395
|
+
if (held.length)
|
|
396
|
+
return `Your checkout is still on ${now}, since ${listPaths(held)} changed there: git checkout ${start} takes it back to the branch you started on.`;
|
|
346
397
|
// The closing -- makes git read the name as the branch, never as a path.
|
|
347
398
|
// An ignored file of the person's where the start branch tracks a file of that name stops the checkout, rather than being overwritten.
|
|
348
399
|
git(['-c', 'submodule.recurse=false', 'checkout', '-q', '--no-overwrite-ignore', start, '--']);
|
|
349
|
-
return null;
|
|
400
|
+
return carried.length ? `${listPaths(carried)} changed during the setup, from a command it ran or from you, and ${carried.length === 1 ? 'stays' : 'stay'} uncommitted on ${start} as ${carried.length === 1 ? 'it is' : 'they are'}.` : null;
|
|
350
401
|
}
|
|
351
402
|
catch {
|
|
352
403
|
return `Your checkout is still on ${now}: git checkout ${start} takes it back to the branch you started on.`;
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endAppSetup, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste, vercelSignedIn } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, renderSiteOf, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { closedPulls, deploymentsOf, hostingApp, hostingRead, liveHostOf, renderSitesOf } from './hosting.js';
|
|
@@ -19,7 +19,7 @@ import { cloudflareFromHosting, cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connect
|
|
|
19
19
|
import { passCloudflareAccess, TOKEN_NAME } from './cloudflare-access.js';
|
|
20
20
|
import { netlifyFromHosting, commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
21
21
|
import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-password.js';
|
|
22
|
-
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
22
|
+
import { connectVercelProject, DEPLOYMENT_EVENTS, DEPLOYMENTS_OFF, deploymentOfStatus, enableGithubDeployments, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
23
23
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
24
24
|
import { fitsTask, stagesOf, workStarted } from './stages.js';
|
|
25
25
|
import { commandLine, redact } from './log.js';
|
|
@@ -165,10 +165,11 @@ function stageError(status, tasks) {
|
|
|
165
165
|
}
|
|
166
166
|
/** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
|
|
167
167
|
export function resultLine(report) {
|
|
168
|
+
// A setup the person's no ended shows its own line alone: the run's words for why stay in the report (#413, REN-1245).
|
|
169
|
+
if (report.ended)
|
|
170
|
+
return SETUP_ENDS[report.ended].line;
|
|
168
171
|
// The run's own words for why no method was saved, where it gave them (REN-1108).
|
|
169
172
|
const why = report.reason && !report.methodSaved ? ` Why: ${report.reason.replace(/[.\s]+$/, '')}.` : '';
|
|
170
|
-
if (report.ended)
|
|
171
|
-
return `${SETUP_ENDS[report.ended].line}${why}`;
|
|
172
173
|
const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
|
|
173
174
|
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)}${why} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
174
175
|
// --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
|
|
@@ -282,7 +283,7 @@ const REPORT = {
|
|
|
282
283
|
description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
|
|
283
284
|
items: { type: 'string', minLength: 1 },
|
|
284
285
|
},
|
|
285
|
-
reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it. Names only, never an address or a key.' },
|
|
286
|
+
reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it, unless the person\'s no ended the setup. Names only, never an address or a key.' },
|
|
286
287
|
apps: {
|
|
287
288
|
type: 'array',
|
|
288
289
|
description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". The last screen shows it on that app\'s line. Names only, never an address or a key.',
|
|
@@ -406,6 +407,19 @@ const namesCredential = (q) => {
|
|
|
406
407
|
const text = [q.title, q.prompt, q.hint].filter(Boolean).join('\n');
|
|
407
408
|
return CREDENTIAL.test(text) || CREDENTIAL.test(text.replace(/_|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' '));
|
|
408
409
|
};
|
|
410
|
+
/** What Vercel's commit statuses on a pushed branch's head say, where GitHub records no deployment of Vercel's for it (REN-1249): the cause in Vercel's own words; none while it is still building. */
|
|
411
|
+
function statusReason(branch, statuses, listed) {
|
|
412
|
+
const said = (state) => statuses.some((s) => s.state === state);
|
|
413
|
+
if (said('built'))
|
|
414
|
+
return listed
|
|
415
|
+
? `Vercel built ${branch}; the deployment its commit status names is listed, as Vercel reads it. ${DEPLOYMENTS_OFF}`
|
|
416
|
+
: `Vercel built ${branch}, as its commit status says, but the Vercel CLI could not read which deployment that was, so it has no address here. ${DEPLOYMENTS_OFF}`;
|
|
417
|
+
if (said('failed'))
|
|
418
|
+
return `Vercel's build of ${branch} failed, as its commit status says. ${DEPLOYMENTS_OFF}`;
|
|
419
|
+
if (said('skipped'))
|
|
420
|
+
return `Vercel skipped ${branch}, as its commit status says: an Ignored Build Step or a branch rule kept it from building.`;
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
409
423
|
/** Under the field of every text question typed in the open, where the model reads the answer. */
|
|
410
424
|
const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
|
|
411
425
|
/** The app a screen is for, on a repository with several apps: the person's no on it ends the setup for that app alone (#413). */
|
|
@@ -414,7 +428,7 @@ const ENABLE_PREVIEWS = {
|
|
|
414
428
|
type: 'object',
|
|
415
429
|
additionalProperties: false,
|
|
416
430
|
required: ['host'],
|
|
417
|
-
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
|
|
431
|
+
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare', 'vercel'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
|
|
418
432
|
};
|
|
419
433
|
const CLOUDFLARE_CONNECT = {
|
|
420
434
|
type: 'object',
|
|
@@ -1738,36 +1752,43 @@ export function createTools(ctx) {
|
|
|
1738
1752
|
const accountOf = new Map();
|
|
1739
1753
|
/** The Vercel project the bypass tool read for each address its secret opened, and its team where that is not the CLI's current scope. */
|
|
1740
1754
|
const vercelNamed = new Map();
|
|
1741
|
-
/**
|
|
1742
|
-
const
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1755
|
+
/** The Vercel projects impeccable_vercel_connect took as this run's, by name, each with its team ('' for the personal scope): the last screen names them however the run ended, as the report does (REN-1250). */
|
|
1756
|
+
const vercelSetUp = new Map();
|
|
1757
|
+
/** What the last screen says the run used on each host: the Vercel projects it set up, and the previews it opened. */
|
|
1758
|
+
const hostsUsed = () => [...[...vercelSetUp].map(([project, team]) => ({ host: 'Vercel', project, ...(team ? { account: team } : {}) })), ...[...usedPreviews].flatMap((url) => {
|
|
1759
|
+
const origin = new URL(url).origin;
|
|
1760
|
+
const label = pagesProject(origin);
|
|
1761
|
+
if (label)
|
|
1762
|
+
return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
|
|
1763
|
+
const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
|
|
1764
|
+
if (worker)
|
|
1765
|
+
return [{ host: 'Cloudflare', project: worker }];
|
|
1766
|
+
const site = netlifySiteOf(url);
|
|
1767
|
+
if (site)
|
|
1768
|
+
return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
|
|
1769
|
+
const render = renderSiteOf(url);
|
|
1770
|
+
if (render)
|
|
1771
|
+
return [{ host: 'Render', project: render.site }];
|
|
1772
|
+
const read = vercelNamed.get(origin);
|
|
1773
|
+
const named = read?.project ?? listedApps.get(origin)?.name;
|
|
1774
|
+
const vercel = vercelDeploymentOf(url, named);
|
|
1775
|
+
const team = vercel?.team ?? read?.team;
|
|
1776
|
+
const project = vercel?.project ?? named;
|
|
1777
|
+
return project ? [{ host: 'Vercel', project, ...(team ? { account: team } : {}) }] : [];
|
|
1778
|
+
})];
|
|
1779
|
+
/** The builds, by what was waited for (`key`), whose wait ran out after its retries or that the person stopped waiting for: read again, never waited for again (REN-1250). */
|
|
1780
|
+
const buildsGivenUp = new Set();
|
|
1763
1781
|
/**
|
|
1764
1782
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1765
1783
|
* every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
1784
|
+
* A build whose wait (`key`) ran out or was skipped earlier in the run is read once and not waited for again.
|
|
1766
1785
|
*/
|
|
1767
|
-
async function awaitBuild(host, status, read, built, page, signal, access) {
|
|
1786
|
+
async function awaitBuild(host, key, status, read, built, page, signal, access) {
|
|
1768
1787
|
const first = await read();
|
|
1769
1788
|
if (built(first))
|
|
1770
1789
|
return first;
|
|
1790
|
+
if (buildsGivenUp.has(key))
|
|
1791
|
+
return { ...first, reason: `No second wait: ${host}'s build was waited for already in this run, until its retries ran out or the person skipped them, and it is still not built. There is no preview of it, as "No preview, no test drive" says.` };
|
|
1771
1792
|
const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
|
|
1772
1793
|
const waited = async (evidence) => {
|
|
1773
1794
|
said(status);
|
|
@@ -1784,19 +1805,21 @@ export function createTools(ctx) {
|
|
|
1784
1805
|
};
|
|
1785
1806
|
// No build in the time: the host's page where a build is turned on, and the wait again on the person's yes. Where the host's settings build every
|
|
1786
1807
|
// branch and still none came, its GitHub app cannot see the repository: the app's installation page instead, at once when the app is not installed at all.
|
|
1787
|
-
|
|
1788
|
-
|
|
1808
|
+
const given = access
|
|
1809
|
+
? await onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
|
|
1789
1810
|
skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
|
|
1790
1811
|
declines: { app: access.app },
|
|
1791
1812
|
still: `${host}'s GitHub app still cannot see the repository: no build came.`,
|
|
1792
1813
|
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1814
|
+
}, signal)
|
|
1815
|
+
: await onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
|
|
1816
|
+
skipped: `The person stopped waiting for ${host}'s build.`,
|
|
1817
|
+
still: `${host} built no preview in the time.`,
|
|
1818
|
+
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1793
1819
|
}, signal);
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
still: `${host} built no preview in the time.`,
|
|
1798
|
-
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1799
|
-
}, signal);
|
|
1820
|
+
if (!built(given) && !signal?.aborted)
|
|
1821
|
+
buildsGivenUp.add(key);
|
|
1822
|
+
return given;
|
|
1800
1823
|
}
|
|
1801
1824
|
/** What a wait for a pull request's build is for: the test drive's preview, or another pull request's. */
|
|
1802
1825
|
const subjectOf = async (pull) => {
|
|
@@ -1833,15 +1856,15 @@ export function createTools(ctx) {
|
|
|
1833
1856
|
const passwordReplaceConsented = new Set();
|
|
1834
1857
|
/** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
|
|
1835
1858
|
let accessGated;
|
|
1836
|
-
/**
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* The service token that opened each Pages project's (or Worker's) previews in this run, by project: the token, what the
|
|
1861
|
+
* tool said of it and whether the store holds it. Once it does, the proofs of that project's previews send it; never the
|
|
1862
|
+
* model. A monorepo's apps, each a project behind an Access application of its own, keep theirs side by side, so one
|
|
1863
|
+
* app's token never takes the place of another's (REN-1194).
|
|
1864
|
+
*/
|
|
1865
|
+
const accessTokens = new Map();
|
|
1841
1866
|
/** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
|
|
1842
1867
|
let accessOutcome;
|
|
1843
|
-
/** The Access applications, by id, the person said yes to a token for. */
|
|
1844
|
-
const accessConsented = new Set();
|
|
1845
1868
|
/** What each host's settings last said of its pull-request previews, as the evidence tools read them: the one fact impeccable_enable_previews acts on. */
|
|
1846
1869
|
const previewFacts = new Map();
|
|
1847
1870
|
/** On a repository with several apps, the project or site each app's own read named (`<host>/<appKey>`): a no on that project ends the setup of the apps on it alone (#413). */
|
|
@@ -1922,7 +1945,7 @@ export function createTools(ctx) {
|
|
|
1922
1945
|
* it back (`turnOn`); where the host kept it, the person changes it on the host's page and the run reads again, twice at most.
|
|
1923
1946
|
*/
|
|
1924
1947
|
const turnOnPreviews = async (host, turnOn, signal) => {
|
|
1925
|
-
said(`Turning on pull-request previews on ${{ netlify: 'Netlify', cloudflare: 'Cloudflare Pages', render: 'Render' }[host]}`);
|
|
1948
|
+
said(host === 'vercel' ? `Turning on ${DEPLOYMENT_EVENTS} on Vercel` : `Turning on pull-request previews on ${{ netlify: 'Netlify', cloudflare: 'Cloudflare Pages', render: 'Render' }[host]}`);
|
|
1926
1949
|
for (let tries = 0;; tries++) {
|
|
1927
1950
|
const outcome = await turnOn();
|
|
1928
1951
|
if (outcome.fact.enabled === true) {
|
|
@@ -2123,9 +2146,11 @@ export function createTools(ctx) {
|
|
|
2123
2146
|
*/
|
|
2124
2147
|
async function hostSignIn(host, pi, signal) {
|
|
2125
2148
|
const cli = hostCli(host, pi, signal);
|
|
2149
|
+
/** The team, read once the CLI is signed in: before the setup screen, which names it, where it was signed in already. */
|
|
2150
|
+
let teamRead;
|
|
2126
2151
|
const signedIn = async () => {
|
|
2127
2152
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
2128
|
-
const { team, choices } = await cli.team();
|
|
2153
|
+
const { team, choices } = await (teamRead ?? cli.team());
|
|
2129
2154
|
if (team)
|
|
2130
2155
|
chooseHostTeam(ctx.log, `${host}-team`, team);
|
|
2131
2156
|
teamChoices.set(host, choices);
|
|
@@ -2135,7 +2160,13 @@ export function createTools(ctx) {
|
|
|
2135
2160
|
return { skipped: true };
|
|
2136
2161
|
let status = await cli.status();
|
|
2137
2162
|
if (!hostSignIns.has(host)) {
|
|
2138
|
-
|
|
2163
|
+
// Signed in already, Vercel's screen names the account and the team the new project goes into, which the yes is to (REN-1250).
|
|
2164
|
+
let words = status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : undefined;
|
|
2165
|
+
if (status.cli === 'signed-in' && host === 'vercel' && status.user) {
|
|
2166
|
+
const { team, choices } = await (teamRead = cli.team());
|
|
2167
|
+
words = vercelSignedIn(status.user, { team, pick: choices.length > 1 });
|
|
2168
|
+
}
|
|
2169
|
+
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...words });
|
|
2139
2170
|
if (answer.skipped) {
|
|
2140
2171
|
endSetup(host);
|
|
2141
2172
|
return { skipped: true };
|
|
@@ -2280,6 +2311,8 @@ export function createTools(ctx) {
|
|
|
2280
2311
|
return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} is not this run's: ${foreign}. It stays as it is, and nothing was connected. The Vercel setup ends here.` };
|
|
2281
2312
|
}
|
|
2282
2313
|
}
|
|
2314
|
+
if (project.name)
|
|
2315
|
+
vercelSetUp.set(project.name, own.team);
|
|
2283
2316
|
// Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
|
|
2284
2317
|
const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal, connectedApp);
|
|
2285
2318
|
// The fence holds pushes and deploys from the sign-in until the linked project's first deployment is settled: Vercel's app cannot reach the repository (a push builds nothing), production is deployed already, or the project is locked at every address.
|
|
@@ -2288,6 +2321,12 @@ export function createTools(ctx) {
|
|
|
2288
2321
|
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
2289
2322
|
return result;
|
|
2290
2323
|
}
|
|
2324
|
+
// Vercel records its deployments on GitHub, where the wait finds the test drive's, only with deployment_status Events on, and a project the CLI creates starts with it off (REN-1248).
|
|
2325
|
+
const created = Boolean(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`));
|
|
2326
|
+
const events = project.githubDeployments ? undefined : await vercelDeploymentsOn(vercel, link, project.name, !created, connectedApp, signal);
|
|
2327
|
+
// The person's no ends the setup: nothing is pushed, so the hold stays and nothing more changes on the project.
|
|
2328
|
+
if (events?.status === 'skipped')
|
|
2329
|
+
return { ...result, deploymentEvents: events };
|
|
2291
2330
|
let first = await prepareFirstDeployment(vercel, link, repository);
|
|
2292
2331
|
vercelProject = first.project ?? vercelProject;
|
|
2293
2332
|
// Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
|
|
@@ -2303,7 +2342,36 @@ export function createTools(ctx) {
|
|
|
2303
2342
|
}
|
|
2304
2343
|
if (first.production === 'exists' || first.locked === true)
|
|
2305
2344
|
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
|
|
2306
|
-
return { ...result, ...first };
|
|
2345
|
+
return { ...result, ...first, ...(events ? { deploymentEvents: events } : {}) };
|
|
2346
|
+
}
|
|
2347
|
+
/** What impeccable_hosted_previews last read of each app's Vercel project ('' for a repository of one app): the fact impeccable_enable_previews acts on for that app. */
|
|
2348
|
+
const vercelFacts = new Map();
|
|
2349
|
+
/** What turning on deployment_status Events answered, by Vercel project: asked once, so that answer stands. */
|
|
2350
|
+
const deploymentEventsOutcomes = new Map();
|
|
2351
|
+
/**
|
|
2352
|
+
* The project's deployment_status Events, turned on as the person through the CLI where they are off: at once for a
|
|
2353
|
+
* project this run created, after the one consent screen (`consent`) for any other. Where Vercel refuses the run, the
|
|
2354
|
+
* person turns it on on the project's Settings → Git and the run reads again. A skip is a no that ends the setup.
|
|
2355
|
+
*/
|
|
2356
|
+
async function vercelDeploymentsOn(vercel, link, project, consent, app, signal) {
|
|
2357
|
+
const standing = deploymentEventsOutcomes.get(link.projectId);
|
|
2358
|
+
if (standing?.status === 'skipped')
|
|
2359
|
+
decline('no-previews', app);
|
|
2360
|
+
if (standing)
|
|
2361
|
+
return standing;
|
|
2362
|
+
const answer = (status, more) => {
|
|
2363
|
+
const told = { status, ...(project ? { project } : {}), ...more };
|
|
2364
|
+
deploymentEventsOutcomes.set(link.projectId, told);
|
|
2365
|
+
if (status === 'skipped')
|
|
2366
|
+
decline('no-previews', app);
|
|
2367
|
+
return told;
|
|
2368
|
+
};
|
|
2369
|
+
const screen = HOST_SCREENS['vercel-previews'];
|
|
2370
|
+
if (consent && (await ask({ ...screen, prompt: `Turn on ${DEPLOYMENT_EVENTS} for ${project ?? 'the Vercel project'}?` })).skipped)
|
|
2371
|
+
return answer('skipped', { reason: `The person skipped turning on ${DEPLOYMENT_EVENTS}.` });
|
|
2372
|
+
const page = await vercelPageFor(vercel, link, 'settings/git', project);
|
|
2373
|
+
const { status, ...more } = await turnOnPreviews('vercel', () => enableGithubDeployments(vercel, link, repository, page), signal);
|
|
2374
|
+
return answer(status, more);
|
|
2307
2375
|
}
|
|
2308
2376
|
async function syncWorkflowTool(pi, signal) {
|
|
2309
2377
|
try {
|
|
@@ -3145,7 +3213,7 @@ export function createTools(ctx) {
|
|
|
3145
3213
|
if (!left.includes('Watch the review on the test drive'))
|
|
3146
3214
|
left.push('Watch the review on the test drive');
|
|
3147
3215
|
}
|
|
3148
|
-
const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
|
|
3216
|
+
const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore, ended: endedBefore } = ctx.sync.payload;
|
|
3149
3217
|
// Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
|
|
3150
3218
|
const appsBefore = new Map(appSummaries);
|
|
3151
3219
|
// Each app whose setup ended on the person's no says so on the web page, unless the report gives its own words below.
|
|
@@ -3170,11 +3238,11 @@ export function createTools(ctx) {
|
|
|
3170
3238
|
// The list as the run left the apps: a Preview method done for some apps only says whose (REN-1108).
|
|
3171
3239
|
const tasks = taskOrder.sort(labelled(ctx.sync.payload.tasks));
|
|
3172
3240
|
reportTasks(tasks);
|
|
3173
|
-
ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
|
|
3241
|
+
ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20), ended });
|
|
3174
3242
|
const res = await ctx.sync.flush();
|
|
3175
3243
|
if (res && res.status >= 400 && res.status < 500) {
|
|
3176
3244
|
// A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
|
|
3177
|
-
ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore });
|
|
3245
|
+
ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore, ended: endedBefore });
|
|
3178
3246
|
// Its reasons go with it: the report sent again gives its own.
|
|
3179
3247
|
if (appsBefore.size) {
|
|
3180
3248
|
for (const [key, was] of appsBefore)
|
|
@@ -3190,7 +3258,7 @@ export function createTools(ctx) {
|
|
|
3190
3258
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
3191
3259
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
3192
3260
|
}),
|
|
3193
|
-
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
3261
|
+
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. Where GitHub records no deployment of Vercel\'s for that head (the project\'s deployment_status Events off), Vercel\'s commit status on it ends the wait at once: the deployment it names is listed, read from Vercel, and githubDeployments false with reason says what Vercel did (built, failed or skipped). It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
3194
3262
|
const branch = p.branch;
|
|
3195
3263
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3196
3264
|
if (refused)
|
|
@@ -3227,21 +3295,44 @@ export function createTools(ctx) {
|
|
|
3227
3295
|
return named !== undefined ? named === app.name : projectMadeDeployment(vercel, app.link, app.project, new URL(d.url).host);
|
|
3228
3296
|
};
|
|
3229
3297
|
const read = async () => {
|
|
3230
|
-
const evidence = await raw();
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3298
|
+
const { vercelStatuses, ...evidence } = await raw();
|
|
3299
|
+
const kept = app ? await Promise.all(evidence.deployments.map(owned)) : [];
|
|
3300
|
+
const mine = app ? evidence.deployments.filter((_, i) => kept[i]) : evidence.deployments;
|
|
3301
|
+
// Vercel reported the head's build only as its commit status, as it does with deployment_status Events off (REN-1248), where GitHub lists no deployment of
|
|
3302
|
+
// Vercel's of this project: the deployment the status names, read from Vercel as the person, stands for the one GitHub never got.
|
|
3303
|
+
if (branch === undefined || !vercelStatuses?.length || mine.some((d) => d.vercel))
|
|
3304
|
+
return { ...evidence, deployments: mine };
|
|
3305
|
+
const linked = app ? app.link : await readLink(pi.cwd);
|
|
3306
|
+
const own = !app && linked ? await readOwnProject(vercel, linked, repository) : null;
|
|
3307
|
+
const name = app?.name ?? (own && !('reason' in own) ? own.name : undefined) ?? vercelProject;
|
|
3308
|
+
const statuses = [];
|
|
3309
|
+
const made = [];
|
|
3310
|
+
for (const s of vercelStatuses) {
|
|
3311
|
+
// A context names its project; the one plain "Vercel" status may be any project's, and the deployment it names tells.
|
|
3312
|
+
if (s.context !== 'Vercel' && (!name || s.context !== `Vercel – ${name}`))
|
|
3313
|
+
continue;
|
|
3314
|
+
const d = linked && s.targetUrl && (s.state === 'built' || s.state === 'failed') ? await deploymentOfStatus(vercel, linked, s.targetUrl, s.sha) : null;
|
|
3315
|
+
if (d === 'other')
|
|
3316
|
+
continue;
|
|
3317
|
+
// Unread, the plain one is this project's only where its page in Vercel's dashboard, vercel.com/<scope>/<project>/<id>, names this project.
|
|
3318
|
+
if (!d && s.context === 'Vercel' && (!name || s.targetUrl?.split('/')[4] !== name))
|
|
3319
|
+
continue;
|
|
3320
|
+
statuses.push(s);
|
|
3321
|
+
if (d)
|
|
3322
|
+
made.push({ sha: s.sha, environment: `${d.production ? 'Production' : 'Preview'} – ${d.name || name || ''}`, state: s.state === 'built' ? 'success' : 'failure', url: d.url, vercel: true, pullRequests: [] });
|
|
3323
|
+
}
|
|
3324
|
+
return { ...evidence, deployments: [...mine, ...made], ...(statuses.length ? { vercelStatuses: statuses } : {}) };
|
|
3235
3325
|
};
|
|
3236
3326
|
// With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
|
|
3237
3327
|
// project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
|
|
3238
3328
|
const isPreview = (d) => /preview/i.test(d.environment ?? '');
|
|
3239
3329
|
const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
|
|
3240
3330
|
// A head's build is still building until it has a final status.
|
|
3241
|
-
|
|
3331
|
+
// Vercel's commit status settles it too, so a build GitHub will never record a deployment of ends the wait at once.
|
|
3332
|
+
const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error')) || Boolean(evidence.vercelStatuses?.some((s) => s.state !== 'building'));
|
|
3242
3333
|
// The project's Git settings, for the screen a build that never comes opens: the app's project's own page, else the linked project's.
|
|
3243
3334
|
const gitPage = branch !== undefined ? await vercelPageFor(vercel, app ? app.link : await readLink(pi.cwd), 'settings/git', app?.name ?? vercelProject) : '';
|
|
3244
|
-
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
|
|
3335
|
+
const evidence = branch !== undefined ? await awaitBuild('Vercel', `vercel ${appKey ?? ''} ${branch}`, `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
|
|
3245
3336
|
/** The project the evidence is of: the app's, else the one this folder is linked to. */
|
|
3246
3337
|
const link = async () => (app ? app.link : readLink(pi.cwd));
|
|
3247
3338
|
/** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
|
|
@@ -3279,6 +3370,7 @@ export function createTools(ctx) {
|
|
|
3279
3370
|
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
|
|
3280
3371
|
if (fact) {
|
|
3281
3372
|
previewFacts.set('vercel', fact);
|
|
3373
|
+
vercelFacts.set(appKey ?? '', fact);
|
|
3282
3374
|
evidence.pullRequestPreviews = fact;
|
|
3283
3375
|
}
|
|
3284
3376
|
}
|
|
@@ -3295,9 +3387,12 @@ export function createTools(ctx) {
|
|
|
3295
3387
|
}
|
|
3296
3388
|
if (app?.name)
|
|
3297
3389
|
noteApp(appKey, { host_project: app.name });
|
|
3298
|
-
|
|
3390
|
+
// What Vercel's commit status says, where GitHub records no deployment of Vercel's for the head: the cause in Vercel's own words, never "no build".
|
|
3391
|
+
const told = branch !== undefined && evidence.vercelStatuses?.length ? statusReason(branch, evidence.vercelStatuses, evidence.deployments.some((d) => d.vercel && d.state === 'success' && d.url)) : undefined;
|
|
3392
|
+
const { vercelStatuses: _statuses, ...shown } = evidence;
|
|
3393
|
+
return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...shown, ...(production ? { production } : {}), ...(told ? { githubDeployments: false, reason: told } : {}) });
|
|
3299
3394
|
}),
|
|
3300
|
-
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
3395
|
+
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
3301
3396
|
const pull = p.pull;
|
|
3302
3397
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3303
3398
|
if (refused)
|
|
@@ -3316,7 +3411,7 @@ export function createTools(ctx) {
|
|
|
3316
3411
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
3317
3412
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
3318
3413
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
3319
|
-
const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
|
|
3414
|
+
const evidence = pull !== undefined ? await awaitBuild('Netlify', `netlify ${appKey ?? ''} #${pull}`, `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
|
|
3320
3415
|
// The waited pull request's own site; else the one site known, so another app's site never opens.
|
|
3321
3416
|
const known = new Set([...e.sites, ...(netlifySite ? [netlifySite] : [])]);
|
|
3322
3417
|
const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
|
|
@@ -3349,7 +3444,7 @@ export function createTools(ctx) {
|
|
|
3349
3444
|
noteApp(appKey, { host_project: site });
|
|
3350
3445
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3351
3446
|
}),
|
|
3352
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
3447
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
3353
3448
|
const pull = p.pull;
|
|
3354
3449
|
const branch = p.branch;
|
|
3355
3450
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -3385,7 +3480,7 @@ export function createTools(ctx) {
|
|
|
3385
3480
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3386
3481
|
const app = cloudflareEvidence?.githubApp;
|
|
3387
3482
|
const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false, ...(typeof p.appKey === 'string' ? { app: p.appKey } : {}) } : undefined;
|
|
3388
|
-
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3483
|
+
let waited = subject ? await awaitBuild('Cloudflare', `cloudflare ${typeof p.appKey === 'string' ? p.appKey : ''} ${branch ?? `#${pull}`}`, `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3389
3484
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3390
3485
|
if (!subject && waited.cli === 'missing') {
|
|
3391
3486
|
await installCf(pi, signal);
|
|
@@ -3445,7 +3540,8 @@ export function createTools(ctx) {
|
|
|
3445
3540
|
const site = netlifySiteOf(url) ?? known?.site;
|
|
3446
3541
|
// A saved service token opens its Pages project's previews, or its Worker's: Access admits it wherever the application's policy covers.
|
|
3447
3542
|
const project = pagesProject(origin) ?? workerPatterns.get(origin);
|
|
3448
|
-
const
|
|
3543
|
+
const accessHeld = project ? accessTokens.get(project) : undefined;
|
|
3544
|
+
const token = accessHeld?.stored ? accessHeld.value : undefined;
|
|
3449
3545
|
const prove = () => provePreview(url, p.expect, pi.cwd, {
|
|
3450
3546
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
3451
3547
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
@@ -3520,7 +3616,7 @@ export function createTools(ctx) {
|
|
|
3520
3616
|
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
3521
3617
|
return JSON.stringify(proof);
|
|
3522
3618
|
}),
|
|
3523
|
-
tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site the setup state's hosting record names for the app (Render's deployments, its live address), else the one Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them (from the state's record where it lists them or carries readAt, else read from GitHub here), each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down; pullState closed marks one the record knows is closed, and impeccable_prove_preview refuses it); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
|
|
3619
|
+
tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site the setup state's hosting record names for the app (Render's deployments, its live address), else the one Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them (from the state's record where it lists them or carries readAt, else read from GitHub here), each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down; pullState closed marks one the record knows is closed, and impeccable_prove_preview refuses it); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
|
|
3524
3620
|
const pull = p.pull;
|
|
3525
3621
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3526
3622
|
if (refused)
|
|
@@ -3563,7 +3659,7 @@ export function createTools(ctx) {
|
|
|
3563
3659
|
return { previews: previewsOf(evidence, known.site), ...(evidence.error ? { error: evidence.error } : {}) };
|
|
3564
3660
|
};
|
|
3565
3661
|
const final = (e) => Boolean(e.error) || e.previews.some((preview) => preview.state === 'success' || preview.state === 'failure' || preview.state === 'error');
|
|
3566
|
-
const waited = await awaitBuild('Render', `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
|
|
3662
|
+
const waited = await awaitBuild('Render', `render ${appKey ?? ''} #${pull}`, `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
|
|
3567
3663
|
list(waited.previews);
|
|
3568
3664
|
for (const origin of renderListed)
|
|
3569
3665
|
renderBuilt.add(origin);
|
|
@@ -3653,7 +3749,26 @@ export function createTools(ctx) {
|
|
|
3653
3749
|
noteApp(appKey, { host_project: result.site.name });
|
|
3654
3750
|
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
3655
3751
|
}),
|
|
3656
|
-
tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect or
|
|
3752
|
+
tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect, impeccable_cloudflare_previews or impeccable_hosted_previews said (enabled false). It does the rest as the person: one screen asking to turn them on, then the host's CLI changes that one setting (Netlify: Deploy Previews on; Cloudflare Pages: preview branch control set to every non-production branch, or the test drive's branch added to the custom includes; Vercel, with cause previews: deployment_status Events, which records Vercel's deployments on GitHub, where Impeccable finds them) and reads it back; where the CLI cannot (the role, a cf sign-in without pages:write, builds stopped altogether), it opens the host's settings page and asks the person to try again once they changed it there. Answers {\"status\": …}: enabled (pull-request previews are on now; by says whether the run or the person turned them on, and project which project or site), skipped (the person skipped a screen; reason says what came before) or refused (still off after the retries; reason says why, and page where it is changed). A vercel.json rule that turns Vercel's builds off is the repository's own, and is never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
|
|
3753
|
+
if (p.host === 'vercel') {
|
|
3754
|
+
// The project the fact was read of: the app's own on a repository with several apps, else the one this folder is linked to.
|
|
3755
|
+
const fact = vercelFacts.get(appsRepo ? p.appKey : '');
|
|
3756
|
+
const off = fact?.enabled === false && fact.cause === 'previews';
|
|
3757
|
+
const unread = `Refused: this is for a Vercel project whose pullRequestPreviews said enabled false with cause previews (${DEPLOYMENT_EVENTS} off) in this run. Read impeccable_hosted_previews first; a vercel.json rule is the repository's own to change.`;
|
|
3758
|
+
if (!off && !deploymentEventsOutcomes.size)
|
|
3759
|
+
return unread;
|
|
3760
|
+
const vercel = hostRunner('vercel', pi.cwd, { signal });
|
|
3761
|
+
const app = appsRepo ? await vercelApp(pi, p.appKey, vercel) : null;
|
|
3762
|
+
if (app && 'reason' in app)
|
|
3763
|
+
return JSON.stringify({ appKey: p.appKey, status: 'refused', reason: app.reason });
|
|
3764
|
+
const link = app ? app.link : await readLink(pi.cwd);
|
|
3765
|
+
if (!link)
|
|
3766
|
+
return 'Refused: this folder is linked to no Vercel project (.vercel/project.json), so which project to change cannot be told.';
|
|
3767
|
+
// The answer stands for the project, as before.
|
|
3768
|
+
if (!off && !deploymentEventsOutcomes.has(link.projectId))
|
|
3769
|
+
return unread;
|
|
3770
|
+
return JSON.stringify(await vercelDeploymentsOn(vercel, link, app ? app.name : fact?.project || undefined, true, p.appKey, signal));
|
|
3771
|
+
}
|
|
3657
3772
|
const host = p.host;
|
|
3658
3773
|
const standing = enableOutcomes.get(host);
|
|
3659
3774
|
// The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
|
|
@@ -3754,7 +3869,7 @@ export function createTools(ctx) {
|
|
|
3754
3869
|
cloudflareEvidence = { ...evidence, project, ...(outcome.fact ? { pullRequestPreviews: outcome.fact } : {}) };
|
|
3755
3870
|
return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
|
|
3756
3871
|
}),
|
|
3757
|
-
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
|
|
3872
|
+
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; deploymentEvents, present where the project's deployment_status Events was off (Vercel then records no deployment on GitHub, where Impeccable finds its previews), says how turning it on as the person ended: at once for a project this run created, after one screen for any other; status enabled, skipped (the person's no, which ends the setup: push nothing) or refused (reason, and page where the person turns it on); with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
|
|
3758
3873
|
const refused = appRefusal(undefined, 'connected', false);
|
|
3759
3874
|
if (refused)
|
|
3760
3875
|
return refused;
|
|
@@ -3890,6 +4005,15 @@ export function createTools(ctx) {
|
|
|
3890
4005
|
bypassConsented.add(`${store}/${link.projectId}`);
|
|
3891
4006
|
return JSON.stringify({ ...of, protected: true, consent: 'given' });
|
|
3892
4007
|
}
|
|
4008
|
+
// No proof will meet the login now, so the web page learns it from here, as a proof's would tell it (#365): at one of the project's own deployments, which the login covers (REN-1246).
|
|
4009
|
+
const deployed = [...project.deploymentHosts][0];
|
|
4010
|
+
if (deployed) {
|
|
4011
|
+
const met = { gate: 'vercel-protection', preview_host: deployed };
|
|
4012
|
+
if (app)
|
|
4013
|
+
noteApp(app.appKey, { ...met, preview_proven: false });
|
|
4014
|
+
else if (!appsRepo)
|
|
4015
|
+
ctx.sync.update({ preview_gate: met });
|
|
4016
|
+
}
|
|
3893
4017
|
const outcome = noAccess({ status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` });
|
|
3894
4018
|
bypassOutcomes.set(store, outcome);
|
|
3895
4019
|
return JSON.stringify(outcome);
|
|
@@ -4164,25 +4288,23 @@ export function createTools(ctx) {
|
|
|
4164
4288
|
};
|
|
4165
4289
|
let answer;
|
|
4166
4290
|
const project = pagesProject(new URL(at.url).origin) ?? workerPatterns.get(new URL(at.url).origin);
|
|
4167
|
-
// The token in hand goes only to
|
|
4168
|
-
|
|
4291
|
+
// The project's own token in hand goes only to that project, as the proofs send it: any other address is read for an application of the person's first.
|
|
4292
|
+
const held = project ? accessTokens.get(project) : undefined;
|
|
4293
|
+
if (held && (await prove(held.value)).proven) {
|
|
4169
4294
|
// The token in hand opens this address too: stored already, or stored now.
|
|
4170
|
-
const failed =
|
|
4171
|
-
answer = { status: failed ? 'save-failed' : 'saved', ...
|
|
4295
|
+
const failed = held.stored ? null : await save(held.value);
|
|
4296
|
+
answer = { status: failed ? 'save-failed' : 'saved', ...held.made, ...(failed ? { reason: failed } : {}), value: held.value };
|
|
4172
4297
|
}
|
|
4173
4298
|
else {
|
|
4299
|
+
// A token saved for this project that no longer opens its preview is replaced only on a new yes, which the screen asks saying why (REN-1194).
|
|
4300
|
+
const why = held && `The service token saved for ${at.app ? appName(at.app.appKey) : new URL(at.url).host} no longer opens ${new URL(at.url).host}: ${proof?.reason ?? 'Cloudflare Access did not let it in.'} A new one takes its place.`;
|
|
4174
4301
|
answer = await passCloudflareAccess({
|
|
4175
4302
|
url: at.url,
|
|
4176
|
-
// Named once the Access application was found (#357)
|
|
4303
|
+
// Named once the Access application was found (#357). Every token made is asked for: a yes makes one token, for this app's previews.
|
|
4177
4304
|
consent: async (found) => {
|
|
4178
|
-
if (found && accessConsented.has(found.id))
|
|
4179
|
-
return true;
|
|
4180
4305
|
// The account by the name the last screen gives it, where a read found it; else its id (#390).
|
|
4181
4306
|
const named = found && { ...found, account: accountOf.get(`cloudflare account ${found.account}`) ?? found.account };
|
|
4182
|
-
|
|
4183
|
-
if (yes && found)
|
|
4184
|
-
accessConsented.add(found.id);
|
|
4185
|
-
return yes;
|
|
4307
|
+
return !(await ask(sentTo(cloudflareAccessConsent(named, new URL(at.url).host, at.app && appName(at.app.appKey), why), at.url))).skipped;
|
|
4186
4308
|
},
|
|
4187
4309
|
cf: cfRunner({ checkout: pi.cwd }, hostRunner),
|
|
4188
4310
|
login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, hostRunner),
|
|
@@ -4210,14 +4332,10 @@ export function createTools(ctx) {
|
|
|
4210
4332
|
if (value && told.token === 'created' && told.application)
|
|
4211
4333
|
ctx.onKeyCreated?.(accessNote(told.application), `cloudflare-access:${told.application}`);
|
|
4212
4334
|
if (value) {
|
|
4213
|
-
// What is in hand follows the store, which holds
|
|
4214
|
-
|
|
4215
|
-
accessProjects.clear();
|
|
4216
|
-
accessToken = value;
|
|
4217
|
-
accessStored = told.status === 'saved';
|
|
4218
|
-
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
4335
|
+
// What is in hand follows the store, which holds each app's own: the token saved now is this project's, beside every other project's.
|
|
4336
|
+
const made = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
4219
4337
|
if (project)
|
|
4220
|
-
|
|
4338
|
+
accessTokens.set(project, { value, made, stored: told.status === 'saved' });
|
|
4221
4339
|
}
|
|
4222
4340
|
const pattern = told.status === 'saved' ? (proof?.pattern ?? workerPatterns.get(new URL(at.url).origin)) : undefined;
|
|
4223
4341
|
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
package/dist/vercel.js
CHANGED
|
@@ -103,6 +103,63 @@ export const readDeploymentApi = (link, host) => ['api', `/v13/deployments/${enc
|
|
|
103
103
|
* (https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication#how-to-manage-vercel-authentication-with-the-api).
|
|
104
104
|
*/
|
|
105
105
|
export const lockProjectApi = (link) => projectApi(link, ['--field', 'ssoProtection={"deploymentType":"all"}']);
|
|
106
|
+
/**
|
|
107
|
+
* Vercel's deployments recorded on GitHub, which is where Impeccable finds Vercel's previews: Settings → Git →
|
|
108
|
+
* "deployment_status Events" in the dashboard, `gitProviderOptions.createDeployments` to the API. A project the CLI or
|
|
109
|
+
* the API creates starts with it disabled.
|
|
110
|
+
*/
|
|
111
|
+
export const githubDeploymentsApi = (link) => projectApi(link, ['--field', 'gitProviderOptions={"createDeployments":"enabled"}']);
|
|
112
|
+
/** The dashboard's label for that setting, as a screen or a report names it. */
|
|
113
|
+
export const DEPLOYMENT_EVENTS = 'deployment_status Events';
|
|
114
|
+
/** Why Impeccable sees none of a project's previews while that setting is off. */
|
|
115
|
+
export const DEPLOYMENTS_OFF = `Vercel builds the project's pushes but records no deployment of them on GitHub, where Impeccable finds Vercel's previews: ${DEPLOYMENT_EVENTS} is off under the project's Settings → Git.`;
|
|
116
|
+
/**
|
|
117
|
+
* Turns on the project's deployments on GitHub through the CLI signed in as the person, where they are off, and reads the
|
|
118
|
+
* project back: enabled only once Vercel says so. The project counts only as this repository's (readOwnProject). `page`
|
|
119
|
+
* is the project's Settings → Git, where the person turns it on when Vercel refused the run.
|
|
120
|
+
*/
|
|
121
|
+
export async function enableGithubDeployments(vercel, link, repository, page) {
|
|
122
|
+
const fact = (project) => 'reason' in project
|
|
123
|
+
? { project: '', enabled: 'unknown', reason: project.reason }
|
|
124
|
+
: { project: project.name ?? '', enabled: project.githubDeployments, ...(project.githubDeployments ? {} : { cause: 'previews', reason: DEPLOYMENTS_OFF }) };
|
|
125
|
+
const before = await readOwnProject(vercel, link, repository);
|
|
126
|
+
if ('reason' in before || before.githubDeployments)
|
|
127
|
+
return { fact: fact(before), page, changed: false, ...('reason' in before ? { reason: before.reason } : {}) };
|
|
128
|
+
try {
|
|
129
|
+
await vercel(githubDeploymentsApi(link));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { fact: fact(before), page, changed: false, reason: `Vercel refused to turn on ${DEPLOYMENT_EVENTS} for the project: that takes the Member role or Project Administrator.` };
|
|
133
|
+
}
|
|
134
|
+
const after = await readOwnProject(vercel, link, repository);
|
|
135
|
+
return { fact: fact(after), page, changed: !('reason' in after) && after.githubDeployments };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The deployment a commit status of Vercel's names, read through the CLI: its `target_url` is the deployment's page in
|
|
139
|
+
* Vercel's dashboard, `https://vercel.com/<scope>/<project>/<id>`, whose last part is the deployment's id less `dpl_`.
|
|
140
|
+
* Only a deployment of the linked project, of the commit `sha`, counts: `other` for another project's or commit's, null
|
|
141
|
+
* where it could not be read.
|
|
142
|
+
*/
|
|
143
|
+
export async function deploymentOfStatus(vercel, link, targetUrl, sha) {
|
|
144
|
+
const id = /^https:\/\/vercel\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/([A-Za-z0-9]{8,64})$/.exec(targetUrl)?.[1];
|
|
145
|
+
if (!id)
|
|
146
|
+
return null;
|
|
147
|
+
try {
|
|
148
|
+
const d = await printedProject(vercel, readDeploymentApi(link, `dpl_${id}`));
|
|
149
|
+
const host = typeof d?.url === 'string' && /^[a-z0-9.-]{1,253}$/i.test(d.url) ? d.url.toLowerCase() : undefined;
|
|
150
|
+
if (!d)
|
|
151
|
+
return null;
|
|
152
|
+
// Vercel named another project's deployment, or one of another commit: a status that is not this build's.
|
|
153
|
+
if (d.projectId !== link.projectId || record(d.meta)?.githubCommitSha !== sha)
|
|
154
|
+
return 'other';
|
|
155
|
+
if (!host)
|
|
156
|
+
return null;
|
|
157
|
+
return { url: `https://${host}`, name: typeof d.name === 'string' ? d.name : '', production: d.target === 'production' };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
106
163
|
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
107
164
|
const printedProject = async (vercel, args) => {
|
|
108
165
|
const printed = await vercel(args);
|
|
@@ -268,7 +325,7 @@ export async function vercelPreviewsFact(cwd, repository, vercel, app) {
|
|
|
268
325
|
if (own && 'reason' in own)
|
|
269
326
|
return { ...fact, enabled: 'unknown', reason: own.reason };
|
|
270
327
|
if (project && !project.githubDeployments)
|
|
271
|
-
return { ...fact, enabled: false,
|
|
328
|
+
return { ...fact, enabled: false, cause: 'previews', reason: DEPLOYMENTS_OFF };
|
|
272
329
|
if (project?.ignoredBuildStep)
|
|
273
330
|
return { ...fact, enabled: 'unknown', reason: 'An Ignored Build Step is set on the project (Settings → Git), which decides commit by commit whether a push builds.' };
|
|
274
331
|
return fact;
|