@goodfellow-cms/github 0.3.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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, compareReleases, credentialStorage, 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 = {}) {
@@ -148,6 +148,8 @@ function githubEditors(read, manage, repo, login) {
148
148
  */
149
149
  function githubOwnerAccess(base, repo, tokenLink, redirect) {
150
150
  let owner;
151
+ /** The owner's access to the site's code repository, where its content is kept in a repository of its own. */
152
+ let ownerCode;
151
153
  let endSignIn;
152
154
  let problem;
153
155
  return {
@@ -177,21 +179,28 @@ function githubOwnerAccess(base, repo, tokenLink, redirect) {
177
179
  throw error;
178
180
  }
179
181
  owner = api;
182
+ ownerCode = api;
180
183
  problem = void 0;
181
184
  },
182
185
  forget() {
183
186
  owner = void 0;
187
+ ownerCode = void 0;
184
188
  endSignIn?.();
185
189
  endSignIn = void 0;
186
190
  }
187
191
  },
188
192
  api: () => owner,
189
- /** Uses an owner's sign-in through the worker. `end` runs when it's forgotten. */
190
- useSignIn(token, end) {
193
+ codeApi: () => ownerCode,
194
+ /** Uses an owner's sign-in through the worker, with its token for the code repository if it gave one. `end` runs when it's forgotten. */
195
+ useSignIn(token, end, codeToken) {
191
196
  owner = {
192
197
  ...base,
193
198
  token
194
199
  };
200
+ ownerCode = codeToken ? {
201
+ ...base,
202
+ token: codeToken
203
+ } : void 0;
195
204
  endSignIn = end;
196
205
  problem = void 0;
197
206
  },
@@ -286,6 +295,7 @@ function githubPages(api, repo) {
286
295
  }
287
296
  }
288
297
  return {
298
+ severalDomains: false,
289
299
  async site() {
290
300
  const found = await pages();
291
301
  return found && {
@@ -319,20 +329,45 @@ function githubPages(api, repo) {
319
329
  };
320
330
  }
321
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
322
352
  //#region src/updates.ts
323
353
  /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
324
354
  const WORKFLOW = "deploy.yml";
325
- const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
355
+ /** The code repository's workflow, where a site keeps its content in a repository of its own. */
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";
326
359
  /**
327
360
  * The site's nightly update, which runs in its GitHub Pages workflow. Running
328
361
  * it now dispatches the workflow, which needs GitHub's Actions permission
329
362
  * with write access: `manage()` gives the owner token when one is in use.
330
363
  */
331
- function githubUpdates(read, manage, repo, branch, files) {
332
- const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
364
+ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
365
+ const path = `/repos/${repo}/actions/workflows/${workflow}`;
366
+ const workflowFile = `.github/workflows/${workflow}`;
333
367
  return {
334
368
  async setup() {
335
- return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
369
+ const text = await files.read(workflowFile);
370
+ return text && /update --(publish|commit)\b/.test(text) ? "ready" : "missing";
336
371
  },
337
372
  async lastRun() {
338
373
  const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
@@ -354,6 +389,11 @@ function githubUpdates(read, manage, repo, branch, files) {
354
389
  state: "failed",
355
390
  ...base
356
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
+ };
357
397
  const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
358
398
  const now = siteVersion(await files.read("package.json") ?? "");
359
399
  if (!before || !now || before === now) return {
@@ -373,30 +413,160 @@ function githubUpdates(read, manage, repo, branch, files) {
373
413
  };
374
414
  }
375
415
  },
416
+ nightly: githubNightly(read, manage, repo, () => files.read(workflowFile)),
417
+ async rebuild() {
418
+ await dispatch({});
419
+ },
376
420
  async start(target) {
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;
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);
483
+ }
484
+ };
485
+ }
486
+ /** Whether an error means the sign-in can't see the code repository, or has no token for it. */
487
+ function noAccess(error) {
488
+ return error instanceof SignInError && error.problem === "no-access" || error instanceof GitApiError && (error.status === 403 || error.status === 404);
489
+ }
490
+ /**
491
+ * The nightly update of a site whose content is kept in a repository of its
492
+ * own, which runs in the code repository's `update.yml`. The sign-in needs to
493
+ * see the code repository too; where it can't, `setup()` says `no-access`.
494
+ */
495
+ function githubCodeUpdates(options) {
496
+ const { read } = options;
497
+ const repo = repoPath(options.repo);
498
+ let branch;
499
+ const liveBranch = (api) => {
500
+ branch ??= githubJson(api, `/repos/${repo}`).then((found) => found.default_branch);
501
+ branch.catch(() => {
502
+ branch = void 0;
503
+ });
504
+ return branch;
505
+ };
506
+ /** The code repository at its newest commit, read again each time, since updates change it. */
507
+ async function files() {
508
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
509
+ return options.open(read, await liveBranch(read));
510
+ }
511
+ /** A file as it is now: setup files are outside the folders backends list, so they're read at the newest commit. */
512
+ async function readNow(path) {
513
+ const backend = await files();
514
+ return backend.readAt(path, await backend.revision());
515
+ }
516
+ async function updates() {
517
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
518
+ const target = await liveBranch(read);
519
+ return githubUpdates(read, options.manage, repo, target, {
520
+ read: readNow,
521
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
522
+ }, CODE_WORKFLOW);
523
+ }
524
+ return {
525
+ code: {
526
+ repo: options.repo,
527
+ webUrl: `${options.webUrl}/${options.repo}`,
528
+ read: readNow,
529
+ history: async (path, history) => (await files()).history(path, history),
530
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
531
+ },
532
+ async setup() {
377
533
  try {
378
- await githubRequest(manage() ?? read, `${path}/dispatches`, {
379
- method: "POST",
380
- body: {
381
- ref: branch,
382
- inputs: { update: target }
383
- }
384
- });
534
+ return await (await updates()).setup();
535
+ } catch (error) {
536
+ if (noAccess(error)) return "no-access";
537
+ throw error;
538
+ }
539
+ },
540
+ async lastRun() {
541
+ try {
542
+ return await (await updates()).lastRun();
385
543
  } catch (error) {
386
- 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.");
387
- if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
544
+ if (noAccess(error)) return void 0;
388
545
  throw error;
389
546
  }
547
+ },
548
+ async start(target) {
549
+ await (await updates()).start(target);
550
+ },
551
+ async rebuild() {
552
+ await (await updates()).rebuild?.();
390
553
  }
391
554
  };
392
555
  }
393
556
  //#endregion
394
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;
395
560
  /** How many files one GraphQL request reads. */
396
561
  const GRAPHQL_BATCH = 100;
397
562
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
398
563
  createCommitOnBranch(input: $input) { commit { oid } }
399
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
+ }
400
570
  /**
401
571
  * The site's files on GitHub. Reads come from the commit `revision()` last
402
572
  * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
@@ -407,8 +577,11 @@ var GitHubBackend = class GitHubBackend {
407
577
  pages;
408
578
  editors;
409
579
  ownerAccess;
580
+ /** Absent where the repository holds only content: the update job runs where the code is. */
410
581
  updates;
411
582
  drafts;
583
+ scheduledBuilds;
584
+ contentOnly;
412
585
  options;
413
586
  api;
414
587
  repo;
@@ -429,13 +602,35 @@ var GitHubBackend = class GitHubBackend {
429
602
  this.user = options.user;
430
603
  this.onSignOut = options.onSignOut;
431
604
  this.author = options.author;
605
+ this.pinned = options.revision;
432
606
  const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
433
607
  this.owner = owner;
434
608
  this.ownerAccess = owner.access;
435
609
  this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
436
610
  this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
437
- this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
611
+ this.contentOnly = options.contentOnly === true;
612
+ if (!this.contentOnly) this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
613
+ else if (options.code) this.updates = githubCodeUpdates({
614
+ repo: options.code.repo,
615
+ webUrl: options.code.webUrl,
616
+ read: options.code.api,
617
+ manage: owner.codeApi,
618
+ open: (api, branch) => new GitHubBackend({
619
+ api,
620
+ repo: options.code?.repo ?? "",
621
+ branch,
622
+ user: this.user,
623
+ onSignOut: () => {},
624
+ ownerTokenLink: options.ownerTokenLink
625
+ })
626
+ });
438
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";
439
634
  }
440
635
  /** Drafts on branches of the repository, with pull requests for review. */
441
636
  draftHost() {
@@ -468,6 +663,9 @@ var GitHubBackend = class GitHubBackend {
468
663
  if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
469
664
  return (await response.json()).merge_base_commit.sha;
470
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
+ },
471
669
  openReview: async ({ branch, title, description }) => {
472
670
  const response = await githubRequest(this.api, `${base}/pulls`, {
473
671
  method: "POST",
@@ -497,10 +695,13 @@ var GitHubBackend = class GitHubBackend {
497
695
  };
498
696
  }
499
697
  async revision() {
500
- const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
501
- this.pinned = ref.object.sha;
698
+ this.pinned = await this.head();
502
699
  return this.pinned;
503
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
+ }
504
705
  /** Path → blob id for every file at a commit. Commits never change, so this is cached. */
505
706
  tree(sha) {
506
707
  let tree = this.trees.get(sha);
@@ -535,7 +736,9 @@ var GitHubBackend = class GitHubBackend {
535
736
  }
536
737
  /** Reads many text files in a few GraphQL requests rather than one request each. */
537
738
  async readFiles(paths) {
538
- const tree = await this.currentTree();
739
+ return this.readFilesFrom(await this.currentTree(), paths);
740
+ }
741
+ async readFilesFrom(tree, paths) {
539
742
  const wanted = paths.flatMap((path) => {
540
743
  const sha = tree.get(path);
541
744
  return sha ? [[path, sha]] : [];
@@ -568,14 +771,24 @@ var GitHubBackend = class GitHubBackend {
568
771
  return result;
569
772
  }
570
773
  async readBytes(path) {
571
- 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);
572
778
  if (!sha) return void 0;
573
779
  const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
574
780
  return new Uint8Array(await response.arrayBuffer());
575
781
  }
576
782
  async list(dir) {
577
- const prefix = `${dir}/`;
578
- 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
+ };
579
792
  }
580
793
  async fileSizes(dir) {
581
794
  const prefix = `${dir}/`;
@@ -584,14 +797,15 @@ var GitHubBackend = class GitHubBackend {
584
797
  return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
585
798
  }
586
799
  /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
587
- useOwnerSignIn(token, end) {
588
- this.owner.useSignIn(token, end);
800
+ useOwnerSignIn(token, end, codeToken) {
801
+ this.owner.useSignIn(token, end, codeToken);
589
802
  }
590
803
  /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
591
804
  ownerSignInFailed(error) {
592
805
  this.owner.failed(error);
593
806
  }
594
807
  async write(changes, options) {
808
+ if (this.contentOnly) assertContentChanges(changes);
595
809
  return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
596
810
  }
597
811
  /**
@@ -728,10 +942,11 @@ var GitHubBackend = class GitHubBackend {
728
942
  return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
729
943
  }
730
944
  /**
731
- * Reads the deployment GitHub Pages or Vercel created for a commit, and for
732
- * GitHub Actions, the workflow run that builds it: a build that fails never
733
- * creates a deployment. Before either exists, the site counts as building
734
- * 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.
735
950
  */
736
951
  async deployStatus(revision) {
737
952
  try {
@@ -760,7 +975,13 @@ var GitHubBackend = class GitHubBackend {
760
975
  };
761
976
  }
762
977
  }
978
+ const cloudflare = await this.cloudflareBuild(revision);
979
+ if (cloudflare) return cloudflare;
763
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
+ }
764
985
  if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
765
986
  state: "failed",
766
987
  detailsUrl: run.html_url,
@@ -770,18 +991,83 @@ var GitHubBackend = class GitHubBackend {
770
991
  state: "building",
771
992
  detailsUrl: run.html_url
772
993
  };
773
- 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" };
774
997
  } catch (error) {
775
998
  if (error instanceof GitApiError) return { state: "unknown" };
776
999
  throw error;
777
1000
  }
778
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
+ }
779
1064
  /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
780
1065
  async workflowRun(revision) {
781
1066
  const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
782
1067
  if (!response.ok) return void 0;
783
1068
  const { workflow_runs: runs = [] } = await response.json();
784
- return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
1069
+ const builds = runs.filter((run) => !run.path?.endsWith(`/${REBUILD_JOB}.yml`));
1070
+ return builds.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? builds[0];
785
1071
  }
786
1072
  /** Which step of a failed run failed. */
787
1073
  async runProblem(run) {
@@ -814,8 +1100,6 @@ var GitHubBackend = class GitHubBackend {
814
1100
  * repository, lasting an hour. Requests are sent as plain text, so browsers
815
1101
  * send them straight away without asking the worker first.
816
1102
  */
817
- /** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
818
- const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
819
1103
  /** The worker's problems, as the admin panel's sign-in problems. */
820
1104
  const PROBLEMS = {
821
1105
  invalid: "invalid",
@@ -935,32 +1219,46 @@ var SignInService = class {
935
1219
  });
936
1220
  return `${this.url}/github/start?${query}`;
937
1221
  }
938
- /** Exchanges the grant the worker came back with, and this tab's verifier, for a session (and for a site, its first token). */
939
- session(grant, verifier) {
1222
+ /**
1223
+ * Exchanges the grant the worker came back with, and this tab's verifier,
1224
+ * for a session (and for a site, its first token, and with `code`, one for
1225
+ * its code repository).
1226
+ */
1227
+ session(grant, verifier, code) {
940
1228
  return this.post("/github/session", {
941
1229
  grant,
942
- verifier
1230
+ verifier,
1231
+ ...code && { code }
943
1232
  });
944
1233
  }
945
- token(session) {
946
- return this.post("/github/token", { session });
1234
+ token(session, code) {
1235
+ return this.post("/github/token", {
1236
+ session,
1237
+ ...code && { code }
1238
+ });
947
1239
  }
948
1240
  };
949
1241
  /** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
950
- function renewingToken(service, first, onRenew) {
1242
+ function renewingToken(service, first, onRenew, code) {
951
1243
  let current = first;
952
1244
  let renewing;
1245
+ async function token({ refresh = false } = {}) {
1246
+ if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
1247
+ renewing ??= service.token(current.session, code).finally(() => {
1248
+ renewing = void 0;
1249
+ });
1250
+ current = await renewing;
1251
+ onRenew(current);
1252
+ }
1253
+ return current.token;
1254
+ }
953
1255
  return {
954
1256
  current: () => current,
955
- async token({ refresh = false } = {}) {
956
- if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
957
- renewing ??= service.token(current.session).finally(() => {
958
- renewing = void 0;
959
- });
960
- current = await renewing;
961
- onRenew(current);
962
- }
963
- return current.token;
1257
+ token,
1258
+ async codeToken(options) {
1259
+ await token(options);
1260
+ if (!current.code) throw new SignInError("no-access", "The sign-in service gave no token for the code repository.");
1261
+ return current.code.token;
964
1262
  }
965
1263
  };
966
1264
  }
@@ -970,8 +1268,9 @@ function renewingToken(service, first, onRenew) {
970
1268
  const MAIN = "main";
971
1269
  /**
972
1270
  * Links to GitHub's token pages with what creating a site needs: creating the
973
- * repository (administration), writing its files and its build setup
974
- * (contents and workflows), and turning on GitHub Pages (pages).
1271
+ * repository and adding deploy keys (administration), writing its files and
1272
+ * its build setup (contents and workflows), turning on GitHub Pages (pages),
1273
+ * and giving a workflow the key to another of the site's repositories (secrets).
975
1274
  */
976
1275
  function githubSetupTokenLinks(webUrl = "https://github.com") {
977
1276
  const fineGrained = new URLSearchParams({
@@ -981,7 +1280,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
981
1280
  administration: "write",
982
1281
  contents: "write",
983
1282
  workflows: "write",
984
- pages: "write"
1283
+ pages: "write",
1284
+ secrets: "write"
985
1285
  });
986
1286
  const classic = new URLSearchParams({
987
1287
  scopes: "repo,workflow",
@@ -994,6 +1294,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
994
1294
  }
995
1295
  /** The steps after the repository exists, which are the same however it was created. */
996
1296
  function setupAccount(creator) {
1297
+ /** Each repository created, and the token for setting it up, for connecting it to another one later. */
1298
+ const createdApis = /* @__PURE__ */ new Map();
997
1299
  /** Makes `main` the default branch, which the deploy setups build from. */
998
1300
  async function useMainBranch(api, repo) {
999
1301
  if (repo.default_branch === MAIN) return;
@@ -1029,6 +1331,25 @@ function setupAccount(creator) {
1029
1331
  throw error;
1030
1332
  }
1031
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
+ }
1032
1353
  /** Writes every file in one commit, which replaces the starting README rather than following it. */
1033
1354
  async function writeFiles(api, repo, site) {
1034
1355
  const base = `/repos/${repoPath(repo.full_name)}`;
@@ -1108,6 +1429,53 @@ function setupAccount(creator) {
1108
1429
  ]
1109
1430
  })).ok;
1110
1431
  }
1432
+ /**
1433
+ * Lets each private repository's workflows read the other, as their setup
1434
+ * files say: a read-only deploy key on the one read, with its private half
1435
+ * a secret of the one reading. Public repositories need nothing. Returns
1436
+ * whether it worked: an older sign-in may not be allowed to set secrets.
1437
+ */
1438
+ async function connectRepositories(link) {
1439
+ if (!link.private) return true;
1440
+ const { newDeployKey, sealSecret } = await import("./deploy-keys-DAj2oX-N.js").then((n) => n.t);
1441
+ try {
1442
+ for (const { reader, read, secret } of link.links.reads) {
1443
+ if (!secret) continue;
1444
+ const readerRepo = link[reader];
1445
+ const readRepo = link[read];
1446
+ const readerApi = createdApis.get(readerRepo.toLowerCase());
1447
+ const readApi = createdApis.get(readRepo.toLowerCase());
1448
+ if (!readerApi || !readApi) throw new Error(`${readerRepo} and ${readRepo} have to be created first.`);
1449
+ const key = await newDeployKey(`goodfellow-${secret.toLowerCase()}`);
1450
+ await githubRequest(readApi, `/repos/${repoPath(readRepo)}/keys`, {
1451
+ method: "POST",
1452
+ body: {
1453
+ title: `${readerRepo}'s builds`,
1454
+ key: key.publicKey,
1455
+ read_only: true
1456
+ }
1457
+ });
1458
+ const base = `/repos/${repoPath(readerRepo)}/actions/secrets`;
1459
+ const publicKey = await githubJson(readerApi, `${base}/public-key`);
1460
+ const sealed = await sealSecret(key.privateKey, decodeBase64(publicKey.key));
1461
+ await githubRequest(readerApi, `${base}/${secret}`, {
1462
+ method: "PUT",
1463
+ body: {
1464
+ encrypted_value: encodeBase64Bytes(sealed),
1465
+ key_id: publicKey.key_id
1466
+ }
1467
+ });
1468
+ }
1469
+ return true;
1470
+ } catch (error) {
1471
+ if (error instanceof GitApiError && [
1472
+ 403,
1473
+ 404,
1474
+ 422
1475
+ ].includes(error.status)) return false;
1476
+ throw error;
1477
+ }
1478
+ }
1111
1479
  return {
1112
1480
  user: creator.user,
1113
1481
  owners: () => creator.owners(),
@@ -1116,13 +1484,23 @@ function setupAccount(creator) {
1116
1484
  const warnings = [];
1117
1485
  onStep?.("repository");
1118
1486
  const { repo, api, signInInstallUrl } = await creator.create(site);
1487
+ const connect = async (link) => {
1488
+ onStep?.("connect");
1489
+ return connectRepositories(link);
1490
+ };
1119
1491
  await useMainBranch(api, repo);
1120
1492
  let siteUrl;
1121
1493
  if (site.pages) {
1122
1494
  onStep?.("pages");
1123
1495
  siteUrl = await turnOnPages(api, repo);
1124
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
+ }
1125
1501
  }
1502
+ createdApis.set(repo.full_name.toLowerCase(), api);
1503
+ if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
1126
1504
  onStep?.("files");
1127
1505
  await writeFiles(api, repo, site);
1128
1506
  onStep?.("protection");
@@ -1233,7 +1611,8 @@ function workerCreator(service, apiUrl, fetcher, signedIn) {
1233
1611
  owner: site.owner.path,
1234
1612
  name: site.name,
1235
1613
  private: site.private,
1236
- description: site.description
1614
+ description: site.description,
1615
+ ...site.more && { more: true }
1237
1616
  });
1238
1617
  if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
1239
1618
  return {
@@ -1264,7 +1643,7 @@ function githubSetup(options = {}) {
1264
1643
  const links = githubSetupTokenLinks(options.webUrl);
1265
1644
  const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1266
1645
  const location = options.location ?? browserLocation();
1267
- const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1646
+ const signInUrl = options.signIn;
1268
1647
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1269
1648
  const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
1270
1649
  return {
@@ -1336,7 +1715,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1336
1715
  expires_in: "90",
1337
1716
  contents: "write",
1338
1717
  deployments: "read",
1339
- actions: "read"
1718
+ actions: "read",
1719
+ statuses: "read"
1340
1720
  });
1341
1721
  const classic = new URLSearchParams({
1342
1722
  scopes: "repo",
@@ -1349,7 +1729,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1349
1729
  expires_in: "1",
1350
1730
  administration: "write",
1351
1731
  pages: "write",
1352
- actions: "write"
1732
+ actions: "write",
1733
+ variables: "write"
1353
1734
  });
1354
1735
  return {
1355
1736
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
@@ -1360,14 +1741,24 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1360
1741
  /** Sign-in and storage for a site whose repository is on GitHub. */
1361
1742
  function github(options) {
1362
1743
  if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
1744
+ if (options.code !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(options.code)) throw new Error(`github({ code }) must look like "owner/name" (got "${options.code}").`);
1363
1745
  const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1746
+ const webUrl = (options.webUrl ?? "https://github.com").replace(/\/$/, "");
1747
+ /** The code repository, where the content is in a repository of its own, read with `api`. */
1748
+ const codeRepository = (api) => options.contentOnly && options.code ? { code: {
1749
+ repo: options.code,
1750
+ webUrl,
1751
+ ...api && { api }
1752
+ } } : {};
1364
1753
  const links = githubTokenLinks(options.repo, options.webUrl);
1365
1754
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1366
1755
  const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1367
1756
  const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1368
1757
  const location = options.location ?? browserLocation();
1369
- const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1758
+ const signInUrl = options.signIn;
1370
1759
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1760
+ /** The code repository to ask the worker for a token for, where the Updates screen follows it. */
1761
+ const codeName = () => options.contentOnly ? options.code : void 0;
1371
1762
  function save(value, remember) {
1372
1763
  storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
1373
1764
  }
@@ -1410,7 +1801,7 @@ function github(options) {
1410
1801
  const source = renewingToken(service, issued, (renewed) => save({
1411
1802
  type: "worker",
1412
1803
  session: renewed.session
1413
- }, remember));
1804
+ }, remember), options.code);
1414
1805
  save({
1415
1806
  type: "worker",
1416
1807
  session: issued.session
@@ -1426,6 +1817,12 @@ function github(options) {
1426
1817
  api,
1427
1818
  repo: options.repo,
1428
1819
  branch: options.branch ?? repo.default_branch,
1820
+ ...options.contentOnly && { contentOnly: true },
1821
+ ...codeRepository({
1822
+ apiUrl,
1823
+ token: source.codeToken,
1824
+ fetch: fetcher
1825
+ }),
1429
1826
  user: {
1430
1827
  login: user.login,
1431
1828
  name: user.name,
@@ -1437,7 +1834,7 @@ function github(options) {
1437
1834
  },
1438
1835
  onSignOut: () => {
1439
1836
  storage.clear();
1440
- revokeToken(source.current().token);
1837
+ revokeIssued(source.current());
1441
1838
  },
1442
1839
  ownerTokenLink: {
1443
1840
  url: links.owner,
@@ -1454,12 +1851,17 @@ function github(options) {
1454
1851
  fetch: fetcher
1455
1852
  }, "/installation/token", { method: "DELETE" }).catch(() => {});
1456
1853
  }
1854
+ /** Revokes the tokens the worker gave: the site's, and the code repository's. */
1855
+ function revokeIssued(issued) {
1856
+ revokeToken(issued.token);
1857
+ if (issued.code) revokeToken(issued.code.token);
1858
+ }
1457
1859
  /** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
1458
1860
  async function useOwner(backend, grant, verifier) {
1459
1861
  const worker = service;
1460
1862
  try {
1461
- const source = renewingToken(worker, await worker.session(grant, verifier), () => {});
1462
- backend.useOwnerSignIn(source.token, () => revokeToken(source.current().token));
1863
+ const source = renewingToken(worker, await worker.session(grant, verifier, codeName()), () => {}, codeName());
1864
+ backend.useOwnerSignIn(source.token, () => revokeIssued(source.current()), codeName() ? source.codeToken : void 0);
1463
1865
  } catch (error) {
1464
1866
  backend.ownerSignInFailed(error);
1465
1867
  }
@@ -1486,7 +1888,7 @@ function github(options) {
1486
1888
  return backend;
1487
1889
  }
1488
1890
  if (failed || !grant) throw signInProblem({ problem: failed });
1489
- return connectWorker(await service.session(grant, redirect.verifier), redirect.remember);
1891
+ return connectWorker(await service.session(grant, redirect.verifier, codeName()), redirect.remember);
1490
1892
  }
1491
1893
  async function restoreSaved() {
1492
1894
  const saved = loadSaved();
@@ -1497,7 +1899,7 @@ function github(options) {
1497
1899
  storage.clear();
1498
1900
  return null;
1499
1901
  }
1500
- return await connectWorker(await service.token(saved.session), storage.remembered());
1902
+ return await connectWorker(await service.token(saved.session, codeName()), storage.remembered());
1501
1903
  } catch (error) {
1502
1904
  if (forgets(error)) storage.clear();
1503
1905
  throw error;
@@ -1522,6 +1924,8 @@ function github(options) {
1522
1924
  api,
1523
1925
  repo: options.repo,
1524
1926
  branch: options.branch ?? repo.default_branch,
1927
+ ...options.contentOnly && { contentOnly: true },
1928
+ ...codeRepository(api),
1525
1929
  user: {
1526
1930
  login: user.login,
1527
1931
  name: user.name ?? void 0,
@@ -1535,9 +1939,62 @@ function github(options) {
1535
1939
  ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
1536
1940
  });
1537
1941
  }
1942
+ /** The repository through a token of the site's own, on a full-stack site's server. */
1943
+ function server({ token }) {
1944
+ const api = {
1945
+ apiUrl,
1946
+ token: typeof token === "string" ? token : () => token(),
1947
+ fetch: fetcher
1948
+ };
1949
+ let branch;
1950
+ const liveBranch = () => {
1951
+ branch ??= options.branch ? Promise.resolve(options.branch) : githubJson(api, `/repos/${repoPath(options.repo)}`).then((repo) => repo.default_branch);
1952
+ branch.catch(() => {
1953
+ branch = void 0;
1954
+ });
1955
+ return branch;
1956
+ };
1957
+ const backend = async (revision, author) => new GitHubBackend({
1958
+ api,
1959
+ repo: options.repo,
1960
+ branch: await liveBranch(),
1961
+ ...options.contentOnly && { contentOnly: true },
1962
+ user: {
1963
+ login: author?.name ?? options.repo,
1964
+ ...author && { name: author.name }
1965
+ },
1966
+ onSignOut: () => {},
1967
+ ownerTokenLink: {
1968
+ url: links.owner,
1969
+ label: "owner.token.create"
1970
+ },
1971
+ ...revision && { revision },
1972
+ ...author && { author }
1973
+ });
1974
+ return {
1975
+ ...options.contentOnly && { contentOnly: true },
1976
+ async revision() {
1977
+ return (await backend()).revision();
1978
+ },
1979
+ at: backend
1980
+ };
1981
+ }
1538
1982
  return {
1539
1983
  name: "GitHub",
1984
+ source: hostSource("@goodfellow-cms/github", "github", options, [
1985
+ "fetch",
1986
+ "storage",
1987
+ "location"
1988
+ ]),
1540
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
+ }),
1541
1998
  tokenLinks: [{
1542
1999
  url: links.fineGrained,
1543
2000
  label: "signIn.token.create"
@@ -1563,8 +2020,9 @@ function github(options) {
1563
2020
  },
1564
2021
  startRedirect(remember, redirectOptions) {
1565
2022
  return redirect("edit", remember, redirectOptions);
1566
- }
2023
+ },
2024
+ server
1567
2025
  };
1568
2026
  }
1569
2027
  //#endregion
1570
- export { GOODFELLOW_SIGN_IN, GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
2028
+ export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };