@goodfellow-cms/github 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +33 -5
- package/dist/index.js +320 -27
- package/dist/testing.d.ts +48 -1
- package/dist/testing.js +145 -4
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ import { github } from "@goodfellow-cms/github";
|
|
|
8
8
|
export default defineConfig({ blocks, backend: github({ repo: "your-name/your-site" }) });
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
[Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or
|
|
11
|
+
[Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
|
|
12
12
|
|
|
13
13
|
More in the docs: [Hosts and business sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts).
|
|
14
14
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, RedirectOptions, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, PluginTasks, RedirectOptions, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/sign-in.d.ts
|
|
3
3
|
/** The page's address and how to change it. Replaceable in tests. */
|
|
4
4
|
interface BrowserLocation {
|
|
@@ -20,6 +20,15 @@ interface ApiOptions {
|
|
|
20
20
|
fetch: typeof fetch;
|
|
21
21
|
}
|
|
22
22
|
//#endregion
|
|
23
|
+
//#region src/tasks.d.ts
|
|
24
|
+
interface GitHubTasksOptions {
|
|
25
|
+
/** How often to look for the answer, and how long to wait for it. */
|
|
26
|
+
pollMs?: number;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Waits between looks; tests pass one that doesn't. */
|
|
29
|
+
wait?: (ms: number) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
23
32
|
//#region src/backend.d.ts
|
|
24
33
|
interface GitHubBackendOptions {
|
|
25
34
|
api: ApiOptions;
|
|
@@ -42,6 +51,8 @@ interface GitHubBackendOptions {
|
|
|
42
51
|
};
|
|
43
52
|
/** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
|
|
44
53
|
revision?: string;
|
|
54
|
+
/** How plugins' tasks wait for their answers; tests pass short waits. */
|
|
55
|
+
tasks?: GitHubTasksOptions;
|
|
45
56
|
/** The repository holds only the site's content, with its code in a repository of its own (`GitBackend.contentOnly`). */
|
|
46
57
|
contentOnly?: boolean;
|
|
47
58
|
/**
|
|
@@ -67,6 +78,8 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
67
78
|
/** Absent where the repository holds only content: the update job runs where the code is. */
|
|
68
79
|
readonly updates?: SiteUpdates;
|
|
69
80
|
readonly drafts: SiteDrafts;
|
|
81
|
+
readonly scheduledBuilds: ScheduledBuilds;
|
|
82
|
+
readonly tasks: PluginTasks;
|
|
70
83
|
readonly contentOnly: boolean;
|
|
71
84
|
private readonly options;
|
|
72
85
|
private readonly api;
|
|
@@ -81,9 +94,12 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
81
94
|
private readonly treeSizes;
|
|
82
95
|
private readonly blobs;
|
|
83
96
|
constructor(options: GitHubBackendOptions);
|
|
97
|
+
repositoryVisibility(): Promise<RepositoryVisibility>;
|
|
84
98
|
/** Drafts on branches of the repository, with pull requests for review. */
|
|
85
99
|
private draftHost;
|
|
86
100
|
revision(): Promise<string>;
|
|
101
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
102
|
+
private head;
|
|
87
103
|
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
88
104
|
private tree;
|
|
89
105
|
private currentTree;
|
|
@@ -91,8 +107,11 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
91
107
|
private readFrom;
|
|
92
108
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
93
109
|
readFiles(paths: string[]): Promise<Map<string, string>>;
|
|
110
|
+
private readFilesFrom;
|
|
94
111
|
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
112
|
+
private bytesFrom;
|
|
95
113
|
list(dir: string): Promise<string[]>;
|
|
114
|
+
filesAt(revision: string): RevisionFiles;
|
|
96
115
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
97
116
|
/** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
|
|
98
117
|
useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
|
|
@@ -112,12 +131,21 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
112
131
|
readAt(path: string, revision: string): Promise<string | undefined>;
|
|
113
132
|
changedPaths(from: string, to: string): Promise<string[]>;
|
|
114
133
|
/**
|
|
115
|
-
* Reads the deployment GitHub Pages or Vercel created for a commit,
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* if it has ever been deployed
|
|
134
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit, or what
|
|
135
|
+
* Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
|
|
136
|
+
* that builds it: a build that fails never creates a deployment. Before any
|
|
137
|
+
* exists, the site counts as building if it has ever been deployed, or if
|
|
138
|
+
* Cloudflare reported on the commit before.
|
|
119
139
|
*/
|
|
120
140
|
deployStatus(revision: string): Promise<DeployStatus>;
|
|
141
|
+
/**
|
|
142
|
+
* What Cloudflare Pages reports on a commit: a check run of its GitHub App,
|
|
143
|
+
* or a commit status, which fine-grained tokens can read where check runs
|
|
144
|
+
* need a GitHub App's permission. `undefined` if it reported nothing.
|
|
145
|
+
*/
|
|
146
|
+
private cloudflareBuild;
|
|
147
|
+
/** The commit a commit was made on top of, if the token can see it. */
|
|
148
|
+
private parentOf;
|
|
121
149
|
/** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
|
|
122
150
|
private workflowRun;
|
|
123
151
|
/** Which step of a failed run failed. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, SetupError, SignInError, TASK_BRANCH_PREFIX, TASK_EVENT, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, TASK_WORKFLOW, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, hostSource, nightlyMode, siteVersion, taskRequestProblem } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/api.ts
|
|
3
3
|
/** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
|
|
4
4
|
async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
|
|
@@ -295,6 +295,7 @@ function githubPages(api, repo) {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
return {
|
|
298
|
+
severalDomains: false,
|
|
298
299
|
async site() {
|
|
299
300
|
const found = await pages();
|
|
300
301
|
return found && {
|
|
@@ -328,11 +329,98 @@ function githubPages(api, repo) {
|
|
|
328
329
|
};
|
|
329
330
|
}
|
|
330
331
|
//#endregion
|
|
332
|
+
//#region src/scheduled-builds.ts
|
|
333
|
+
/** The starters' hourly check, which builds the site once a scheduled change has come due. */
|
|
334
|
+
const SCHEDULED_WORKFLOW = ".github/workflows/scheduled.yml";
|
|
335
|
+
/** Workflows that build the site, or ask another host to, each night. */
|
|
336
|
+
const NIGHTLY_WORKFLOWS = [".github/workflows/deploy.yml", ".github/workflows/rebuild.yml"];
|
|
337
|
+
/**
|
|
338
|
+
* Scheduled changes on GitHub go live within the hour after their time, with
|
|
339
|
+
* the starters' hourly check (`scheduled.yml`), which builds the site only
|
|
340
|
+
* once one has come due. Sites set up without it get them with the nightly
|
|
341
|
+
* build.
|
|
342
|
+
*/
|
|
343
|
+
function githubScheduledBuilds(read) {
|
|
344
|
+
return { async timing() {
|
|
345
|
+
const [scheduled, ...nightly] = await Promise.all([SCHEDULED_WORKFLOW, ...NIGHTLY_WORKFLOWS].map(read));
|
|
346
|
+
if (scheduled && /\bdue\b/.test(scheduled) && /\bschedule:/.test(scheduled)) return "hourly";
|
|
347
|
+
if (nightly.some((workflow) => workflow && /\bschedule:/.test(workflow))) return "nightly";
|
|
348
|
+
return "none";
|
|
349
|
+
} };
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/tasks.ts
|
|
353
|
+
/** GitHub's own pages for an API address: `github.com` for its API, the host itself for GitHub Enterprise's `/api/v3`. */
|
|
354
|
+
function webBase(apiUrl) {
|
|
355
|
+
const url = new URL(apiUrl);
|
|
356
|
+
if (url.hostname === "api.github.com") return "https://github.com";
|
|
357
|
+
return `${url.origin}${url.pathname.replace(/\/api\/v3\/?$/, "")}`.replace(/\/+$/, "");
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Plugins' tasks on GitHub, through the starters' `plugin-task.yml`: a
|
|
361
|
+
* `repository_dispatch` event starts it, which editors' tokens can send with
|
|
362
|
+
* the Contents permission they publish with, and its `answer` job leaves the
|
|
363
|
+
* sealed answer on a branch of its own, `goodfellow-task-<request>`, which is
|
|
364
|
+
* read here and deleted. No Actions permission is needed.
|
|
365
|
+
*/
|
|
366
|
+
function githubTasks(api, repo, branch, options = {}) {
|
|
367
|
+
const wait = options.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
368
|
+
const contents = (path, ref) => githubRequest(api, `/repos/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, { allow: [404] });
|
|
369
|
+
const supported = async () => (await contents(`.github/workflows/${TASK_WORKFLOW}`, branch)).ok;
|
|
370
|
+
return {
|
|
371
|
+
supported,
|
|
372
|
+
secretsPlace: async () => ({
|
|
373
|
+
kind: "github",
|
|
374
|
+
url: `${webBase(api.apiUrl)}/${repo}/settings/secrets/actions/new`
|
|
375
|
+
}),
|
|
376
|
+
async run(request) {
|
|
377
|
+
const problem = taskRequestProblem(request);
|
|
378
|
+
if (problem) throw new PluginTaskError("failed", problem);
|
|
379
|
+
if (!await supported()) throw new PluginTaskError("missing", "The site's setup files don't run plugins' tasks: it needs plugin-task.yml.");
|
|
380
|
+
try {
|
|
381
|
+
await githubRequest(api, `/repos/${repo}/dispatches`, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
body: {
|
|
384
|
+
event_type: TASK_EVENT,
|
|
385
|
+
client_payload: {
|
|
386
|
+
request: request.id,
|
|
387
|
+
plugin: request.plugin,
|
|
388
|
+
task: request.task,
|
|
389
|
+
input: request.input,
|
|
390
|
+
key: request.key
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new PluginTaskError("not-allowed", "Running plugins' tasks needs a sign-in that can publish to the site.");
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
const answerBranch = `${TASK_BRANCH_PREFIX}${request.id}`;
|
|
399
|
+
const deadline = Date.now() + (options.timeoutMs ?? TASK_TIMEOUT_MS);
|
|
400
|
+
for (;;) {
|
|
401
|
+
const found = await contents(TASK_RESULT_FILE, answerBranch);
|
|
402
|
+
if (found.ok) {
|
|
403
|
+
const { content = "" } = await found.json();
|
|
404
|
+
await githubRequest(api, `/repos/${repo}/git/refs/heads/${encodeURIComponent(answerBranch)}`, {
|
|
405
|
+
method: "DELETE",
|
|
406
|
+
allow: [404, 422]
|
|
407
|
+
}).catch(() => {});
|
|
408
|
+
return new TextDecoder().decode(decodeBase64(content.replace(/\s/g, "")));
|
|
409
|
+
}
|
|
410
|
+
if (Date.now() >= deadline) throw new PluginTaskError("timeout", "The site's build service didn't answer in time.");
|
|
411
|
+
await wait(options.pollMs ?? TASK_POLL_MS);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
331
417
|
//#region src/updates.ts
|
|
332
418
|
/** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
|
|
333
419
|
const WORKFLOW = "deploy.yml";
|
|
334
420
|
/** The code repository's workflow, where a site keeps its content in a repository of its own. */
|
|
335
421
|
const CODE_WORKFLOW = "update.yml";
|
|
422
|
+
/** The job that publishes the update, which the job that builds the site with it can't. */
|
|
423
|
+
const PUBLISH_JOB = "publish-update";
|
|
336
424
|
/**
|
|
337
425
|
* The site's nightly update, which runs in its GitHub Pages workflow. Running
|
|
338
426
|
* it now dispatches the workflow, which needs GitHub's Actions permission
|
|
@@ -343,7 +431,8 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
|
|
|
343
431
|
const workflowFile = `.github/workflows/${workflow}`;
|
|
344
432
|
return {
|
|
345
433
|
async setup() {
|
|
346
|
-
|
|
434
|
+
const text = await files.read(workflowFile);
|
|
435
|
+
return text && /update --(publish|commit)\b/.test(text) ? "ready" : "missing";
|
|
347
436
|
},
|
|
348
437
|
async lastRun() {
|
|
349
438
|
const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
|
|
@@ -365,6 +454,11 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
|
|
|
365
454
|
state: "failed",
|
|
366
455
|
...base
|
|
367
456
|
};
|
|
457
|
+
const publish = jobs.find((candidate) => candidate.name === PUBLISH_JOB);
|
|
458
|
+
if (publish && publish.conclusion !== "success" && publish.conclusion !== "skipped") return {
|
|
459
|
+
state: "failed",
|
|
460
|
+
...base
|
|
461
|
+
};
|
|
368
462
|
const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
|
|
369
463
|
const now = siteVersion(await files.read("package.json") ?? "");
|
|
370
464
|
if (!before || !now || before === now) return {
|
|
@@ -384,20 +478,73 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
|
|
|
384
478
|
};
|
|
385
479
|
}
|
|
386
480
|
},
|
|
481
|
+
nightly: githubNightly(read, manage, repo, () => files.read(workflowFile)),
|
|
482
|
+
async rebuild() {
|
|
483
|
+
await dispatch({});
|
|
484
|
+
},
|
|
387
485
|
async start(target) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
486
|
+
await dispatch({ update: target });
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
/** Runs the workflow, with an update to install, or none to only build the site. */
|
|
490
|
+
async function dispatch(inputs) {
|
|
491
|
+
try {
|
|
492
|
+
await githubRequest(manage() ?? read, `${path}/dispatches`, {
|
|
493
|
+
method: "POST",
|
|
494
|
+
body: {
|
|
495
|
+
ref: branch,
|
|
496
|
+
inputs
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new UpdatesError("not-allowed", "Running the site's build needs a token that can run its workflows.");
|
|
501
|
+
if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The nightly build setting, kept in the repository variable the workflow's
|
|
508
|
+
* jobs read in their conditions (`GOODFELLOW_NIGHTLY`), which needs GitHub's
|
|
509
|
+
* Variables permission: the owner's sign-in, or a token that has it.
|
|
510
|
+
*/
|
|
511
|
+
function githubNightly(read, manage, repo, workflow) {
|
|
512
|
+
const path = `/repos/${repo}/actions/variables`;
|
|
513
|
+
const notAllowed = (error) => {
|
|
514
|
+
if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new UpdatesError("not-allowed", "Changing when the site rebuilds needs GitHub's Variables permission.");
|
|
515
|
+
throw error;
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
async supported() {
|
|
519
|
+
return (await workflow())?.includes(NIGHTLY_VARIABLE) ?? false;
|
|
520
|
+
},
|
|
521
|
+
async get() {
|
|
522
|
+
const response = await githubRequest(manage() ?? read, `${path}/${NIGHTLY_VARIABLE}`, { allow: [403, 404] });
|
|
523
|
+
if (response.status === 404) return "changed";
|
|
524
|
+
if (!response.ok) return void 0;
|
|
525
|
+
return nightlyMode((await response.json()).value);
|
|
526
|
+
},
|
|
527
|
+
async set(mode) {
|
|
528
|
+
const api = manage() ?? read;
|
|
529
|
+
if (mode === "changed") {
|
|
530
|
+
await githubRequest(api, `${path}/${NIGHTLY_VARIABLE}`, {
|
|
531
|
+
method: "DELETE",
|
|
532
|
+
allow: [404]
|
|
533
|
+
}).catch(notAllowed);
|
|
534
|
+
return;
|
|
400
535
|
}
|
|
536
|
+
const body = {
|
|
537
|
+
name: NIGHTLY_VARIABLE,
|
|
538
|
+
value: mode
|
|
539
|
+
};
|
|
540
|
+
if ((await githubRequest(api, `${path}/${NIGHTLY_VARIABLE}`, {
|
|
541
|
+
method: "PATCH",
|
|
542
|
+
body,
|
|
543
|
+
allow: [404]
|
|
544
|
+
}).catch(notAllowed)).status === 404) await githubRequest(api, path, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
body
|
|
547
|
+
}).catch(notAllowed);
|
|
401
548
|
}
|
|
402
549
|
};
|
|
403
550
|
}
|
|
@@ -465,16 +612,26 @@ function githubCodeUpdates(options) {
|
|
|
465
612
|
},
|
|
466
613
|
async start(target) {
|
|
467
614
|
await (await updates()).start(target);
|
|
615
|
+
},
|
|
616
|
+
async rebuild() {
|
|
617
|
+
await (await updates()).rebuild?.();
|
|
468
618
|
}
|
|
469
619
|
};
|
|
470
620
|
}
|
|
471
621
|
//#endregion
|
|
472
622
|
//#region src/backend.ts
|
|
623
|
+
/** Cloudflare Pages' check runs (its GitHub App is "Cloudflare Workers and Pages") and commit statuses. */
|
|
624
|
+
const CLOUDFLARE = /cloudflare/i;
|
|
473
625
|
/** How many files one GraphQL request reads. */
|
|
474
626
|
const GRAPHQL_BATCH = 100;
|
|
475
627
|
const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
476
628
|
createCommitOnBranch(input: $input) { commit { oid } }
|
|
477
629
|
}`;
|
|
630
|
+
/** The files under a folder of a tree, in order. */
|
|
631
|
+
function listFrom(tree, dir) {
|
|
632
|
+
const prefix = `${dir}/`;
|
|
633
|
+
return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
634
|
+
}
|
|
478
635
|
/**
|
|
479
636
|
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
480
637
|
* returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
|
|
@@ -488,6 +645,8 @@ var GitHubBackend = class GitHubBackend {
|
|
|
488
645
|
/** Absent where the repository holds only content: the update job runs where the code is. */
|
|
489
646
|
updates;
|
|
490
647
|
drafts;
|
|
648
|
+
scheduledBuilds;
|
|
649
|
+
tasks;
|
|
491
650
|
contentOnly;
|
|
492
651
|
options;
|
|
493
652
|
api;
|
|
@@ -532,6 +691,13 @@ var GitHubBackend = class GitHubBackend {
|
|
|
532
691
|
})
|
|
533
692
|
});
|
|
534
693
|
this.drafts = branchDrafts(this, this.draftHost());
|
|
694
|
+
this.scheduledBuilds = githubScheduledBuilds((path) => this.read(path));
|
|
695
|
+
this.tasks = githubTasks(this.api, this.repo, this.branch, options.tasks);
|
|
696
|
+
}
|
|
697
|
+
async repositoryVisibility() {
|
|
698
|
+
const found = await githubJson(this.api, `/repos/${this.repo}`);
|
|
699
|
+
if (found.visibility === "internal") return "internal";
|
|
700
|
+
return found.private === false || found.visibility === "public" ? "public" : "private";
|
|
535
701
|
}
|
|
536
702
|
/** Drafts on branches of the repository, with pull requests for review. */
|
|
537
703
|
draftHost() {
|
|
@@ -564,6 +730,9 @@ var GitHubBackend = class GitHubBackend {
|
|
|
564
730
|
if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
|
|
565
731
|
return (await response.json()).merge_base_commit.sha;
|
|
566
732
|
},
|
|
733
|
+
listBranches: async (prefix) => {
|
|
734
|
+
return (await githubJson(this.api, `${base}/git/matching-refs/heads/${encodeURIComponent(prefix)}`)).map((found) => found.ref.replace(/^refs\/heads\//, ""));
|
|
735
|
+
},
|
|
567
736
|
openReview: async ({ branch, title, description }) => {
|
|
568
737
|
const response = await githubRequest(this.api, `${base}/pulls`, {
|
|
569
738
|
method: "POST",
|
|
@@ -593,10 +762,13 @@ var GitHubBackend = class GitHubBackend {
|
|
|
593
762
|
};
|
|
594
763
|
}
|
|
595
764
|
async revision() {
|
|
596
|
-
|
|
597
|
-
this.pinned = ref.object.sha;
|
|
765
|
+
this.pinned = await this.head();
|
|
598
766
|
return this.pinned;
|
|
599
767
|
}
|
|
768
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
769
|
+
async head() {
|
|
770
|
+
return (await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`)).object.sha;
|
|
771
|
+
}
|
|
600
772
|
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
601
773
|
tree(sha) {
|
|
602
774
|
let tree = this.trees.get(sha);
|
|
@@ -631,7 +803,9 @@ var GitHubBackend = class GitHubBackend {
|
|
|
631
803
|
}
|
|
632
804
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
633
805
|
async readFiles(paths) {
|
|
634
|
-
|
|
806
|
+
return this.readFilesFrom(await this.currentTree(), paths);
|
|
807
|
+
}
|
|
808
|
+
async readFilesFrom(tree, paths) {
|
|
635
809
|
const wanted = paths.flatMap((path) => {
|
|
636
810
|
const sha = tree.get(path);
|
|
637
811
|
return sha ? [[path, sha]] : [];
|
|
@@ -664,14 +838,24 @@ var GitHubBackend = class GitHubBackend {
|
|
|
664
838
|
return result;
|
|
665
839
|
}
|
|
666
840
|
async readBytes(path) {
|
|
667
|
-
|
|
841
|
+
return this.bytesFrom(await this.currentTree(), path);
|
|
842
|
+
}
|
|
843
|
+
async bytesFrom(tree, path) {
|
|
844
|
+
const sha = tree.get(path);
|
|
668
845
|
if (!sha) return void 0;
|
|
669
846
|
const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
|
|
670
847
|
return new Uint8Array(await response.arrayBuffer());
|
|
671
848
|
}
|
|
672
849
|
async list(dir) {
|
|
673
|
-
|
|
674
|
-
|
|
850
|
+
return listFrom(await this.currentTree(), dir);
|
|
851
|
+
}
|
|
852
|
+
filesAt(revision) {
|
|
853
|
+
return {
|
|
854
|
+
read: async (path) => this.readFrom(await this.tree(revision), path),
|
|
855
|
+
readFiles: async (paths) => this.readFilesFrom(await this.tree(revision), paths),
|
|
856
|
+
readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
|
|
857
|
+
list: async (dir) => listFrom(await this.tree(revision), dir)
|
|
858
|
+
};
|
|
675
859
|
}
|
|
676
860
|
async fileSizes(dir) {
|
|
677
861
|
const prefix = `${dir}/`;
|
|
@@ -825,10 +1009,11 @@ var GitHubBackend = class GitHubBackend {
|
|
|
825
1009
|
return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
|
|
826
1010
|
}
|
|
827
1011
|
/**
|
|
828
|
-
* Reads the deployment GitHub Pages or Vercel created for a commit,
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
* if it has ever been deployed
|
|
1012
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit, or what
|
|
1013
|
+
* Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
|
|
1014
|
+
* that builds it: a build that fails never creates a deployment. Before any
|
|
1015
|
+
* exists, the site counts as building if it has ever been deployed, or if
|
|
1016
|
+
* Cloudflare reported on the commit before.
|
|
832
1017
|
*/
|
|
833
1018
|
async deployStatus(revision) {
|
|
834
1019
|
try {
|
|
@@ -857,7 +1042,13 @@ var GitHubBackend = class GitHubBackend {
|
|
|
857
1042
|
};
|
|
858
1043
|
}
|
|
859
1044
|
}
|
|
1045
|
+
const cloudflare = await this.cloudflareBuild(revision);
|
|
1046
|
+
if (cloudflare) return cloudflare;
|
|
860
1047
|
const run = await this.workflowRun(revision);
|
|
1048
|
+
if (run?.status === "completed" && run.conclusion === "cancelled") {
|
|
1049
|
+
const head = await this.head();
|
|
1050
|
+
if (head !== revision) return this.deployStatus(head);
|
|
1051
|
+
}
|
|
861
1052
|
if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
|
|
862
1053
|
state: "failed",
|
|
863
1054
|
detailsUrl: run.html_url,
|
|
@@ -867,12 +1058,76 @@ var GitHubBackend = class GitHubBackend {
|
|
|
867
1058
|
state: "building",
|
|
868
1059
|
detailsUrl: run.html_url
|
|
869
1060
|
};
|
|
870
|
-
|
|
1061
|
+
if ((await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0) return { state: "building" };
|
|
1062
|
+
const parent = await this.parentOf(revision);
|
|
1063
|
+
return { state: parent && await this.cloudflareBuild(parent) ? "building" : "unknown" };
|
|
871
1064
|
} catch (error) {
|
|
872
1065
|
if (error instanceof GitApiError) return { state: "unknown" };
|
|
873
1066
|
throw error;
|
|
874
1067
|
}
|
|
875
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* What Cloudflare Pages reports on a commit: a check run of its GitHub App,
|
|
1071
|
+
* or a commit status, which fine-grained tokens can read where check runs
|
|
1072
|
+
* need a GitHub App's permission. `undefined` if it reported nothing.
|
|
1073
|
+
*/
|
|
1074
|
+
async cloudflareBuild(revision) {
|
|
1075
|
+
const checks = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/check-runs?per_page=50`, { allow: [403, 404] });
|
|
1076
|
+
if (checks.ok) {
|
|
1077
|
+
const { check_runs: found = [] } = await checks.json();
|
|
1078
|
+
const run = found.find((candidate) => CLOUDFLARE.test(`${candidate.app?.slug ?? ""} ${candidate.name}`));
|
|
1079
|
+
if (run) {
|
|
1080
|
+
const detailsUrl = run.details_url ?? run.html_url ?? void 0;
|
|
1081
|
+
if (run.status !== "completed") return {
|
|
1082
|
+
state: "building",
|
|
1083
|
+
detailsUrl
|
|
1084
|
+
};
|
|
1085
|
+
if (run.conclusion === "success" || run.conclusion === "neutral" || run.conclusion === "skipped") return {
|
|
1086
|
+
state: "live",
|
|
1087
|
+
detailsUrl
|
|
1088
|
+
};
|
|
1089
|
+
return {
|
|
1090
|
+
state: "failed",
|
|
1091
|
+
detailsUrl,
|
|
1092
|
+
problem: {
|
|
1093
|
+
step: "build",
|
|
1094
|
+
detail: run.output?.title ?? void 0
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const statuses = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/statuses?per_page=50`, { allow: [403, 404] });
|
|
1100
|
+
if (!statuses.ok) return void 0;
|
|
1101
|
+
const status = (await statuses.json()).find((candidate) => CLOUDFLARE.test(candidate.context));
|
|
1102
|
+
if (!status) return void 0;
|
|
1103
|
+
const detailsUrl = status.target_url ?? void 0;
|
|
1104
|
+
if (status.state === "success") return {
|
|
1105
|
+
state: "live",
|
|
1106
|
+
detailsUrl
|
|
1107
|
+
};
|
|
1108
|
+
if (status.state === "pending") return {
|
|
1109
|
+
state: "building",
|
|
1110
|
+
detailsUrl
|
|
1111
|
+
};
|
|
1112
|
+
return {
|
|
1113
|
+
state: "failed",
|
|
1114
|
+
detailsUrl,
|
|
1115
|
+
problem: {
|
|
1116
|
+
step: "build",
|
|
1117
|
+
detail: status.description ?? void 0
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/** The commit a commit was made on top of, if the token can see it. */
|
|
1122
|
+
async parentOf(revision) {
|
|
1123
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}`, { allow: [
|
|
1124
|
+
403,
|
|
1125
|
+
404,
|
|
1126
|
+
422
|
|
1127
|
+
] });
|
|
1128
|
+
if (!response.ok) return void 0;
|
|
1129
|
+
return (await response.json()).parents?.[0]?.sha;
|
|
1130
|
+
}
|
|
876
1131
|
/** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
|
|
877
1132
|
async workflowRun(revision) {
|
|
878
1133
|
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
|
|
@@ -1143,6 +1398,25 @@ function setupAccount(creator) {
|
|
|
1143
1398
|
throw error;
|
|
1144
1399
|
}
|
|
1145
1400
|
}
|
|
1401
|
+
/** Gives the repository's Pages a domain of its own, saying whether GitHub took it. */
|
|
1402
|
+
async function setDomain(api, repo, domain) {
|
|
1403
|
+
try {
|
|
1404
|
+
await githubRequest(api, `/repos/${repoPath(repo.full_name)}/pages`, {
|
|
1405
|
+
method: "PUT",
|
|
1406
|
+
body: { cname: domain }
|
|
1407
|
+
});
|
|
1408
|
+
return true;
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (error instanceof GitApiError && [
|
|
1411
|
+
400,
|
|
1412
|
+
403,
|
|
1413
|
+
404,
|
|
1414
|
+
409,
|
|
1415
|
+
422
|
|
1416
|
+
].includes(error.status)) return false;
|
|
1417
|
+
throw error;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1146
1420
|
/** Writes every file in one commit, which replaces the starting README rather than following it. */
|
|
1147
1421
|
async function writeFiles(api, repo, site) {
|
|
1148
1422
|
const base = `/repos/${repoPath(repo.full_name)}`;
|
|
@@ -1287,6 +1561,10 @@ function setupAccount(creator) {
|
|
|
1287
1561
|
onStep?.("pages");
|
|
1288
1562
|
siteUrl = await turnOnPages(api, repo);
|
|
1289
1563
|
if (!siteUrl) warnings.push("pages-unavailable");
|
|
1564
|
+
else if (site.domain) {
|
|
1565
|
+
if (await setDomain(api, repo, site.domain)) siteUrl = `https://${site.domain}/`;
|
|
1566
|
+
else warnings.push("domain-unavailable");
|
|
1567
|
+
}
|
|
1290
1568
|
}
|
|
1291
1569
|
createdApis.set(repo.full_name.toLowerCase(), api);
|
|
1292
1570
|
if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
|
|
@@ -1504,7 +1782,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
|
1504
1782
|
expires_in: "90",
|
|
1505
1783
|
contents: "write",
|
|
1506
1784
|
deployments: "read",
|
|
1507
|
-
actions: "read"
|
|
1785
|
+
actions: "read",
|
|
1786
|
+
statuses: "read"
|
|
1508
1787
|
});
|
|
1509
1788
|
const classic = new URLSearchParams({
|
|
1510
1789
|
scopes: "repo",
|
|
@@ -1517,7 +1796,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
|
1517
1796
|
expires_in: "1",
|
|
1518
1797
|
administration: "write",
|
|
1519
1798
|
pages: "write",
|
|
1520
|
-
actions: "write"
|
|
1799
|
+
actions: "write",
|
|
1800
|
+
variables: "write"
|
|
1521
1801
|
});
|
|
1522
1802
|
return {
|
|
1523
1803
|
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
@@ -1768,7 +2048,20 @@ function github(options) {
|
|
|
1768
2048
|
}
|
|
1769
2049
|
return {
|
|
1770
2050
|
name: "GitHub",
|
|
2051
|
+
source: hostSource("@goodfellow-cms/github", "github", options, [
|
|
2052
|
+
"fetch",
|
|
2053
|
+
"storage",
|
|
2054
|
+
"location"
|
|
2055
|
+
]),
|
|
1771
2056
|
canRedirect: service !== void 0,
|
|
2057
|
+
setup: () => githubSetup({
|
|
2058
|
+
...options.apiUrl && { apiUrl: options.apiUrl },
|
|
2059
|
+
...options.webUrl && { webUrl: options.webUrl },
|
|
2060
|
+
...options.fetch && { fetch: options.fetch },
|
|
2061
|
+
...options.storage && { storage: options.storage },
|
|
2062
|
+
...options.location && { location: options.location },
|
|
2063
|
+
signIn: false
|
|
2064
|
+
}),
|
|
1772
2065
|
tokenLinks: [{
|
|
1773
2066
|
url: links.fineGrained,
|
|
1774
2067
|
label: "signIn.token.create"
|
package/dist/testing.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface FakeGitHubUser {
|
|
|
20
20
|
actions?: boolean;
|
|
21
21
|
/** Whether the token may run workflows, as owner tokens can. Defaults to the `administration` setting. */
|
|
22
22
|
actionsWrite?: boolean;
|
|
23
|
+
/** Whether the token may read and change Actions variables, as owner tokens can. Defaults to the `administration` setting. */
|
|
24
|
+
variables?: boolean;
|
|
23
25
|
/** Whether the token may set Actions secrets and add deploy keys. Defaults to true. */
|
|
24
26
|
secrets?: boolean;
|
|
25
27
|
}
|
|
@@ -74,6 +76,17 @@ export interface FakeGitHubOptions {
|
|
|
74
76
|
* reports success after being checked this many times. Off by default.
|
|
75
77
|
*/
|
|
76
78
|
deployAfterChecks?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Simulates Cloudflare Pages: every commit to the default branch gets a
|
|
81
|
+
* build, which reports success after being checked this many times, as a
|
|
82
|
+
* check run of Cloudflare's GitHub App, or as a commit status. Off by default.
|
|
83
|
+
*/
|
|
84
|
+
cloudflare?: {
|
|
85
|
+
afterChecks: number;
|
|
86
|
+
report?: "check-runs" | "statuses";
|
|
87
|
+
};
|
|
88
|
+
/** Answers 403 to reading check runs, as GitHub does for fine-grained tokens, which can't have the permission. */
|
|
89
|
+
checkRunsForbidden?: boolean;
|
|
77
90
|
/** Organizations the users belong to, which new repositories can be created in. */
|
|
78
91
|
orgs?: string[];
|
|
79
92
|
/** The default branch of repositories created through the API, as an account's settings choose. Defaults to `main`. */
|
|
@@ -88,6 +101,23 @@ export interface FakeGitHubOptions {
|
|
|
88
101
|
accounts?: string[];
|
|
89
102
|
/** GitHub Apps, which the sign-in worker signs people in with. */
|
|
90
103
|
apps?: FakeGitHubApp[];
|
|
104
|
+
/** Whether the site's repository is private. Public by default. */
|
|
105
|
+
private?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Answers plugins' tasks, as the site's `plugin-task.yml` would: given each
|
|
108
|
+
* `repository_dispatch` event's payload, it gives the text the workflow's
|
|
109
|
+
* `answer` job leaves on the request's branch, or nothing for a workflow
|
|
110
|
+
* that never answers. Without it, events are only recorded.
|
|
111
|
+
*/
|
|
112
|
+
tasks?: (payload: FakeTaskPayload) => string | undefined | Promise<string | undefined>;
|
|
113
|
+
}
|
|
114
|
+
/** A plugin task's `repository_dispatch` payload, as the admin panel sends it. */
|
|
115
|
+
export interface FakeTaskPayload {
|
|
116
|
+
request: string;
|
|
117
|
+
plugin: string;
|
|
118
|
+
task: string;
|
|
119
|
+
input: string;
|
|
120
|
+
key: string;
|
|
91
121
|
}
|
|
92
122
|
/** A GitHub App, as the sign-in worker uses one: its JWTs, installations, OAuth sign-in and tokens. */
|
|
93
123
|
export interface FakeGitHubApp {
|
|
@@ -129,6 +159,7 @@ export interface FakeCreatedRepo {
|
|
|
129
159
|
repo: FakeRepo;
|
|
130
160
|
pages?: {
|
|
131
161
|
build_type: string;
|
|
162
|
+
cname?: string;
|
|
132
163
|
};
|
|
133
164
|
rulesets: unknown[];
|
|
134
165
|
/** Deploy keys added to it, by their public key. */
|
|
@@ -150,6 +181,11 @@ interface Deployment {
|
|
|
150
181
|
statuses: FakeDeploymentStatus[];
|
|
151
182
|
checks: number;
|
|
152
183
|
}
|
|
184
|
+
/** A build Cloudflare Pages reports on a commit, which tests can fail with `failCloudflare()`. */
|
|
185
|
+
export interface FakeCloudflareBuild {
|
|
186
|
+
checks: number;
|
|
187
|
+
failed?: boolean;
|
|
188
|
+
}
|
|
153
189
|
export declare function fakeGitHub(options: FakeGitHubOptions): {
|
|
154
190
|
repo: FakeRepo;
|
|
155
191
|
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
@@ -182,9 +218,20 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
|
|
|
182
218
|
invitations: FakeInvitation[];
|
|
183
219
|
accept: (login: string) => void;
|
|
184
220
|
deployments: Deployment[];
|
|
185
|
-
/**
|
|
221
|
+
/** Cloudflare Pages' builds, by commit, with `cloudflare`; `failCloudflare()` makes one fail. */
|
|
222
|
+
cloudflareBuilds: Map<string, FakeCloudflareBuild>;
|
|
223
|
+
failCloudflare: (sha: string) => void;
|
|
224
|
+
/** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
186
225
|
runs: FakeRun[];
|
|
226
|
+
/** `repository_dispatch` events sent, such as plugins' tasks. */
|
|
227
|
+
dispatches: {
|
|
228
|
+
event_type: string;
|
|
229
|
+
client_payload: FakeTaskPayload;
|
|
230
|
+
}[];
|
|
231
|
+
/** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
|
|
232
|
+
variables: Map<string, string>;
|
|
187
233
|
failBuild: (sha: string, step?: string, job?: string) => FakeRun;
|
|
234
|
+
cancelBuild: (sha: string) => FakeRun;
|
|
188
235
|
/** Every request received, for checking what the backend sent. */
|
|
189
236
|
requests: Request[];
|
|
190
237
|
/** Simulates someone else publishing. */
|
package/dist/testing.js
CHANGED
|
@@ -53,7 +53,12 @@ function fakeGitHub(options) {
|
|
|
53
53
|
let nextToken = 1;
|
|
54
54
|
let nextRepoId = 100;
|
|
55
55
|
const deployments = [];
|
|
56
|
+
const cloudflareBuilds = /* @__PURE__ */ new Map();
|
|
56
57
|
const runs = [];
|
|
58
|
+
/** `repository_dispatch` events, in the order they were sent. */
|
|
59
|
+
const dispatches = [];
|
|
60
|
+
/** The repository's Actions variables, by name. */
|
|
61
|
+
const variables = /* @__PURE__ */ new Map();
|
|
57
62
|
const requests = [];
|
|
58
63
|
let nextDeployment = 1;
|
|
59
64
|
let graphqlReads = 0;
|
|
@@ -187,6 +192,23 @@ function fakeGitHub(options) {
|
|
|
187
192
|
runs.push(run);
|
|
188
193
|
return run;
|
|
189
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Simulates the deploy workflow's run for a commit being cancelled, as a
|
|
197
|
+
* newer run in its concurrency group replaces one still waiting: it never
|
|
198
|
+
* deploys.
|
|
199
|
+
*/
|
|
200
|
+
function cancelBuild(sha) {
|
|
201
|
+
for (const [index, deployment] of [...deployments.entries()].reverse()) if (deployment.sha === sha) deployments.splice(index, 1);
|
|
202
|
+
const run = {
|
|
203
|
+
id: runs.length + 1,
|
|
204
|
+
sha,
|
|
205
|
+
status: "completed",
|
|
206
|
+
conclusion: "cancelled",
|
|
207
|
+
jobs: []
|
|
208
|
+
};
|
|
209
|
+
runs.push(run);
|
|
210
|
+
return run;
|
|
211
|
+
}
|
|
190
212
|
/** Simulates an invited person accepting the invitation. */
|
|
191
213
|
function accept(login) {
|
|
192
214
|
const index = invitations.findIndex((invitation) => invitation.login === login);
|
|
@@ -333,6 +355,12 @@ function fakeGitHub(options) {
|
|
|
333
355
|
build_type: site.pages.build_type
|
|
334
356
|
}, 201);
|
|
335
357
|
}
|
|
358
|
+
if (rest === "/pages" && method === "PUT" && site.pages) {
|
|
359
|
+
const body = await request.json();
|
|
360
|
+
if (body.cname && options.takenDomains?.includes(body.cname)) return json({ message: `The CNAME \`${body.cname}\` is already taken.` }, 422);
|
|
361
|
+
if (body.cname) site.pages.cname = body.cname;
|
|
362
|
+
return new Response(null, { status: 204 });
|
|
363
|
+
}
|
|
336
364
|
const notAllowed = (permission) => principal.app ? principal.permissions?.[permission] !== "write" : principal.secrets === false;
|
|
337
365
|
if (rest === "/keys" && method === "POST") {
|
|
338
366
|
if (notAllowed("administration")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
@@ -447,6 +475,7 @@ function fakeGitHub(options) {
|
|
|
447
475
|
}
|
|
448
476
|
}
|
|
449
477
|
function deploy(sha) {
|
|
478
|
+
if (options.cloudflare) cloudflareBuilds.set(sha, { checks: 0 });
|
|
450
479
|
if (options.deployAfterChecks === void 0) return;
|
|
451
480
|
deployments.unshift({
|
|
452
481
|
id: nextDeployment++,
|
|
@@ -455,6 +484,20 @@ function fakeGitHub(options) {
|
|
|
455
484
|
checks: 0
|
|
456
485
|
});
|
|
457
486
|
}
|
|
487
|
+
/** Where a Cloudflare build is up to, counting the check. */
|
|
488
|
+
function cloudflareState(sha) {
|
|
489
|
+
const build = cloudflareBuilds.get(sha);
|
|
490
|
+
if (!build) return void 0;
|
|
491
|
+
build.checks += 1;
|
|
492
|
+
if (build.failed) return "failure";
|
|
493
|
+
return build.checks > (options.cloudflare?.afterChecks ?? 0) ? "success" : "building";
|
|
494
|
+
}
|
|
495
|
+
function failCloudflare(sha) {
|
|
496
|
+
cloudflareBuilds.set(sha, {
|
|
497
|
+
checks: 0,
|
|
498
|
+
failed: true
|
|
499
|
+
});
|
|
500
|
+
}
|
|
458
501
|
function commit(changes, message, expectedHead, author, branch = repo.defaultBranch) {
|
|
459
502
|
const sha = repo.commit(changes, message, {
|
|
460
503
|
expectedHead,
|
|
@@ -517,7 +560,8 @@ function fakeGitHub(options) {
|
|
|
517
560
|
pages: app.permissions.pages === "write",
|
|
518
561
|
workflow: app.permissions.workflows === "write",
|
|
519
562
|
actions: app.permissions.actions !== void 0,
|
|
520
|
-
actionsWrite: app.permissions.actions === "write"
|
|
563
|
+
actionsWrite: app.permissions.actions === "write",
|
|
564
|
+
variables: app.permissions.variables === "write"
|
|
521
565
|
});
|
|
522
566
|
refreshTokens.set(refresh, {
|
|
523
567
|
app,
|
|
@@ -625,7 +669,8 @@ function fakeGitHub(options) {
|
|
|
625
669
|
administration: permissions.administration === "write",
|
|
626
670
|
admin: permissions.administration === "write",
|
|
627
671
|
actions: permissions.actions !== void 0,
|
|
628
|
-
actionsWrite: permissions.actions === "write"
|
|
672
|
+
actionsWrite: permissions.actions === "write",
|
|
673
|
+
variables: permissions.variables === "write"
|
|
629
674
|
});
|
|
630
675
|
issued.push({
|
|
631
676
|
token,
|
|
@@ -768,7 +813,9 @@ function fakeGitHub(options) {
|
|
|
768
813
|
const contents = rest.match(/^\/contents\/(.+)$/);
|
|
769
814
|
if (contents && request.method === "GET") {
|
|
770
815
|
if (user.bot && !user.permissions?.contents) return json({ message: "Resource not accessible by integration" }, 403);
|
|
771
|
-
const
|
|
816
|
+
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
817
|
+
if (!repo.branches.has(ref) && !repo.commitAt(ref)) return json({ message: `No commit found for the ref ${ref}` }, 404);
|
|
818
|
+
const file = repo.files(ref).get(decodeURIComponent(contents[1] ?? ""));
|
|
772
819
|
if (file === void 0) return json({ message: "Not Found" }, 404);
|
|
773
820
|
return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(file)) : json({
|
|
774
821
|
content: encodeBase64Bytes(fakeFileBytes(file)),
|
|
@@ -789,6 +836,17 @@ function fakeGitHub(options) {
|
|
|
789
836
|
object: { sha: body.sha }
|
|
790
837
|
}, 201);
|
|
791
838
|
}
|
|
839
|
+
const matching = rest.match(/^\/git\/matching-refs\/heads\/(.*)$/);
|
|
840
|
+
if (matching) {
|
|
841
|
+
const prefix = decodeURIComponent(matching[1] ?? "");
|
|
842
|
+
return json([...repo.branches].filter(([branch]) => branch.startsWith(prefix)).map(([branch, sha]) => ({
|
|
843
|
+
ref: `refs/heads/${branch}`,
|
|
844
|
+
object: {
|
|
845
|
+
sha,
|
|
846
|
+
type: "commit"
|
|
847
|
+
}
|
|
848
|
+
})));
|
|
849
|
+
}
|
|
792
850
|
const branchRef = rest.match(/^\/git\/refs\/heads\/(.+)$/);
|
|
793
851
|
if (branchRef && request.method === "DELETE") {
|
|
794
852
|
if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
@@ -846,6 +904,8 @@ function fakeGitHub(options) {
|
|
|
846
904
|
if (rest === "") return json({
|
|
847
905
|
id: 1,
|
|
848
906
|
full_name: options.repo,
|
|
907
|
+
private: options.private === true,
|
|
908
|
+
visibility: options.private === true ? "private" : "public",
|
|
849
909
|
default_branch: repo.defaultBranch,
|
|
850
910
|
permissions: {
|
|
851
911
|
push: user.push !== false,
|
|
@@ -904,6 +964,42 @@ function fakeGitHub(options) {
|
|
|
904
964
|
author: null
|
|
905
965
|
})));
|
|
906
966
|
}
|
|
967
|
+
const variable = rest.match(/^\/actions\/variables(?:\/([\w-]+))?$/);
|
|
968
|
+
if (variable) {
|
|
969
|
+
if (!(user.variables ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
970
|
+
const [, name] = variable;
|
|
971
|
+
if (!name && request.method === "POST") {
|
|
972
|
+
const body = await request.json();
|
|
973
|
+
if (variables.has(body.name)) return json({ message: "Already exists" }, 409);
|
|
974
|
+
variables.set(body.name, body.value);
|
|
975
|
+
return new Response(null, { status: 201 });
|
|
976
|
+
}
|
|
977
|
+
if (name && !variables.has(name)) return json({ message: "Not Found" }, 404);
|
|
978
|
+
if (name && request.method === "GET") return json({
|
|
979
|
+
name,
|
|
980
|
+
value: variables.get(name)
|
|
981
|
+
});
|
|
982
|
+
if (name && request.method === "PATCH") {
|
|
983
|
+
const body = await request.json();
|
|
984
|
+
variables.set(name, body.value);
|
|
985
|
+
return new Response(null, { status: 204 });
|
|
986
|
+
}
|
|
987
|
+
if (name && request.method === "DELETE") {
|
|
988
|
+
variables.delete(name);
|
|
989
|
+
return new Response(null, { status: 204 });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (rest === "/dispatches" && request.method === "POST") {
|
|
993
|
+
if (user.push === false || user.bot && user.permissions?.contents !== "write") return json({ message: "Resource not accessible by integration" }, 403);
|
|
994
|
+
const body = await request.json();
|
|
995
|
+
dispatches.push(body);
|
|
996
|
+
const answer = await options.tasks?.(body.client_payload);
|
|
997
|
+
if (answer !== void 0 && body.event_type === "goodfellow-plugin-task") {
|
|
998
|
+
const sha = repo.addCommit(void 0, `The answer to plugin task ${body.client_payload.request}`, /* @__PURE__ */ new Map([["goodfellow-task-result.json", `${answer}\n`]]));
|
|
999
|
+
repo.branches.set(`goodfellow-task-${body.client_payload.request}`, sha);
|
|
1000
|
+
}
|
|
1001
|
+
return new Response(null, { status: 204 });
|
|
1002
|
+
}
|
|
907
1003
|
const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
|
|
908
1004
|
const workflowPath = `.github/workflows/${workflowFile}`;
|
|
909
1005
|
const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
|
|
@@ -976,6 +1072,43 @@ function fakeGitHub(options) {
|
|
|
976
1072
|
files: repo.changedPaths(base, b).map((filename) => ({ filename }))
|
|
977
1073
|
});
|
|
978
1074
|
}
|
|
1075
|
+
const commitSha = rest.match(/^\/commits\/([0-9a-f]{40})$/)?.[1];
|
|
1076
|
+
if (commitSha) {
|
|
1077
|
+
const found = repo.commitAt(commitSha);
|
|
1078
|
+
if (!found) return json({ message: "No commit found for SHA" }, 422);
|
|
1079
|
+
return json({
|
|
1080
|
+
sha: commitSha,
|
|
1081
|
+
parents: found.parent ? [{ sha: found.parent }] : []
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const checkRunsSha = rest.match(/^\/commits\/([0-9a-f]{40})\/check-runs$/)?.[1];
|
|
1085
|
+
if (checkRunsSha) {
|
|
1086
|
+
if (options.checkRunsForbidden) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
1087
|
+
const state = options.cloudflare?.report === "statuses" ? void 0 : cloudflareState(checkRunsSha);
|
|
1088
|
+
const runsFound = state ? [{
|
|
1089
|
+
id: 1,
|
|
1090
|
+
name: "Cloudflare Pages",
|
|
1091
|
+
app: { slug: "cloudflare-workers-and-pages" },
|
|
1092
|
+
status: state === "building" ? "in_progress" : "completed",
|
|
1093
|
+
conclusion: state === "building" ? null : state,
|
|
1094
|
+
details_url: `https://dash.cloudflare.com/builds/${checkRunsSha}`,
|
|
1095
|
+
output: { title: state === "failure" ? "Build failed" : "Deployed" }
|
|
1096
|
+
}] : [];
|
|
1097
|
+
return json({
|
|
1098
|
+
total_count: runsFound.length,
|
|
1099
|
+
check_runs: runsFound
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
const statusesSha = rest.match(/^\/commits\/([0-9a-f]{40})\/statuses$/)?.[1];
|
|
1103
|
+
if (statusesSha) {
|
|
1104
|
+
const state = options.cloudflare?.report === "statuses" ? cloudflareState(statusesSha) : void 0;
|
|
1105
|
+
return json(state ? [{
|
|
1106
|
+
context: "Cloudflare Pages",
|
|
1107
|
+
state: state === "building" ? "pending" : state,
|
|
1108
|
+
target_url: `https://dash.cloudflare.com/builds/${statusesSha}`,
|
|
1109
|
+
description: state === "failure" ? "Build failed" : "Deployed"
|
|
1110
|
+
}] : []);
|
|
1111
|
+
}
|
|
979
1112
|
if (rest === "/deployments") {
|
|
980
1113
|
const sha = url.searchParams.get("sha");
|
|
981
1114
|
return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
|
|
@@ -1018,9 +1151,17 @@ function fakeGitHub(options) {
|
|
|
1018
1151
|
invitations,
|
|
1019
1152
|
accept,
|
|
1020
1153
|
deployments,
|
|
1021
|
-
/**
|
|
1154
|
+
/** Cloudflare Pages' builds, by commit, with `cloudflare`; `failCloudflare()` makes one fail. */
|
|
1155
|
+
cloudflareBuilds,
|
|
1156
|
+
failCloudflare,
|
|
1157
|
+
/** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
1022
1158
|
runs,
|
|
1159
|
+
/** `repository_dispatch` events sent, such as plugins' tasks. */
|
|
1160
|
+
dispatches,
|
|
1161
|
+
/** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
|
|
1162
|
+
variables,
|
|
1023
1163
|
failBuild,
|
|
1164
|
+
cancelBuild,
|
|
1024
1165
|
/** Every request received, for checking what the backend sent. */
|
|
1025
1166
|
requests,
|
|
1026
1167
|
/** Simulates someone else publishing. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -35,15 +35,15 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@goodfellow-cms/core": "0.6.0",
|
|
38
39
|
"@noble/ciphers": "2.4.0",
|
|
39
|
-
"@noble/hashes": "2.4.0"
|
|
40
|
-
"@goodfellow-cms/core": "0.4.0"
|
|
40
|
+
"@noble/hashes": "2.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@puckeditor/core": "0.23.0"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
|
-
"node": ">=
|
|
46
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public"
|