@goodfellow-cms/github 0.3.0 → 0.4.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.
@@ -0,0 +1,108 @@
1
+ import { encodeBase64Bytes } from "@goodfellow-cms/core";
2
+ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa.js";
3
+ import { blake2b } from "@noble/hashes/blake2.js";
4
+ //#region \0rolldown/runtime.js
5
+ var __defProp = Object.defineProperty;
6
+ var __exportAll = (all, no_symbols) => {
7
+ let target = {};
8
+ for (var name in all) __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
13
+ return target;
14
+ };
15
+ //#endregion
16
+ //#region src/deploy-keys.ts
17
+ /**
18
+ * Deploy keys and Actions secrets, for connecting a site's private code and
19
+ * content repositories: each workflow reads the other repository with a
20
+ * deploy key, whose private half is a secret of the repository it runs in.
21
+ * Loaded only by the setup page, when it connects them.
22
+ */
23
+ var deploy_keys_exports = /* @__PURE__ */ __exportAll({
24
+ newDeployKey: () => newDeployKey,
25
+ openSealedSecret: () => openSealedSecret,
26
+ sealSecret: () => sealSecret
27
+ });
28
+ function concat(...parts) {
29
+ const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
30
+ let at = 0;
31
+ for (const part of parts) {
32
+ result.set(part, at);
33
+ at += part.length;
34
+ }
35
+ return result;
36
+ }
37
+ /** SSH's encoding of a number: four bytes, most significant first. */
38
+ function uint32(value) {
39
+ const bytes = /* @__PURE__ */ new Uint8Array(4);
40
+ new DataView(bytes.buffer).setUint32(0, value);
41
+ return bytes;
42
+ }
43
+ /** SSH's encoding of a string of bytes: its length, then the bytes. */
44
+ function sshString(value) {
45
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
46
+ return concat(uint32(bytes.length), bytes);
47
+ }
48
+ /** A new Ed25519 key pair, made with the browser's own Web Crypto, as `ssh-keygen -t ed25519 -N ""` would make it. */
49
+ async function newDeployKey(comment) {
50
+ const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
51
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
52
+ const seed = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).slice(-32);
53
+ const publicBlob = concat(sshString("ssh-ed25519"), sshString(publicKey));
54
+ const check = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4));
55
+ let secret = concat(check, check, sshString("ssh-ed25519"), sshString(publicKey), sshString(concat(seed, publicKey)), sshString(comment));
56
+ const padding = Array.from({ length: (8 - secret.length % 8) % 8 }, (_, index) => index + 1);
57
+ secret = concat(secret, new Uint8Array(padding));
58
+ const body = concat(new TextEncoder().encode("openssh-key-v1\0"), sshString("none"), sshString("none"), sshString(""), uint32(1), sshString(publicBlob), sshString(secret));
59
+ const lines = encodeBase64Bytes(body).match(/.{1,70}/g) ?? [];
60
+ seed.fill(0);
61
+ secret.fill(0);
62
+ return {
63
+ publicKey: `ssh-ed25519 ${encodeBase64Bytes(publicBlob)} ${comment}`,
64
+ privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join("\n")}\n-----END OPENSSH PRIVATE KEY-----\n`
65
+ };
66
+ }
67
+ const SIGMA = new TextEncoder().encode("expand 32-byte k");
68
+ function words(bytes) {
69
+ return new Uint32Array(bytes.slice().buffer);
70
+ }
71
+ /**
72
+ * Encrypts a value for an Actions secret, as libsodium's `crypto_box_seal`
73
+ * does, which is what GitHub expects: an X25519 key of its own, the Web
74
+ * Crypto key agreement with the repository's public key, and XSalsa20-Poly1305.
75
+ */
76
+ async function sealSecret(value, recipient) {
77
+ const ephemeral = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
78
+ const ephemeralPublic = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
79
+ const recipientKey = await crypto.subtle.importKey("raw", recipient.slice(), { name: "X25519" }, false, []);
80
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({
81
+ name: "X25519",
82
+ public: recipientKey
83
+ }, ephemeral.privateKey, 256));
84
+ const key = /* @__PURE__ */ new Uint32Array(8);
85
+ hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
86
+ const nonce = blake2b(concat(ephemeralPublic, recipient), { dkLen: 24 });
87
+ const keyBytes = new Uint8Array(key.buffer);
88
+ const sealed = xsalsa20poly1305(keyBytes, nonce).encrypt(new TextEncoder().encode(value));
89
+ shared.fill(0);
90
+ keyBytes.fill(0);
91
+ return concat(ephemeralPublic, sealed);
92
+ }
93
+ /** Opens a sealed value with the recipient's X25519 key pair, as GitHub does with a secret. For the fake. */
94
+ async function openSealedSecret(sealed, recipient) {
95
+ const ephemeralPublic = sealed.slice(0, 32);
96
+ const recipientPublic = new Uint8Array(await crypto.subtle.exportKey("raw", recipient.publicKey));
97
+ const ephemeralKey = await crypto.subtle.importKey("raw", ephemeralPublic, { name: "X25519" }, false, []);
98
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({
99
+ name: "X25519",
100
+ public: ephemeralKey
101
+ }, recipient.privateKey, 256));
102
+ const key = /* @__PURE__ */ new Uint32Array(8);
103
+ hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
104
+ const nonce = blake2b(concat(ephemeralPublic, recipientPublic), { dkLen: 24 });
105
+ return new TextDecoder().decode(xsalsa20poly1305(new Uint8Array(key.buffer), nonce).decrypt(sealed.slice(32)));
106
+ }
107
+ //#endregion
108
+ export { openSealedSecret as n, deploy_keys_exports as t };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,5 @@
1
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";
2
2
  //#region src/sign-in.d.ts
3
- /** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
4
- export declare const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
5
3
  /** The page's address and how to change it. Replaceable in tests. */
6
4
  interface BrowserLocation {
7
5
  href(): string;
@@ -42,6 +40,19 @@ interface GitHubBackendOptions {
42
40
  name: string;
43
41
  email: string;
44
42
  };
43
+ /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
44
+ revision?: string;
45
+ /** The repository holds only the site's content, with its code in a repository of its own (`GitBackend.contentOnly`). */
46
+ contentOnly?: boolean;
47
+ /**
48
+ * The code repository, for a repository that holds only content, where the
49
+ * site's updates run: `api` reads it, absent where the sign-in can't.
50
+ */
51
+ code?: {
52
+ repo: string;
53
+ webUrl: string;
54
+ api?: ApiOptions;
55
+ };
45
56
  }
46
57
  /**
47
58
  * The site's files on GitHub. Reads come from the commit `revision()` last
@@ -53,8 +64,10 @@ export declare class GitHubBackend implements GitBackend {
53
64
  readonly pages: PagesDomains;
54
65
  readonly editors: SiteEditors;
55
66
  readonly ownerAccess: OwnerAccess;
56
- readonly updates: SiteUpdates;
67
+ /** Absent where the repository holds only content: the update job runs where the code is. */
68
+ readonly updates?: SiteUpdates;
57
69
  readonly drafts: SiteDrafts;
70
+ readonly contentOnly: boolean;
58
71
  private readonly options;
59
72
  private readonly api;
60
73
  private readonly repo;
@@ -82,7 +95,7 @@ export declare class GitHubBackend implements GitBackend {
82
95
  list(dir: string): Promise<string[]>;
83
96
  fileSizes(dir: string): Promise<Map<string, number>>;
84
97
  /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
85
- useOwnerSignIn(token: TokenSource, end: () => void): void;
98
+ useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
86
99
  /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
87
100
  ownerSignInFailed(error: unknown): void;
88
101
  write(changes: FileChange[], options: WriteOptions): Promise<{
@@ -122,7 +135,7 @@ interface GitHubSetupOptions {
122
135
  apiUrl?: string;
123
136
  /** For GitHub Enterprise. Defaults to `https://github.com`. */
