@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 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 GitLab 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.
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, and for
116
- * GitHub Actions, the workflow run that builds it: a build that fails never
117
- * creates a deployment. Before either exists, the site counts as building
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
- return (await files.read(workflowFile))?.includes("update --publish") ? "ready" : "missing";
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
- try {
389
- await githubRequest(manage() ?? read, `${path}/dispatches`, {
390
- method: "POST",
391
- body: {
392
- ref: branch,
393
- inputs: { update: target }
394
- }
395
- });
396
- } catch (error) {
397
- if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new UpdatesError("not-allowed", "Running the update needs a token that can run the site's workflows.");
398
- if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
399
- throw error;
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
- const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
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
- const tree = await this.currentTree();
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
- const sha = (await this.currentTree()).get(path);
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
- const prefix = `${dir}/`;
674
- return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
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, and for
829
- * GitHub Actions, the workflow run that builds it: a build that fails never
830
- * creates a deployment. Before either exists, the site counts as building
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
- return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
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
- /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
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 file = repo.files(url.searchParams.get("ref") ?? repo.defaultBranch).get(decodeURIComponent(contents[1] ?? ""));
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
- /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
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.4.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": ">=22"
46
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public"