@goodfellow-cms/github 0.4.0 → 0.5.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/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, 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 {
@@ -67,6 +67,7 @@ export declare class GitHubBackend implements GitBackend {
67
67
  /** Absent where the repository holds only content: the update job runs where the code is. */
68
68
  readonly updates?: SiteUpdates;
69
69
  readonly drafts: SiteDrafts;
70
+ readonly scheduledBuilds: ScheduledBuilds;
70
71
  readonly contentOnly: boolean;
71
72
  private readonly options;
72
73
  private readonly api;
@@ -81,9 +82,12 @@ export declare class GitHubBackend implements GitBackend {
81
82
  private readonly treeSizes;
82
83
  private readonly blobs;
83
84
  constructor(options: GitHubBackendOptions);
85
+ repositoryVisibility(): Promise<RepositoryVisibility>;
84
86
  /** Drafts on branches of the repository, with pull requests for review. */
85
87
  private draftHost;
86
88
  revision(): Promise<string>;
89
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
90
+ private head;
87
91
  /** Path → blob id for every file at a commit. Commits never change, so this is cached. */
88
92
  private tree;
89
93
  private currentTree;
@@ -91,8 +95,11 @@ export declare class GitHubBackend implements GitBackend {
91
95
  private readFrom;
92
96
  /** Reads many text files in a few GraphQL requests rather than one request each. */
93
97
  readFiles(paths: string[]): Promise<Map<string, string>>;
98
+ private readFilesFrom;
94
99
  readBytes(path: string): Promise<Uint8Array | undefined>;
100
+ private bytesFrom;
95
101
  list(dir: string): Promise<string[]>;
102
+ filesAt(revision: string): RevisionFiles;
96
103
  fileSizes(dir: string): Promise<Map<string, number>>;
97
104
  /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
98
105
  useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
@@ -112,12 +119,21 @@ export declare class GitHubBackend implements GitBackend {
112
119
  readAt(path: string, revision: string): Promise<string | undefined>;
113
120
  changedPaths(from: string, to: string): Promise<string[]>;
114
121
  /**
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.
122
+ * Reads the deployment GitHub Pages or Vercel created for a commit, or what
123
+ * Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
124
+ * that builds it: a build that fails never creates a deployment. Before any
125
+ * exists, the site counts as building if it has ever been deployed, or if
126
+ * Cloudflare reported on the commit before.
119
127
  */
120
128
  deployStatus(revision: string): Promise<DeployStatus>;
129
+ /**
130
+ * What Cloudflare Pages reports on a commit: a check run of its GitHub App,
131
+ * or a commit status, which fine-grained tokens can read where check runs
132
+ * need a GitHub App's permission. `undefined` if it reported nothing.
133
+ */
134
+ private cloudflareBuild;
135
+ /** The commit a commit was made on top of, if the token can see it. */
136
+ private parentOf;
121
137
  /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
122
138
  private workflowRun;
123
139
  /** 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, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, hostSource, nightlyMode, siteVersion } 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,33 @@ 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
331
352
  //#region src/updates.ts
332
353
  /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
333
354
  const WORKFLOW = "deploy.yml";
334
355
  /** The code repository's workflow, where a site keeps its content in a repository of its own. */
335
356
  const CODE_WORKFLOW = "update.yml";
357
+ /** The job that publishes the update, which the job that builds the site with it can't. */
358
+ const PUBLISH_JOB = "publish-update";
336
359
  /**
337
360
  * The site's nightly update, which runs in its GitHub Pages workflow. Running
338
361
  * it now dispatches the workflow, which needs GitHub's Actions permission
@@ -343,7 +366,8 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
343
366
  const workflowFile = `.github/workflows/${workflow}`;
344
367
  return {
345
368
  async setup() {
346
- return (await files.read(workflowFile))?.includes("update --publish") ? "ready" : "missing";
369
+ const text = await files.read(workflowFile);
370
+ return text && /update --(publish|commit)\b/.test(text) ? "ready" : "missing";
347
371
  },
348
372
  async lastRun() {
349
373
  const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
@@ -365,6 +389,11 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
365
389
  state: "failed",
366
390
  ...base
367
391
  };
392
+ const publish = jobs.find((candidate) => candidate.name === PUBLISH_JOB);
393
+ if (publish && publish.conclusion !== "success" && publish.conclusion !== "skipped") return {
394
+ state: "failed",
395
+ ...base
396
+ };
368
397
  const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
369
398
  const now = siteVersion(await files.read("package.json") ?? "");
370
399
  if (!before || !now || before === now) return {
@@ -384,20 +413,73 @@ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
384
413
  };
385
414
  }
386
415
  },
416
+ nightly: githubNightly(read, manage, repo, () => files.read(workflowFile)),
417
+ async rebuild() {
418
+ await dispatch({});
419
+ },
387
420
  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;
421
+ await dispatch({ update: target });
422
+ }
423
+ };
424
+ /** Runs the workflow, with an update to install, or none to only build the site. */
425
+ async function dispatch(inputs) {
426
+ try {
427
+ await githubRequest(manage() ?? read, `${path}/dispatches`, {
428
+ method: "POST",
429
+ body: {
430
+ ref: branch,
431
+ inputs
432
+ }
433
+ });
434
+ } catch (error) {
435
+ 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.");
436
+ if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
437
+ throw error;
438
+ }
439
+ }
440
+ }
441
+ /**
442
+ * The nightly build setting, kept in the repository variable the workflow's
443
+ * jobs read in their conditions (`GOODFELLOW_NIGHTLY`), which needs GitHub's
444
+ * Variables permission: the owner's sign-in, or a token that has it.
445
+ */
446
+ function githubNightly(read, manage, repo, workflow) {
447
+ const path = `/repos/${repo}/actions/variables`;
448
+ const notAllowed = (error) => {
449
+ 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.");
450
+ throw error;
451
+ };
452
+ return {
453
+ async supported() {
454
+ return (await workflow())?.includes(NIGHTLY_VARIABLE) ?? false;
455
+ },
456
+ async get() {
457
+ const response = await githubRequest(manage() ?? read, `${path}/${NIGHTLY_VARIABLE}`, { allow: [403, 404] });
458
+ if (response.status === 404) return "changed";
459
+ if (!response.ok) return void 0;
460
+ return nightlyMode((await response.json()).value);
461
+ },
462
+ async set(mode) {
463
+ const api = manage() ?? read;
464
+ if (mode === "changed") {
465
+ await githubRequest(api, `${path}/${NIGHTLY_VARIABLE}`, {
466
+ method: "DELETE",
467
+ allow: [404]
468
+ }).catch(notAllowed);
469
+ return;
400
470
  }
471
+ const body = {
472
+ name: NIGHTLY_VARIABLE,
473
+ value: mode
474
+ };
475
+ if ((await githubRequest(api, `${path}/${NIGHTLY_VARIABLE}`, {
476
+ method: "PATCH",
477
+ body,
478
+ allow: [404]
479
+ }).catch(notAllowed)).status === 404) await githubRequest(api, path, {
480
+ method: "POST",
481
+ body
482
+ }).catch(notAllowed);
401
483
  }
402
484
  };
403
485
  }
@@ -465,16 +547,26 @@ function githubCodeUpdates(options) {
465
547
  },
466
548
  async start(target) {
467
549
  await (await updates()).start(target);
550
+ },
551
+ async rebuild() {
552
+ await (await updates()).rebuild?.();
468
553
  }
469
554
  };
470
555
  }
471
556
  //#endregion
472
557
  //#region src/backend.ts
558
+ /** Cloudflare Pages' check runs (its GitHub App is "Cloudflare Workers and Pages") and commit statuses. */
559
+ const CLOUDFLARE = /cloudflare/i;
473
560
  /** How many files one GraphQL request reads. */
474
561
  const GRAPHQL_BATCH = 100;
475
562
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
476
563
  createCommitOnBranch(input: $input) { commit { oid } }
477
564
  }`;
565
+ /** The files under a folder of a tree, in order. */
566
+ function listFrom(tree, dir) {
567
+ const prefix = `${dir}/`;
568
+ return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
569
+ }
478
570
  /**
479
571
  * The site's files on GitHub. Reads come from the commit `revision()` last
480
572
  * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
@@ -488,6 +580,7 @@ var GitHubBackend = class GitHubBackend {
488
580
  /** Absent where the repository holds only content: the update job runs where the code is. */
489
581
  updates;
490
582
  drafts;
583
+ scheduledBuilds;
491
584
  contentOnly;
492
585
  options;
493
586
  api;
@@ -532,6 +625,12 @@ var GitHubBackend = class GitHubBackend {
532
625
  })
533
626
  });
534
627
  this.drafts = branchDrafts(this, this.draftHost());
628
+ this.scheduledBuilds = githubScheduledBuilds((path) => this.read(path));
629
+ }
630
+ async repositoryVisibility() {
631
+ const found = await githubJson(this.api, `/repos/${this.repo}`);
632
+ if (found.visibility === "internal") return "internal";
633
+ return found.private === false || found.visibility === "public" ? "public" : "private";
535
634
  }
536
635
  /** Drafts on branches of the repository, with pull requests for review. */
537
636
  draftHost() {
@@ -564,6 +663,9 @@ var GitHubBackend = class GitHubBackend {
564
663
  if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
565
664
  return (await response.json()).merge_base_commit.sha;
566
665
  },
666
+ listBranches: async (prefix) => {
667
+ return (await githubJson(this.api, `${base}/git/matching-refs/heads/${encodeURIComponent(prefix)}`)).map((found) => found.ref.replace(/^refs\/heads\//, ""));
668
+ },
567
669
  openReview: async ({ branch, title, description }) => {
568
670
  const response = await githubRequest(this.api, `${base}/pulls`, {
569
671
  method: "POST",
@@ -593,10 +695,13 @@ var GitHubBackend = class GitHubBackend {
593
695
  };
594
696
  }
595
697
  async revision() {
596
- const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
597
- this.pinned = ref.object.sha;
698
+ this.pinned = await this.head();
598
699
  return this.pinned;
599
700
  }
701
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
702
+ async head() {
703
+ return (await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`)).object.sha;
704
+ }
600
705
  /** Path → blob id for every file at a commit. Commits never change, so this is cached. */
601
706
  tree(sha) {
602
707
  let tree = this.trees.get(sha);
@@ -631,7 +736,9 @@ var GitHubBackend = class GitHubBackend {
631
736
  }
632
737
  /** Reads many text files in a few GraphQL requests rather than one request each. */
633
738
  async readFiles(paths) {
634
- const tree = await this.currentTree();
739
+ return this.readFilesFrom(await this.currentTree(), paths);
740
+ }
741
+ async readFilesFrom(tree, paths) {
635
742
  const wanted = paths.flatMap((path) => {
636
743
  const sha = tree.get(path);
637
744
  return sha ? [[path, sha]] : [];
@@ -664,14 +771,24 @@ var GitHubBackend = class GitHubBackend {
664
771
  return result;
665
772
  }
666
773
  async readBytes(path) {
667
- const sha = (await this.currentTree()).get(path);
774
+ return this.bytesFrom(await this.currentTree(), path);
775
+ }
776
+ async bytesFrom(tree, path) {
777
+ const sha = tree.get(path);
668
778
  if (!sha) return void 0;
669
779
  const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
670
780
  return new Uint8Array(await response.arrayBuffer());
671
781
  }
672
782
  async list(dir) {
673
- const prefix = `${dir}/`;
674
- return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
783
+ return listFrom(await this.currentTree(), dir);
784
+ }
785
+ filesAt(revision) {
786
+ return {
787
+ read: async (path) => this.readFrom(await this.tree(revision), path),
788
+ readFiles: async (paths) => this.readFilesFrom(await this.tree(revision), paths),
789
+ readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
790
+ list: async (dir) => listFrom(await this.tree(revision), dir)
791
+ };
675
792
  }
676
793
  async fileSizes(dir) {
677
794
  const prefix = `${dir}/`;
@@ -825,10 +942,11 @@ var GitHubBackend = class GitHubBackend {
825
942
  return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
826
943
  }
827
944
  /**
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.
945
+ * Reads the deployment GitHub Pages or Vercel created for a commit, or what
946
+ * Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
947
+ * that builds it: a build that fails never creates a deployment. Before any
948
+ * exists, the site counts as building if it has ever been deployed, or if
949
+ * Cloudflare reported on the commit before.
832
950
  */
833
951
  async deployStatus(revision) {
834
952
  try {
@@ -857,7 +975,13 @@ var GitHubBackend = class GitHubBackend {
857
975
  };
858
976
  }
859
977
  }
978
+ const cloudflare = await this.cloudflareBuild(revision);
979
+ if (cloudflare) return cloudflare;
860
980
  const run = await this.workflowRun(revision);
981
+ if (run?.status === "completed" && run.conclusion === "cancelled") {
982
+ const head = await this.head();
983
+ if (head !== revision) return this.deployStatus(head);
984
+ }
861
985
  if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
862
986
  state: "failed",
863
987
  detailsUrl: run.html_url,
@@ -867,12 +991,76 @@ var GitHubBackend = class GitHubBackend {
867
991
  state: "building",
868
992
  detailsUrl: run.html_url
869
993
  };
870
- return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
994
+ if ((await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0) return { state: "building" };
995
+ const parent = await this.parentOf(revision);
996
+ return { state: parent && await this.cloudflareBuild(parent) ? "building" : "unknown" };
871
997
  } catch (error) {
872
998
  if (error instanceof GitApiError) return { state: "unknown" };
873
999
  throw error;
874
1000
  }
875
1001
  }
1002
+ /**
1003
+ * What Cloudflare Pages reports on a commit: a check run of its GitHub App,
1004
+ * or a commit status, which fine-grained tokens can read where check runs
1005
+ * need a GitHub App's permission. `undefined` if it reported nothing.
1006
+ */
1007
+ async cloudflareBuild(revision) {
1008
+ const checks = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/check-runs?per_page=50`, { allow: [403, 404] });
1009
+ if (checks.ok) {
1010
+ const { check_runs: found = [] } = await checks.json();
1011
+ const run = found.find((candidate) => CLOUDFLARE.test(`${candidate.app?.slug ?? ""} ${candidate.name}`));
1012
+ if (run) {
1013
+ const detailsUrl = run.details_url ?? run.html_url ?? void 0;
1014
+ if (run.status !== "completed") return {
1015
+ state: "building",
1016
+ detailsUrl
1017
+ };
1018
+ if (run.conclusion === "success" || run.conclusion === "neutral" || run.conclusion === "skipped") return {
1019
+ state: "live",
1020
+ detailsUrl
1021
+ };
1022
+ return {
1023
+ state: "failed",
1024
+ detailsUrl,
1025
+ problem: {
1026
+ step: "build",
1027
+ detail: run.output?.title ?? void 0
1028
+ }
1029
+ };
1030
+ }
1031
+ }
1032
+ const statuses = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/statuses?per_page=50`, { allow: [403, 404] });
1033
+ if (!statuses.ok) return void 0;
1034
+ const status = (await statuses.json()).find((candidate) => CLOUDFLARE.test(candidate.context));
1035
+ if (!status) return void 0;
1036
+ const detailsUrl = status.target_url ?? void 0;
1037
+ if (status.state === "success") return {
1038
+ state: "live",
1039
+ detailsUrl
1040
+ };
1041
+ if (status.state === "pending") return {
1042
+ state: "building",
1043
+ detailsUrl
1044
+ };
1045
+ return {
1046
+ state: "failed",
1047
+ detailsUrl,
1048
+ problem: {
1049
+ step: "build",
1050
+ detail: status.description ?? void 0
1051
+ }
1052
+ };
1053
+ }
1054
+ /** The commit a commit was made on top of, if the token can see it. */
1055
+ async parentOf(revision) {
1056
+ const response = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}`, { allow: [
1057
+ 403,
1058
+ 404,
1059
+ 422
1060
+ ] });
1061
+ if (!response.ok) return void 0;
1062
+ return (await response.json()).parents?.[0]?.sha;
1063
+ }
876
1064
  /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
877
1065
  async workflowRun(revision) {
878
1066
  const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
@@ -1143,6 +1331,25 @@ function setupAccount(creator) {
1143
1331
  throw error;
1144
1332
  }
1145
1333
  }
1334
+ /** Gives the repository's Pages a domain of its own, saying whether GitHub took it. */
1335
+ async function setDomain(api, repo, domain) {
1336
+ try {
1337
+ await githubRequest(api, `/repos/${repoPath(repo.full_name)}/pages`, {
1338
+ method: "PUT",
1339
+ body: { cname: domain }
1340
+ });
1341
+ return true;
1342
+ } catch (error) {
1343
+ if (error instanceof GitApiError && [
1344
+ 400,
1345
+ 403,
1346
+ 404,
1347
+ 409,
1348
+ 422
1349
+ ].includes(error.status)) return false;
1350
+ throw error;
1351
+ }
1352
+ }
1146
1353
  /** Writes every file in one commit, which replaces the starting README rather than following it. */
1147
1354
  async function writeFiles(api, repo, site) {
1148
1355
  const base = `/repos/${repoPath(repo.full_name)}`;
@@ -1287,6 +1494,10 @@ function setupAccount(creator) {
1287
1494
  onStep?.("pages");
1288
1495
  siteUrl = await turnOnPages(api, repo);
1289
1496
  if (!siteUrl) warnings.push("pages-unavailable");
1497
+ else if (site.domain) {
1498
+ if (await setDomain(api, repo, site.domain)) siteUrl = `https://${site.domain}/`;
1499
+ else warnings.push("domain-unavailable");
1500
+ }
1290
1501
  }
1291
1502
  createdApis.set(repo.full_name.toLowerCase(), api);
1292
1503
  if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
@@ -1504,7 +1715,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1504
1715
  expires_in: "90",
1505
1716
  contents: "write",
1506
1717
  deployments: "read",
1507
- actions: "read"
1718
+ actions: "read",
1719
+ statuses: "read"
1508
1720
  });
1509
1721
  const classic = new URLSearchParams({
1510
1722
  scopes: "repo",
@@ -1517,7 +1729,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1517
1729
  expires_in: "1",
1518
1730
  administration: "write",
1519
1731
  pages: "write",
1520
- actions: "write"
1732
+ actions: "write",
1733
+ variables: "write"
1521
1734
  });
1522
1735
  return {
1523
1736
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
@@ -1768,7 +1981,20 @@ function github(options) {
1768
1981
  }
1769
1982
  return {
1770
1983
  name: "GitHub",
1984
+ source: hostSource("@goodfellow-cms/github", "github", options, [
1985
+ "fetch",
1986
+ "storage",
1987
+ "location"
1988
+ ]),
1771
1989
  canRedirect: service !== void 0,
1990
+ setup: () => githubSetup({
1991
+ ...options.apiUrl && { apiUrl: options.apiUrl },
1992
+ ...options.webUrl && { webUrl: options.webUrl },
1993
+ ...options.fetch && { fetch: options.fetch },
1994
+ ...options.storage && { storage: options.storage },
1995
+ ...options.location && { location: options.location },
1996
+ signIn: false
1997
+ }),
1772
1998
  tokenLinks: [{
1773
1999
  url: links.fineGrained,
1774
2000
  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,8 @@ 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;
91
106
  }
92
107
  /** A GitHub App, as the sign-in worker uses one: its JWTs, installations, OAuth sign-in and tokens. */
93
108
  export interface FakeGitHubApp {
@@ -129,6 +144,7 @@ export interface FakeCreatedRepo {
129
144
  repo: FakeRepo;
130
145
  pages?: {
131
146
  build_type: string;
147
+ cname?: string;
132
148
  };
133
149
  rulesets: unknown[];
134
150
  /** Deploy keys added to it, by their public key. */
@@ -150,6 +166,11 @@ interface Deployment {
150
166
  statuses: FakeDeploymentStatus[];
151
167
  checks: number;
152
168
  }
169
+ /** A build Cloudflare Pages reports on a commit, which tests can fail with `failCloudflare()`. */
170
+ export interface FakeCloudflareBuild {
171
+ checks: number;
172
+ failed?: boolean;
173
+ }
153
174
  export declare function fakeGitHub(options: FakeGitHubOptions): {
154
175
  repo: FakeRepo;
155
176
  /** How many GraphQL requests read files, as `readFiles()` makes. */
@@ -182,9 +203,15 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
182
203
  invitations: FakeInvitation[];
183
204
  accept: (login: string) => void;
184
205
  deployments: Deployment[];
185
- /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
206
+ /** Cloudflare Pages' builds, by commit, with `cloudflare`; `failCloudflare()` makes one fail. */
207
+ cloudflareBuilds: Map<string, FakeCloudflareBuild>;
208
+ failCloudflare: (sha: string) => void;
209
+ /** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
186
210
  runs: FakeRun[];
211
+ /** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
212
+ variables: Map<string, string>;
187
213
  failBuild: (sha: string, step?: string, job?: string) => FakeRun;
214
+ cancelBuild: (sha: string) => FakeRun;
188
215
  /** Every request received, for checking what the backend sent. */
189
216
  requests: Request[];
190
217
  /** Simulates someone else publishing. */
package/dist/testing.js CHANGED
@@ -53,7 +53,10 @@ 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
+ /** The repository's Actions variables, by name. */
59
+ const variables = /* @__PURE__ */ new Map();
57
60
  const requests = [];
58
61
  let nextDeployment = 1;
59
62
  let graphqlReads = 0;
@@ -187,6 +190,23 @@ function fakeGitHub(options) {
187
190
  runs.push(run);
188
191
  return run;
189
192
  }
193
+ /**
194
+ * Simulates the deploy workflow's run for a commit being cancelled, as a
195
+ * newer run in its concurrency group replaces one still waiting: it never
196
+ * deploys.
197
+ */
198
+ function cancelBuild(sha) {
199
+ for (const [index, deployment] of [...deployments.entries()].reverse()) if (deployment.sha === sha) deployments.splice(index, 1);
200
+ const run = {
201
+ id: runs.length + 1,
202
+ sha,
203
+ status: "completed",
204
+ conclusion: "cancelled",
205
+ jobs: []
206
+ };
207
+ runs.push(run);
208
+ return run;
209
+ }
190
210
  /** Simulates an invited person accepting the invitation. */
191
211
  function accept(login) {
192
212
  const index = invitations.findIndex((invitation) => invitation.login === login);
@@ -333,6 +353,12 @@ function fakeGitHub(options) {
333
353
  build_type: site.pages.build_type
334
354
  }, 201);
335
355
  }
356
+ if (rest === "/pages" && method === "PUT" && site.pages) {
357
+ const body = await request.json();
358
+ if (body.cname && options.takenDomains?.includes(body.cname)) return json({ message: `The CNAME \`${body.cname}\` is already taken.` }, 422);
359
+ if (body.cname) site.pages.cname = body.cname;
360
+ return new Response(null, { status: 204 });
361
+ }
336
362
  const notAllowed = (permission) => principal.app ? principal.permissions?.[permission] !== "write" : principal.secrets === false;
337
363
  if (rest === "/keys" && method === "POST") {
338
364
  if (notAllowed("administration")) return json({ message: "Resource not accessible by integration" }, 403);
@@ -447,6 +473,7 @@ function fakeGitHub(options) {
447
473
  }
448
474
  }
449
475
  function deploy(sha) {
476
+ if (options.cloudflare) cloudflareBuilds.set(sha, { checks: 0 });
450
477
  if (options.deployAfterChecks === void 0) return;
451
478
  deployments.unshift({
452
479
  id: nextDeployment++,
@@ -455,6 +482,20 @@ function fakeGitHub(options) {
455
482
  checks: 0
456
483
  });
457
484
  }
485
+ /** Where a Cloudflare build is up to, counting the check. */
486
+ function cloudflareState(sha) {
487
+ const build = cloudflareBuilds.get(sha);
488
+ if (!build) return void 0;
489
+ build.checks += 1;
490
+ if (build.failed) return "failure";
491
+ return build.checks > (options.cloudflare?.afterChecks ?? 0) ? "success" : "building";
492
+ }
493
+ function failCloudflare(sha) {
494
+ cloudflareBuilds.set(sha, {
495
+ checks: 0,
496
+ failed: true
497
+ });
498
+ }
458
499
  function commit(changes, message, expectedHead, author, branch = repo.defaultBranch) {
459
500
  const sha = repo.commit(changes, message, {
460
501
  expectedHead,
@@ -517,7 +558,8 @@ function fakeGitHub(options) {
517
558
  pages: app.permissions.pages === "write",
518
559
  workflow: app.permissions.workflows === "write",
519
560
  actions: app.permissions.actions !== void 0,
520
- actionsWrite: app.permissions.actions === "write"
561
+ actionsWrite: app.permissions.actions === "write",
562
+ variables: app.permissions.variables === "write"
521
563
  });
522
564
  refreshTokens.set(refresh, {
523
565
  app,
@@ -625,7 +667,8 @@ function fakeGitHub(options) {
625
667
  administration: permissions.administration === "write",
626
668
  admin: permissions.administration === "write",
627
669
  actions: permissions.actions !== void 0,
628
- actionsWrite: permissions.actions === "write"
670
+ actionsWrite: permissions.actions === "write",
671
+ variables: permissions.variables === "write"
629
672
  });
630
673
  issued.push({
631
674
  token,
@@ -789,6 +832,17 @@ function fakeGitHub(options) {
789
832
  object: { sha: body.sha }
790
833
  }, 201);
791
834
  }
835
+ const matching = rest.match(/^\/git\/matching-refs\/heads\/(.*)$/);
836
+ if (matching) {
837
+ const prefix = decodeURIComponent(matching[1] ?? "");
838
+ return json([...repo.branches].filter(([branch]) => branch.startsWith(prefix)).map(([branch, sha]) => ({
839
+ ref: `refs/heads/${branch}`,
840
+ object: {
841
+ sha,
842
+ type: "commit"
843
+ }
844
+ })));
845
+ }
792
846
  const branchRef = rest.match(/^\/git\/refs\/heads\/(.+)$/);
793
847
  if (branchRef && request.method === "DELETE") {
794
848
  if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
@@ -846,6 +900,8 @@ function fakeGitHub(options) {
846
900
  if (rest === "") return json({
847
901
  id: 1,
848
902
  full_name: options.repo,
903
+ private: options.private === true,
904
+ visibility: options.private === true ? "private" : "public",
849
905
  default_branch: repo.defaultBranch,
850
906
  permissions: {
851
907
  push: user.push !== false,
@@ -904,6 +960,31 @@ function fakeGitHub(options) {
904
960
  author: null
905
961
  })));
906
962
  }
963
+ const variable = rest.match(/^\/actions\/variables(?:\/([\w-]+))?$/);
964
+ if (variable) {
965
+ if (!(user.variables ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
966
+ const [, name] = variable;
967
+ if (!name && request.method === "POST") {
968
+ const body = await request.json();
969
+ if (variables.has(body.name)) return json({ message: "Already exists" }, 409);
970
+ variables.set(body.name, body.value);
971
+ return new Response(null, { status: 201 });
972
+ }
973
+ if (name && !variables.has(name)) return json({ message: "Not Found" }, 404);
974
+ if (name && request.method === "GET") return json({
975
+ name,
976
+ value: variables.get(name)
977
+ });
978
+ if (name && request.method === "PATCH") {
979
+ const body = await request.json();
980
+ variables.set(name, body.value);
981
+ return new Response(null, { status: 204 });
982
+ }
983
+ if (name && request.method === "DELETE") {
984
+ variables.delete(name);
985
+ return new Response(null, { status: 204 });
986
+ }
987
+ }
907
988
  const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
908
989
  const workflowPath = `.github/workflows/${workflowFile}`;
909
990
  const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
@@ -976,6 +1057,43 @@ function fakeGitHub(options) {
976
1057
  files: repo.changedPaths(base, b).map((filename) => ({ filename }))
977
1058
  });
978
1059
  }
1060
+ const commitSha = rest.match(/^\/commits\/([0-9a-f]{40})$/)?.[1];
1061
+ if (commitSha) {
1062
+ const found = repo.commitAt(commitSha);
1063
+ if (!found) return json({ message: "No commit found for SHA" }, 422);
1064
+ return json({
1065
+ sha: commitSha,
1066
+ parents: found.parent ? [{ sha: found.parent }] : []
1067
+ });
1068
+ }
1069
+ const checkRunsSha = rest.match(/^\/commits\/([0-9a-f]{40})\/check-runs$/)?.[1];
1070
+ if (checkRunsSha) {
1071
+ if (options.checkRunsForbidden) return json({ message: "Resource not accessible by personal access token" }, 403);
1072
+ const state = options.cloudflare?.report === "statuses" ? void 0 : cloudflareState(checkRunsSha);
1073
+ const runsFound = state ? [{
1074
+ id: 1,
1075
+ name: "Cloudflare Pages",
1076
+ app: { slug: "cloudflare-workers-and-pages" },
1077
+ status: state === "building" ? "in_progress" : "completed",
1078
+ conclusion: state === "building" ? null : state,
1079
+ details_url: `https://dash.cloudflare.com/builds/${checkRunsSha}`,
1080
+ output: { title: state === "failure" ? "Build failed" : "Deployed" }
1081
+ }] : [];
1082
+ return json({
1083
+ total_count: runsFound.length,
1084
+ check_runs: runsFound
1085
+ });
1086
+ }
1087
+ const statusesSha = rest.match(/^\/commits\/([0-9a-f]{40})\/statuses$/)?.[1];
1088
+ if (statusesSha) {
1089
+ const state = options.cloudflare?.report === "statuses" ? cloudflareState(statusesSha) : void 0;
1090
+ return json(state ? [{
1091
+ context: "Cloudflare Pages",
1092
+ state: state === "building" ? "pending" : state,
1093
+ target_url: `https://dash.cloudflare.com/builds/${statusesSha}`,
1094
+ description: state === "failure" ? "Build failed" : "Deployed"
1095
+ }] : []);
1096
+ }
979
1097
  if (rest === "/deployments") {
980
1098
  const sha = url.searchParams.get("sha");
981
1099
  return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
@@ -1018,9 +1136,15 @@ function fakeGitHub(options) {
1018
1136
  invitations,
1019
1137
  accept,
1020
1138
  deployments,
1021
- /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
1139
+ /** Cloudflare Pages' builds, by commit, with `cloudflare`; `failCloudflare()` makes one fail. */
1140
+ cloudflareBuilds,
1141
+ failCloudflare,
1142
+ /** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
1022
1143
  runs,
1144
+ /** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
1145
+ variables,
1023
1146
  failBuild,
1147
+ cancelBuild,
1024
1148
  /** Every request received, for checking what the backend sent. */
1025
1149
  requests,
1026
1150
  /** 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.5.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.5.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"