124
137
  webUrl?: string;
125
- /** The sign-in worker, for "Sign in with GitHub". Defaults to Goodfellow's; `false` for token sign-in only. */
138
+ /** The address of a sign-in worker, for "Sign in with GitHub". Without one, people sign in with a token. */
126
139
  signIn?: string | false;
127
140
  /** For tests. */
128
141
  fetch?: typeof fetch;
@@ -136,8 +149,9 @@ interface GitHubSetupOptions {
136
149
  }
137
150
  /**
138
151
  * Links to GitHub's token pages with what creating a site needs: creating the
139
- * repository (administration), writing its files and its build setup
140
- * (contents and workflows), and turning on GitHub Pages (pages).
152
+ * repository and adding deploy keys (administration), writing its files and
153
+ * its build setup (contents and workflows), turning on GitHub Pages (pages),
154
+ * and giving a workflow the key to another of the site's repositories (secrets).
141
155
  */
142
156
  export declare function githubSetupTokenLinks(webUrl?: string): {
143
157
  fineGrained: string;
@@ -157,11 +171,22 @@ export interface GitHubOptions {
157
171
  /** For GitHub Enterprise. Defaults to `https://github.com`. */
158
172
  webUrl?: string;
159
173
  /**
160
- * The sign-in worker, which lets editors sign in with GitHub instead of
161
- * pasting a token. Defaults to Goodfellow's (`GOODFELLOW_SIGN_IN`); give the
162
- * address of your own copy, or `false` for token sign-in only.
174
+ * The address of a sign-in worker, which lets editors sign in with GitHub
175
+ * instead of pasting a token. Without one, editors sign in with a token.
163
176
  */
164
177
  signIn?: string | false;
178
+ /**
179
+ * The repository holds only the site's content (`content/` and
180
+ * `public/media/`), and its code is in a repository of its own. The admin
181
+ * panel then leaves adding blocks, which changes code, to a developer.
182
+ */
183
+ contentOnly?: boolean;
184
+ /**
185
+ * With `contentOnly`, the repository the site's code is kept in, as
186
+ * `owner/name`, where its updates run: the Updates screen follows them.
187
+ * Editors' tokens need to reach it too: a sign-in worker's app installed on it, or tokens made for both.
188
+ */
189
+ code?: string;
165
190
  /** For tests. */
166
191
  fetch?: typeof fetch;
167
192
  /** For tests. */
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, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, 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
  },
@@ -322,17 +331,19 @@ function githubPages(api, repo) {
322
331
  //#region src/updates.ts
323
332
  /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
324
333
  const WORKFLOW = "deploy.yml";
325
- const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
334
+ /** The code repository's workflow, where a site keeps its content in a repository of its own. */
335
+ const CODE_WORKFLOW = "update.yml";
326
336
  /**
327
337
  * The site's nightly update, which runs in its GitHub Pages workflow. Running
328
338
  * it now dispatches the workflow, which needs GitHub's Actions permission
329
339
  * with write access: `manage()` gives the owner token when one is in use.
330
340
  */
331
- function githubUpdates(read, manage, repo, branch, files) {
332
- const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
341
+ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
342
+ const path = `/repos/${repo}/actions/workflows/${workflow}`;
343
+ const workflowFile = `.github/workflows/${workflow}`;
333
344
  return {
334
345
  async setup() {
335
- return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
346
+ return (await files.read(workflowFile))?.includes("update --publish") ? "ready" : "missing";
336
347
  },
337
348
  async lastRun() {
338
349
  const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
@@ -390,6 +401,73 @@ function githubUpdates(read, manage, repo, branch, files) {
390
401
  }
391
402
  };
392
403
  }
404
+ /** Whether an error means the sign-in can't see the code repository, or has no token for it. */
405
+ function noAccess(error) {
406
+ return error instanceof SignInError && error.problem === "no-access" || error instanceof GitApiError && (error.status === 403 || error.status === 404);
407
+ }
408
+ /**
409
+ * The nightly update of a site whose content is kept in a repository of its
410
+ * own, which runs in the code repository's `update.yml`. The sign-in needs to
411
+ * see the code repository too; where it can't, `setup()` says `no-access`.
412
+ */
413
+ function githubCodeUpdates(options) {
414
+ const { read } = options;
415
+ const repo = repoPath(options.repo);
416
+ let branch;
417
+ const liveBranch = (api) => {
418
+ branch ??= githubJson(api, `/repos/${repo}`).then((found) => found.default_branch);
419
+ branch.catch(() => {
420
+ branch = void 0;
421
+ });
422
+ return branch;
423
+ };
424
+ /** The code repository at its newest commit, read again each time, since updates change it. */
425
+ async function files() {
426
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
427
+ return options.open(read, await liveBranch(read));
428
+ }
429
+ /** A file as it is now: setup files are outside the folders backends list, so they're read at the newest commit. */
430
+ async function readNow(path) {
431
+ const backend = await files();
432
+ return backend.readAt(path, await backend.revision());
433
+ }
434
+ async function updates() {
435
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
436
+ const target = await liveBranch(read);
437
+ return githubUpdates(read, options.manage, repo, target, {
438
+ read: readNow,
439
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
440
+ }, CODE_WORKFLOW);
441
+ }
442
+ return {
443
+ code: {
444
+ repo: options.repo,
445
+ webUrl: `${options.webUrl}/${options.repo}`,
446
+ read: readNow,
447
+ history: async (path, history) => (await files()).history(path, history),
448
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
449
+ },
450
+ async setup() {
451
+ try {
452
+ return await (await updates()).setup();
453
+ } catch (error) {
454
+ if (noAccess(error)) return "no-access";
455
+ throw error;
456
+ }
457
+ },
458
+ async lastRun() {
459
+ try {
460
+ return await (await updates()).lastRun();
461
+ } catch (error) {
462
+ if (noAccess(error)) return void 0;
463
+ throw error;
464
+ }
465
+ },
466
+ async start(target) {
467
+ await (await updates()).start(target);
468
+ }
469
+ };
470
+ }
393
471
  //#endregion
394
472
  //#region src/backend.ts
395
473
  /** How many files one GraphQL request reads. */
@@ -407,8 +485,10 @@ var GitHubBackend = class GitHubBackend {
407
485
  pages;
408
486
  editors;
409
487
  ownerAccess;
488
+ /** Absent where the repository holds only content: the update job runs where the code is. */
410
489
  updates;
411
490
  drafts;
491
+ contentOnly;
412
492
  options;
413
493
  api;
414
494
  repo;
@@ -429,12 +509,28 @@ var GitHubBackend = class GitHubBackend {
429
509
  this.user = options.user;
430
510
  this.onSignOut = options.onSignOut;
431
511
  this.author = options.author;
512
+ this.pinned = options.revision;
432
513
  const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
433
514
  this.owner = owner;
434
515
  this.ownerAccess = owner.access;
435
516
  this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
436
517
  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);
518
+ this.contentOnly = options.contentOnly === true;
519
+ if (!this.contentOnly) this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
520
+ else if (options.code) this.updates = githubCodeUpdates({
521
+ repo: options.code.repo,
522
+ webUrl: options.code.webUrl,
523
+ read: options.code.api,
524
+ manage: owner.codeApi,
525
+ open: (api, branch) => new GitHubBackend({
526
+ api,
527
+ repo: options.code?.repo ?? "",
528
+ branch,
529
+ user: this.user,
530
+ onSignOut: () => {},
531
+ ownerTokenLink: options.ownerTokenLink
532
+ })
533
+ });
438
534
  this.drafts = branchDrafts(this, this.draftHost());
439
535
  }
440
536
  /** Drafts on branches of the repository, with pull requests for review. */
@@ -584,14 +680,15 @@ var GitHubBackend = class GitHubBackend {
584
680
  return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
585
681
  }
586
682
  /** 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);
683
+ useOwnerSignIn(token, end, codeToken) {
684
+ this.owner.useSignIn(token, end, codeToken);
589
685
  }
590
686
  /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
591
687
  ownerSignInFailed(error) {
592
688
  this.owner.failed(error);
593
689
  }
594
690
  async write(changes, options) {
691
+ if (this.contentOnly) assertContentChanges(changes);
595
692
  return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
596
693
  }
597
694
  /**
@@ -781,7 +878,8 @@ var GitHubBackend = class GitHubBackend {
781
878
  const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
782
879
  if (!response.ok) return void 0;
783
880
  const { workflow_runs: runs = [] } = await response.json();
784
- return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
881
+ const builds = runs.filter((run) => !run.path?.endsWith(`/${REBUILD_JOB}.yml`));
882
+ return builds.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? builds[0];
785
883
  }
786
884
  /** Which step of a failed run failed. */
787
885
  async runProblem(run) {
@@ -814,8 +912,6 @@ var GitHubBackend = class GitHubBackend {
814
912
  * repository, lasting an hour. Requests are sent as plain text, so browsers
815
913
  * send them straight away without asking the worker first.
816
914
  */
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
915
  /** The worker's problems, as the admin panel's sign-in problems. */
820
916
  const PROBLEMS = {
821
917
  invalid: "invalid",
@@ -935,32 +1031,46 @@ var SignInService = class {
935
1031
  });
936
1032
  return `${this.url}/github/start?${query}`;
937
1033
  }
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) {
1034
+ /**
1035
+ * Exchanges the grant the worker came back with, and this tab's verifier,
1036
+ * for a session (and for a site, its first token, and with `code`, one for
1037
+ * its code repository).
1038
+ */
1039
+ session(grant, verifier, code) {
940
1040
  return this.post("/github/session", {
941
1041
  grant,
942
- verifier
1042
+ verifier,
1043
+ ...code && { code }
943
1044
  });
944
1045
  }
945
- token(session) {
946
- return this.post("/github/token", { session });
1046
+ token(session, code) {
1047
+ return this.post("/github/token", {
1048
+ session,
1049
+ ...code && { code }
1050
+ });
947
1051
  }
948
1052
  };
949
1053
  /** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
950
- function renewingToken(service, first, onRenew) {
1054
+ function renewingToken(service, first, onRenew, code) {
951
1055
  let current = first;
952
1056
  let renewing;
1057
+ async function token({ refresh = false } = {}) {
1058
+ if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
1059
+ renewing ??= service.token(current.session, code).finally(() => {
1060
+ renewing = void 0;
1061
+ });
1062
+ current = await renewing;
1063
+ onRenew(current);
1064
+ }
1065
+ return current.token;
1066
+ }
953
1067
  return {
954
1068
  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;
1069
+ token,
1070
+ async codeToken(options) {
1071
+ await token(options);
1072
+ if (!current.code) throw new SignInError("no-access", "The sign-in service gave no token for the code repository.");
1073
+ return current.code.token;
964
1074
  }
965
1075
  };
966
1076
  }
@@ -970,8 +1080,9 @@ function renewingToken(service, first, onRenew) {
970
1080
  const MAIN = "main";
971
1081
  /**
972
1082
  * 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).
1083
+ * repository and adding deploy keys (administration), writing its files and
1084
+ * its build setup (contents and workflows), turning on GitHub Pages (pages),
1085
+ * and giving a workflow the key to another of the site's repositories (secrets).
975
1086
  */
976
1087
  function githubSetupTokenLinks(webUrl = "https://github.com") {
977
1088
  const fineGrained = new URLSearchParams({
@@ -981,7 +1092,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
981
1092
  administration: "write",
982
1093
  contents: "write",
983
1094
  workflows: "write",
984
- pages: "write"
1095
+ pages: "write",
1096
+ secrets: "write"
985
1097
  });
986
1098
  const classic = new URLSearchParams({
987
1099
  scopes: "repo,workflow",
@@ -994,6 +1106,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
994
1106
  }
995
1107
  /** The steps after the repository exists, which are the same however it was created. */
996
1108
  function setupAccount(creator) {
1109
+ /** Each repository created, and the token for setting it up, for connecting it to another one later. */
1110
+ const createdApis = /* @__PURE__ */ new Map();
997
1111
  /** Makes `main` the default branch, which the deploy setups build from. */
998
1112
  async function useMainBranch(api, repo) {
999
1113
  if (repo.default_branch === MAIN) return;
@@ -1108,6 +1222,53 @@ function setupAccount(creator) {
1108
1222
  ]
1109
1223
  })).ok;
1110
1224
  }
1225
+ /**
1226
+ * Lets each private repository's workflows read the other, as their setup
1227
+ * files say: a read-only deploy key on the one read, with its private half
1228
+ * a secret of the one reading. Public repositories need nothing. Returns
1229
+ * whether it worked: an older sign-in may not be allowed to set secrets.
1230
+ */
1231
+ async function connectRepositories(link) {
1232
+ if (!link.private) return true;
1233
+ const { newDeployKey, sealSecret } = await import("./deploy-keys-DAj2oX-N.js").then((n) => n.t);
1234
+ try {
1235
+ for (const { reader, read, secret } of link.links.reads) {
1236
+ if (!secret) continue;
1237
+ const readerRepo = link[reader];
1238
+ const readRepo = link[read];
1239
+ const readerApi = createdApis.get(readerRepo.toLowerCase());
1240
+ const readApi = createdApis.get(readRepo.toLowerCase());
1241
+ if (!readerApi || !readApi) throw new Error(`${readerRepo} and ${readRepo} have to be created first.`);
1242
+ const key = await newDeployKey(`goodfellow-${secret.toLowerCase()}`);
1243
+ await githubRequest(readApi, `/repos/${repoPath(readRepo)}/keys`, {
1244
+ method: "POST",
1245
+ body: {
1246
+ title: `${readerRepo}'s builds`,
1247
+ key: key.publicKey,
1248
+ read_only: true
1249
+ }
1250
+ });
1251
+ const base = `/repos/${repoPath(readerRepo)}/actions/secrets`;
1252
+ const publicKey = await githubJson(readerApi, `${base}/public-key`);
1253
+ const sealed = await sealSecret(key.privateKey, decodeBase64(publicKey.key));
1254
+ await githubRequest(readerApi, `${base}/${secret}`, {
1255
+ method: "PUT",
1256
+ body: {
1257
+ encrypted_value: encodeBase64Bytes(sealed),
1258
+ key_id: publicKey.key_id
1259
+ }
1260
+ });
1261
+ }
1262
+ return true;
1263
+ } catch (error) {
1264
+ if (error instanceof GitApiError && [
1265
+ 403,
1266
+ 404,
1267
+ 422
1268
+ ].includes(error.status)) return false;
1269
+ throw error;
1270
+ }
1271
+ }
1111
1272
  return {
1112
1273
  user: creator.user,
1113
1274
  owners: () => creator.owners(),
@@ -1116,6 +1277,10 @@ function setupAccount(creator) {
1116
1277
  const warnings = [];
1117
1278
  onStep?.("repository");
1118
1279
  const { repo, api, signInInstallUrl } = await creator.create(site);
1280
+ const connect = async (link) => {
1281
+ onStep?.("connect");
1282
+ return connectRepositories(link);
1283
+ };
1119
1284
  await useMainBranch(api, repo);
1120
1285
  let siteUrl;
1121
1286
  if (site.pages) {
@@ -1123,6 +1288,8 @@ function setupAccount(creator) {
1123
1288
  siteUrl = await turnOnPages(api, repo);
1124
1289
  if (!siteUrl) warnings.push("pages-unavailable");
1125
1290
  }
1291
+ createdApis.set(repo.full_name.toLowerCase(), api);
1292
+ if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
1126
1293
  onStep?.("files");
1127
1294
  await writeFiles(api, repo, site);
1128
1295
  onStep?.("protection");
@@ -1233,7 +1400,8 @@ function workerCreator(service, apiUrl, fetcher, signedIn) {
1233
1400
  owner: site.owner.path,
1234
1401
  name: site.name,
1235
1402
  private: site.private,
1236
- description: site.description
1403
+ description: site.description,
1404
+ ...site.more && { more: true }
1237
1405
  });
1238
1406
  if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
1239
1407
  return {
@@ -1264,7 +1432,7 @@ function githubSetup(options = {}) {
1264
1432
  const links = githubSetupTokenLinks(options.webUrl);
1265
1433
  const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1266
1434
  const location = options.location ?? browserLocation();
1267
- const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1435
+ const signInUrl = options.signIn;
1268
1436
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1269
1437
  const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
1270
1438
  return {
@@ -1360,14 +1528,24 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1360
1528
  /** Sign-in and storage for a site whose repository is on GitHub. */
1361
1529
  function github(options) {
1362
1530
  if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
1531
+ if (options.code !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(options.code)) throw new Error(`github({ code }) must look like "owner/name" (got "${options.code}").`);
1363
1532
  const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1533
+ const webUrl = (options.webUrl ?? "https://github.com").replace(/\/$/, "");
1534
+ /** The code repository, where the content is in a repository of its own, read with `api`. */
1535
+ const codeRepository = (api) => options.contentOnly && options.code ? { code: {
1536
+ repo: options.code,
1537
+ webUrl,
1538
+ ...api && { api }
1539
+ } } : {};
1364
1540
  const links = githubTokenLinks(options.repo, options.webUrl);
1365
1541
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1366
1542
  const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1367
1543
  const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1368
1544
  const location = options.location ?? browserLocation();
1369
- const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1545
+ const signInUrl = options.signIn;
1370
1546
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1547
+ /** The code repository to ask the worker for a token for, where the Updates screen follows it. */
1548
+ const codeName = () => options.contentOnly ? options.code : void 0;
1371
1549
  function save(value, remember) {
1372
1550
  storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
1373
1551
  }
@@ -1410,7 +1588,7 @@ function github(options) {
1410
1588
  const source = renewingToken(service, issued, (renewed) => save({
1411
1589
  type: "worker",
1412
1590
  session: renewed.session
1413
- }, remember));
1591
+ }, remember), options.code);
1414
1592
  save({
1415
1593
  type: "worker",
1416
1594
  session: issued.session
@@ -1426,6 +1604,12 @@ function github(options) {
1426
1604
  api,
1427
1605
  repo: options.repo,
1428
1606
  branch: options.branch ?? repo.default_branch,
1607
+ ...options.contentOnly && { contentOnly: true },
1608
+ ...codeRepository({
1609
+ apiUrl,
1610
+ token: source.codeToken,
1611
+ fetch: fetcher
1612
+ }),
1429
1613
  user: {
1430
1614
  login: user.login,
1431
1615
  name: user.name,
@@ -1437,7 +1621,7 @@ function github(options) {
1437
1621
  },
1438
1622
  onSignOut: () => {
1439
1623
  storage.clear();
1440
- revokeToken(source.current().token);
1624
+ revokeIssued(source.current());
1441
1625
  },
1442
1626
  ownerTokenLink: {
1443
1627
  url: links.owner,
@@ -1454,12 +1638,17 @@ function github(options) {
1454
1638
  fetch: fetcher
1455
1639
  }, "/installation/token", { method: "DELETE" }).catch(() => {});
1456
1640
  }
1641
+ /** Revokes the tokens the worker gave: the site's, and the code repository's. */
1642
+ function revokeIssued(issued) {
1643
+ revokeToken(issued.token);
1644
+ if (issued.code) revokeToken(issued.code.token);
1645
+ }
1457
1646
  /** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
1458
1647
  async function useOwner(backend, grant, verifier) {
1459
1648
  const worker = service;
1460
1649
  try {
1461
- const source = renewingToken(worker, await worker.session(grant, verifier), () => {});
1462
- backend.useOwnerSignIn(source.token, () => revokeToken(source.current().token));
1650
+ const source = renewingToken(worker, await worker.session(grant, verifier, codeName()), () => {}, codeName());
1651
+ backend.useOwnerSignIn(source.token, () => revokeIssued(source.current()), codeName() ? source.codeToken : void 0);
1463
1652
  } catch (error) {
1464
1653
  backend.ownerSignInFailed(error);
1465
1654
  }
@@ -1486,7 +1675,7 @@ function github(options) {
1486
1675
  return backend;
1487
1676
  }
1488
1677
  if (failed || !grant) throw signInProblem({ problem: failed });
1489
- return connectWorker(await service.session(grant, redirect.verifier), redirect.remember);
1678
+ return connectWorker(await service.session(grant, redirect.verifier, codeName()), redirect.remember);
1490
1679
  }
1491
1680
  async function restoreSaved() {
1492
1681
  const saved = loadSaved();
@@ -1497,7 +1686,7 @@ function github(options) {
1497
1686
  storage.clear();
1498
1687
  return null;
1499
1688
  }
1500
- return await connectWorker(await service.token(saved.session), storage.remembered());
1689
+ return await connectWorker(await service.token(saved.session, codeName()), storage.remembered());
1501
1690
  } catch (error) {
1502
1691
  if (forgets(error)) storage.clear();
1503
1692
  throw error;
@@ -1522,6 +1711,8 @@ function github(options) {
1522
1711
  api,
1523
1712
  repo: options.repo,
1524
1713
  branch: options.branch ?? repo.default_branch,
1714
+ ...options.contentOnly && { contentOnly: true },
1715
+ ...codeRepository(api),
1525
1716
  user: {
1526
1717
  login: user.login,
1527
1718
  name: user.name ?? void 0,
@@ -1535,6 +1726,46 @@ function github(options) {
1535
1726
  ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
1536
1727
  });
1537
1728
  }
1729
+ /** The repository through a token of the site's own, on a full-stack site's server. */
1730
+ function server({ token }) {
1731
+ const api = {
1732
+ apiUrl,
1733
+ token: typeof token === "string" ? token : () => token(),
1734
+ fetch: fetcher
1735
+ };
1736
+ let branch;
1737
+ const liveBranch = () => {
1738
+ branch ??= options.branch ? Promise.resolve(options.branch) : githubJson(api, `/repos/${repoPath(options.repo)}`).then((repo) => repo.default_branch);
1739
+ branch.catch(() => {
1740
+ branch = void 0;
1741
+ });
1742
+ return branch;
1743
+ };
1744
+ const backend = async (revision, author) => new GitHubBackend({
1745
+ api,
1746
+ repo: options.repo,
1747
+ branch: await liveBranch(),
1748
+ ...options.contentOnly && { contentOnly: true },
1749
+ user: {
1750
+ login: author?.name ?? options.repo,
1751
+ ...author && { name: author.name }
1752
+ },
1753
+ onSignOut: () => {},
1754
+ ownerTokenLink: {
1755
+ url: links.owner,
1756
+ label: "owner.token.create"
1757
+ },
1758
+ ...revision && { revision },
1759
+ ...author && { author }
1760
+ });
1761
+ return {
1762
+ ...options.contentOnly && { contentOnly: true },
1763
+ async revision() {
1764
+ return (await backend()).revision();
1765
+ },
1766
+ at: backend
1767
+ };
1768
+ }
1538
1769
  return {
1539
1770
  name: "GitHub",
1540
1771
  canRedirect: service !== void 0,
@@ -1563,8 +1794,9 @@ function github(options) {
1563
1794
  },
1564
1795
  startRedirect(remember, redirectOptions) {
1565
1796
  return redirect("edit", remember, redirectOptions);
1566
- }
1797
+ },
1798
+ server
1567
1799
  };
1568
1800
  }
1569
1801
  //#endregion
1570
- export { GOODFELLOW_SIGN_IN, GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
1802
+ export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
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 set Actions secrets and add deploy keys. Defaults to true. */
24
+ secrets?: boolean;
23
25
  }
24
26
  /** A GitHub Actions workflow run, with its jobs and their steps. */
25
27
  export interface FakeRun {
@@ -32,6 +34,8 @@ export interface FakeRun {
32
34
  createdAt?: string;
33
35
  status: "queued" | "in_progress" | "completed";
34
36
  conclusion: "success" | "failure" | "startup_failure" | "cancelled" | null;
37
+ /** The workflow's file, `.github/workflows/deploy.yml` by default. */
38
+ path?: string;
35
39
  jobs: Array<{
36
40
  name: string;
37
41
  conclusion: string | null;
@@ -127,6 +131,18 @@ export interface FakeCreatedRepo {
127
131
  build_type: string;
128
132
  };
129
133
  rulesets: unknown[];
134
+ /** Deploy keys added to it, by their public key. */
135
+ deployKeys: Array<{
136
+ title: string;
137
+ key: string;
138
+ read_only: boolean;
139
+ }>;
140
+ /** Its Actions secrets, opened. */
141
+ secrets: Map<string, string>;
142
+ /** The key pair GitHub seals secrets with. */
143
+ secretKey?: CryptoKeyPair;
144
+ /** People's permission on it, by login, as `collaborators/{login}/permission` says; others have none. */
145
+ collaborators?: Map<string, "admin" | "write" | "read">;
130
146
  }
131
147
  interface Deployment {
132
148
  id: number;
@@ -150,8 +166,13 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
150
166
  code: string;
151
167
  installation: number;
152
168
  };
153
- /** Repositories created through the API, by `owner/name`. */
169
+ /** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
154
170
  created: Map<string, FakeCreatedRepo>;
171
+ addRepo: (fullName: string, repoOptions?: {
172
+ files?: Record<string, string>;
173
+ private?: boolean;
174
+ collaborators?: Record<string, "admin" | "write" | "read">;
175
+ }) => FakeCreatedRepo;
155
176
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
156
177
  pages: {
157
178
  current?: FakePages;
package/dist/testing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as openSealedSecret } from "./deploy-keys-DAj2oX-N.js";
1
2
  import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
2
3
  import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@goodfellow-cms/core/testing";
3
4
  //#region src/testing.ts
@@ -192,6 +193,21 @@ function fakeGitHub(options) {
192
193
  const [invitation] = index === -1 ? [] : invitations.splice(index, 1);
193
194
  if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
194
195
  }
196
+ /** Adds another repository, as if someone had made it, such as a site's code repository beside its content's. */
197
+ function addRepo(fullName, repoOptions = {}) {
198
+ const added = {
199
+ id: nextRepoId++,
200
+ fullName,
201
+ private: repoOptions.private === true,
202
+ repo: new FakeRepo(repoOptions.files ?? {}, "main"),
203
+ rulesets: [],
204
+ deployKeys: [],
205
+ secrets: /* @__PURE__ */ new Map(),
206
+ collaborators: new Map(Object.entries(repoOptions.collaborators ?? {}))
207
+ };
208
+ created.set(fullName, added);
209
+ return added;
210
+ }
195
211
  /** Repository creation and the Git data API, for repositories created through the fake. */
196
212
  async function handleCreated(request, path, principal) {
197
213
  const login = principal.login;
@@ -215,7 +231,9 @@ function fakeGitHub(options) {
215
231
  private: body.private === true,
216
232
  description: body.description,
217
233
  repo,
218
- rulesets: []
234
+ rulesets: [],
235
+ deployKeys: [],
236
+ secrets: /* @__PURE__ */ new Map()
219
237
  });
220
238
  return json({
221
239
  id,
@@ -234,12 +252,48 @@ function fakeGitHub(options) {
234
252
  if (rest === "" && method === "GET") return json({
235
253
  id: site.id,
236
254
  full_name: site.fullName,
255
+ private: site.private,
237
256
  default_branch: repo.defaultBranch,
238
257
  permissions: {
239
258
  push: true,
240
259
  admin: true
241
260
  }
242
261
  });
262
+ const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
263
+ if (tree && method === "GET" && repo.commitAt(tree[1] ?? "")) {
264
+ const entries = [...repo.files(tree[1] ?? "")].map(([file, content]) => ({
265
+ path: file,
266
+ type: "blob",
267
+ sha: repo.blobSha(content),
268
+ size: fakeFileBytes(content).length
269
+ }));
270
+ return json({
271
+ sha: tree[1],
272
+ tree: entries,
273
+ truncated: false
274
+ });
275
+ }
276
+ const blob = rest.match(/^\/git\/blobs\/([0-9a-f]{40})$/);
277
+ if (blob && method === "GET") {
278
+ const content = repo.blobs.get(blob[1] ?? "");
279
+ if (content === void 0) return json({ message: "Not Found" }, 404);
280
+ return new Response(fakeFileBytes(content));
281
+ }
282
+ const contents = rest.match(/^\/contents\/(.+)$/);
283
+ if (contents && method === "GET") {
284
+ if (principal.app && !principal.permissions?.contents) return json({ message: "Resource not accessible" }, 403);
285
+ const file = repo.files().get(decodeURIComponent(contents[1] ?? ""));
286
+ if (file === void 0) return json({ message: "Not Found" }, 404);
287
+ return new Response(typeof file === "string" ? file : fakeFileBytes(file));
288
+ }
289
+ const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/);
290
+ if (permission && method === "GET") {
291
+ const login = decodeURIComponent(permission[1] ?? "");
292
+ return json({
293
+ permission: site.collaborators?.get(login) ?? "none",
294
+ user: { login }
295
+ });
296
+ }
243
297
  if (rest === "" && method === "PATCH") {
244
298
  const body = await request.json();
245
299
  if (body.default_branch) {
@@ -279,6 +333,42 @@ function fakeGitHub(options) {
279
333
  build_type: site.pages.build_type
280
334
  }, 201);
281
335
  }
336
+ const notAllowed = (permission) => principal.app ? principal.permissions?.[permission] !== "write" : principal.secrets === false;
337
+ if (rest === "/keys" && method === "POST") {
338
+ if (notAllowed("administration")) return json({ message: "Resource not accessible by integration" }, 403);
339
+ const body = await request.json();
340
+ if (!/^ssh-ed25519 [A-Za-z0-9+/=]+( |$)/.test(body.key)) return json({ message: "key is invalid" }, 422);
341
+ site.deployKeys.push({
342
+ title: body.title,
343
+ key: body.key,
344
+ read_only: body.read_only === true
345
+ });
346
+ return json({
347
+ id: site.deployKeys.length,
348
+ ...body
349
+ }, 201);
350
+ }
351
+ if (rest === "/actions/secrets/public-key" && method === "GET") {
352
+ if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
353
+ site.secretKey ??= await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
354
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", site.secretKey.publicKey));
355
+ return json({
356
+ key_id: `key-${site.id}`,
357
+ key: encodeBase64Bytes(raw)
358
+ });
359
+ }
360
+ const secret = rest.match(/^\/actions\/secrets\/([A-Z0-9_]+)$/);
361
+ if (secret && method === "PUT") {
362
+ if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
363
+ const body = await request.json();
364
+ if (!site.secretKey || body.key_id !== `key-${site.id}`) return json({ message: "Bad key id" }, 422);
365
+ try {
366
+ site.secrets.set(secret[1] ?? "", await openSealedSecret(decodeBase64(body.encrypted_value), site.secretKey));
367
+ } catch {
368
+ return json({ message: "Bad encrypted value" }, 422);
369
+ }
370
+ return new Response(null, { status: 201 });
371
+ }
282
372
  if (rest === "/rulesets" && method === "POST") {
283
373
  if (site.private && !options.paidPlan) return json({ message: "Upgrade to GitHub Pro" }, 403);
284
374
  site.rulesets.push(await request.json());
@@ -814,7 +904,10 @@ function fakeGitHub(options) {
814
904
  author: null
815
905
  })));
816
906
  }
817
- if (rest === "/actions/workflows/deploy.yml/dispatches" && request.method === "POST") {
907
+ const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
908
+ const workflowPath = `.github/workflows/${workflowFile}`;
909
+ const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
910
+ if (workflowFile && rest.endsWith("/dispatches") && request.method === "POST") {
818
911
  if (!(user.actionsWrite ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
819
912
  const body = await request.json();
820
913
  runs.push({
@@ -824,15 +917,17 @@ function fakeGitHub(options) {
824
917
  inputs: body.inputs,
825
918
  status: "queued",
826
919
  conclusion: null,
920
+ path: workflowPath,
827
921
  jobs: []
828
922
  });
829
923
  return new Response(null, { status: 204 });
830
924
  }
831
- if (rest === "/actions/workflows/deploy.yml/runs") {
925
+ if (workflowFile && rest.endsWith("/runs")) {
832
926
  if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
927
+ const found = runs.filter(ofWorkflow);
833
928
  return json({
834
- total_count: runs.length,
835
- workflow_runs: [...runs].reverse().map((run) => ({
929
+ total_count: found.length,
930
+ workflow_runs: [...found].reverse().map((run) => ({
836
931
  id: run.id,
837
932
  event: run.event ?? "push",
838
933
  head_sha: run.sha,
@@ -856,8 +951,8 @@ function fakeGitHub(options) {
856
951
  head_sha: run.sha,
857
952
  status: run.status,
858
953
  conclusion: run.conclusion,
859
- name: "Deploy to GitHub Pages",
860
- path: ".github/workflows/deploy.yml",
954
+ name: run.path ? "Rebuild on Vercel" : "Deploy to GitHub Pages",
955
+ path: run.path ?? ".github/workflows/deploy.yml",
861
956
  html_url: htmlUrl(run)
862
957
  }))
863
958
  });
@@ -913,8 +1008,9 @@ function fakeGitHub(options) {
913
1008
  commitAuthors,
914
1009
  authorize,
915
1010
  install,
916
- /** Repositories created through the API, by `owner/name`. */
1011
+ /** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
917
1012
  created,
1013
+ addRepo,
918
1014
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
919
1015
  pages,
920
1016
  /** The repository's collaborators, by login, and invitations waiting to be accepted. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/github",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,7 +35,9 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.3.0"
38
+ "@noble/ciphers": "2.4.0",
39
+ "@noble/hashes": "2.4.0",
40
+ "@goodfellow-cms/core": "0.4.0"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@puckeditor/core": "0.23.0"