@goodfellow-cms/github 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.d.ts +46 -4
- package/dist/index.js +690 -29
- package/dist/testing.d.ts +83 -1
- package/dist/testing.js +370 -5
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictError, GitApiError, SignInError, credentialStorage, encodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, compareReleases, credentialStorage, 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 = {}) {
|
|
@@ -28,6 +28,338 @@ function repoPath(repo) {
|
|
|
28
28
|
return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
29
29
|
}
|
|
30
30
|
//#endregion
|
|
31
|
+
//#region src/editors.ts
|
|
32
|
+
/** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
|
|
33
|
+
const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
|
|
34
|
+
const INVITATION = "invitation:";
|
|
35
|
+
function roleOf(permissions) {
|
|
36
|
+
if (permissions?.admin) return "owner";
|
|
37
|
+
return permissions?.maintain || permissions?.push ? "editor" : "viewer";
|
|
38
|
+
}
|
|
39
|
+
function invitationRole(permission) {
|
|
40
|
+
if (permission === "admin") return "owner";
|
|
41
|
+
return permission === "write" || permission === "maintain" ? "editor" : "viewer";
|
|
42
|
+
}
|
|
43
|
+
function isNotAllowed(error) {
|
|
44
|
+
return error instanceof GitApiError && (error.status === 403 || error.status === 404);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The repository's collaborators. Anyone who can publish can list them, but
|
|
48
|
+
* inviting and removing people needs a token with GitHub's Administration
|
|
49
|
+
* permission, which signing in doesn't ask for: `manage()` gives the owner
|
|
50
|
+
* token when one is in use.
|
|
51
|
+
*/
|
|
52
|
+
function githubEditors(read, manage, repo, login) {
|
|
53
|
+
const path = `/repos/${repo}`;
|
|
54
|
+
function managing() {
|
|
55
|
+
const api = manage();
|
|
56
|
+
if (!api) throw new EditorsError("not-allowed", "Changing editors on GitHub needs an owner token.");
|
|
57
|
+
return api;
|
|
58
|
+
}
|
|
59
|
+
async function change(run) {
|
|
60
|
+
try {
|
|
61
|
+
await run(managing());
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function collaborators(api) {
|
|
68
|
+
const all = [];
|
|
69
|
+
for (let page = 1; page <= 10; page++) {
|
|
70
|
+
const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
|
|
71
|
+
all.push(...found);
|
|
72
|
+
if (found.length < 100) break;
|
|
73
|
+
}
|
|
74
|
+
return all;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
invitesByEmail: false,
|
|
78
|
+
async list() {
|
|
79
|
+
const api = manage() ?? read;
|
|
80
|
+
const people = await collaborators(api);
|
|
81
|
+
let invitations;
|
|
82
|
+
try {
|
|
83
|
+
invitations = await githubJson(api, `${path}/invitations?per_page=100`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!isNotAllowed(error)) throw error;
|
|
86
|
+
}
|
|
87
|
+
const editors = [...people.map((person) => ({
|
|
88
|
+
id: person.login,
|
|
89
|
+
login: person.login,
|
|
90
|
+
avatarUrl: person.avatar_url,
|
|
91
|
+
role: roleOf(person.permissions),
|
|
92
|
+
...person.login.toLowerCase() === login.toLowerCase() && { self: true }
|
|
93
|
+
})), ...(invitations ?? []).flatMap((invitation) => invitation.invitee ? [{
|
|
94
|
+
id: `${INVITATION}${invitation.id}`,
|
|
95
|
+
login: invitation.invitee.login,
|
|
96
|
+
avatarUrl: invitation.invitee.avatar_url,
|
|
97
|
+
role: invitationRole(invitation.permissions),
|
|
98
|
+
invited: true
|
|
99
|
+
}] : [])];
|
|
100
|
+
const owner = editors.some((editor) => editor.self && editor.role === "owner");
|
|
101
|
+
return {
|
|
102
|
+
editors,
|
|
103
|
+
manage: invitations ? "yes" : owner ? "token" : "no",
|
|
104
|
+
editorsCanPublish: true
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async invite(who, role) {
|
|
108
|
+
const name = who.trim().replace(/^@/, "");
|
|
109
|
+
if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitHub username.");
|
|
110
|
+
const api = managing();
|
|
111
|
+
try {
|
|
112
|
+
await githubRequest(api, `${path}/collaborators/${encodeURIComponent(name)}`, {
|
|
113
|
+
method: "PUT",
|
|
114
|
+
body: { permission: role === "owner" ? "admin" : "push" }
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof GitApiError && error.status === 404) {
|
|
118
|
+
if ((await githubRequest(api, `/users/${encodeURIComponent(name)}`, { allow: [404] })).status === 404) throw new EditorsError("not-found", `There's no GitHub account named ${name}.`);
|
|
119
|
+
}
|
|
120
|
+
if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
121
|
+
if (error instanceof GitApiError && error.status === 422) throw new EditorsError("invalid", `GitHub didn't accept the invitation: ${error.message}`);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async setRole(editor, role) {
|
|
126
|
+
await change(async (api) => {
|
|
127
|
+
if (editor.id.startsWith(INVITATION)) await githubRequest(api, `${path}/invitations/${editor.id.slice(11)}`, {
|
|
128
|
+
method: "PATCH",
|
|
129
|
+
body: { permissions: role === "owner" ? "admin" : "write" }
|
|
130
|
+
});
|
|
131
|
+
else await githubRequest(api, `${path}/collaborators/${encodeURIComponent(editor.id)}`, {
|
|
132
|
+
method: "PUT",
|
|
133
|
+
body: { permission: role === "owner" ? "admin" : "push" }
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
async remove(editor) {
|
|
138
|
+
await change((api) => githubRequest(api, editor.id.startsWith(INVITATION) ? `${path}/invitations/${editor.id.slice(11)}` : `${path}/collaborators/${encodeURIComponent(editor.id)}`, { method: "DELETE" }));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The owner token: GitHub's Administration and Pages permissions, for this tab only. */
|
|
143
|
+
function githubOwnerAccess(base, repo, tokenLink) {
|
|
144
|
+
let owner;
|
|
145
|
+
return {
|
|
146
|
+
access: {
|
|
147
|
+
tokenLink,
|
|
148
|
+
get active() {
|
|
149
|
+
return owner !== void 0;
|
|
150
|
+
},
|
|
151
|
+
async applyToken(token) {
|
|
152
|
+
const api = {
|
|
153
|
+
...base,
|
|
154
|
+
token: token.trim()
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
if (!(await githubJson(api, `/repos/${repo}`)).permissions?.admin) throw new EditorsError("not-allowed", "This token's account isn't an owner of the site.");
|
|
158
|
+
await githubJson(api, `/repos/${repo}/invitations?per_page=1`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof EditorsError) throw error;
|
|
161
|
+
if (error instanceof SignInError || isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
owner = api;
|
|
165
|
+
},
|
|
166
|
+
forget() {
|
|
167
|
+
owner = void 0;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
api: () => owner
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/pages.ts
|
|
175
|
+
/** GitHub Pages' addresses for an apex domain, from GitHub's documentation. */
|
|
176
|
+
const PAGES_IPV4 = [
|
|
177
|
+
"185.199.108.153",
|
|
178
|
+
"185.199.109.153",
|
|
179
|
+
"185.199.110.153",
|
|
180
|
+
"185.199.111.153"
|
|
181
|
+
];
|
|
182
|
+
const PAGES_IPV6 = [
|
|
183
|
+
"2606:50c0:8000::153",
|
|
184
|
+
"2606:50c0:8001::153",
|
|
185
|
+
"2606:50c0:8002::153",
|
|
186
|
+
"2606:50c0:8003::153"
|
|
187
|
+
];
|
|
188
|
+
/** Certificate states in which GitHub serves the domain over HTTPS. */
|
|
189
|
+
const READY = /* @__PURE__ */ new Set([
|
|
190
|
+
"approved",
|
|
191
|
+
"issued",
|
|
192
|
+
"uploaded"
|
|
193
|
+
]);
|
|
194
|
+
const FAILED = /* @__PURE__ */ new Set([
|
|
195
|
+
"errored",
|
|
196
|
+
"bad_authz",
|
|
197
|
+
"authorization_revoked"
|
|
198
|
+
]);
|
|
199
|
+
/** The records GitHub Pages needs: its addresses for an apex domain, or an alias of the owner's github.io. */
|
|
200
|
+
function githubPagesRecords(repo, domain, { apex, www }) {
|
|
201
|
+
const target = `${(repo.split("/")[0] ?? "").toLowerCase()}.github.io`;
|
|
202
|
+
if (!apex) return [{
|
|
203
|
+
type: "CNAME",
|
|
204
|
+
name: domain,
|
|
205
|
+
value: target,
|
|
206
|
+
purpose: "site"
|
|
207
|
+
}];
|
|
208
|
+
return [
|
|
209
|
+
...PAGES_IPV4.map((value) => ({
|
|
210
|
+
type: "A",
|
|
211
|
+
name: domain,
|
|
212
|
+
value,
|
|
213
|
+
purpose: "site"
|
|
214
|
+
})),
|
|
215
|
+
...PAGES_IPV6.map((value) => ({
|
|
216
|
+
type: "AAAA",
|
|
217
|
+
name: domain,
|
|
218
|
+
value,
|
|
219
|
+
purpose: "site",
|
|
220
|
+
optional: true
|
|
221
|
+
})),
|
|
222
|
+
...www ? [{
|
|
223
|
+
type: "CNAME",
|
|
224
|
+
name: `www.${domain}`,
|
|
225
|
+
value: target,
|
|
226
|
+
purpose: "www",
|
|
227
|
+
optional: true
|
|
228
|
+
}] : []
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* GitHub Pages, for a site's repository: where it's published, and its custom
|
|
233
|
+
* domain. `api` gives the owner token when one is in use (see `ownerAccess`).
|
|
234
|
+
*/
|
|
235
|
+
function githubPages(api, repo) {
|
|
236
|
+
const path = `/repos/${repo}/pages`;
|
|
237
|
+
async function pages() {
|
|
238
|
+
const response = await githubRequest(api(), path, { allow: [404] });
|
|
239
|
+
return response.status === 404 ? void 0 : await response.json();
|
|
240
|
+
}
|
|
241
|
+
async function update(body) {
|
|
242
|
+
try {
|
|
243
|
+
await githubRequest(api(), path, {
|
|
244
|
+
method: "PUT",
|
|
245
|
+
body
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new DomainError("not-allowed", "This sign-in can't change the site's GitHub Pages settings.");
|
|
249
|
+
if (error instanceof GitApiError && (error.status === 400 || error.status === 422)) {
|
|
250
|
+
if (/taken|already/i.test(error.message)) throw new DomainError("taken", "Another GitHub Pages site already uses this domain.");
|
|
251
|
+
if ("https_enforced" in body) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
|
|
252
|
+
throw new DomainError("invalid", "GitHub Pages doesn't accept this domain.");
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
async site() {
|
|
259
|
+
const found = await pages();
|
|
260
|
+
return found && {
|
|
261
|
+
url: found.html_url,
|
|
262
|
+
domain: found.cname ?? void 0
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
async connect(domain, options) {
|
|
266
|
+
await update({ cname: domain });
|
|
267
|
+
return githubPagesRecords(repo, domain, options);
|
|
268
|
+
},
|
|
269
|
+
async records(domain, options) {
|
|
270
|
+
return githubPagesRecords(repo, domain, options);
|
|
271
|
+
},
|
|
272
|
+
async status(domain) {
|
|
273
|
+
const found = await githubJson(api(), path);
|
|
274
|
+
const state = found.https_certificate?.state ?? "";
|
|
275
|
+
return {
|
|
276
|
+
verified: true,
|
|
277
|
+
certificate: READY.has(state) ? "ready" : FAILED.has(state) ? "failed" : "pending",
|
|
278
|
+
httpsOnly: found.https_enforced === true,
|
|
279
|
+
primary: found.cname === domain
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
async secure() {
|
|
283
|
+
await update({ https_enforced: true });
|
|
284
|
+
},
|
|
285
|
+
async disconnect() {
|
|
286
|
+
await update({ cname: null });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/updates.ts
|
|
292
|
+
/** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
|
|
293
|
+
const WORKFLOW = "deploy.yml";
|
|
294
|
+
const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
|
|
295
|
+
/**
|
|
296
|
+
* The site's nightly update, which runs in its GitHub Pages workflow. Running
|
|
297
|
+
* it now dispatches the workflow, which needs GitHub's Actions permission
|
|
298
|
+
* with write access: `manage()` gives the owner token when one is in use.
|
|
299
|
+
*/
|
|
300
|
+
function githubUpdates(read, manage, repo, branch, files) {
|
|
301
|
+
const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
|
|
302
|
+
return {
|
|
303
|
+
async setup() {
|
|
304
|
+
return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
|
|
305
|
+
},
|
|
306
|
+
async lastRun() {
|
|
307
|
+
const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
|
|
308
|
+
if (!response.ok) return void 0;
|
|
309
|
+
const { workflow_runs: runs = [] } = await response.json();
|
|
310
|
+
for (const run of runs.filter((candidate) => ["schedule", "workflow_dispatch"].includes(candidate.event))) {
|
|
311
|
+
const base = {
|
|
312
|
+
date: run.created_at,
|
|
313
|
+
detailsUrl: run.html_url
|
|
314
|
+
};
|
|
315
|
+
if (run.status !== "completed") return {
|
|
316
|
+
state: "running",
|
|
317
|
+
...base
|
|
318
|
+
};
|
|
319
|
+
const { jobs = [] } = await githubJson(read, `/repos/${repo}/actions/runs/${run.id}/jobs`);
|
|
320
|
+
const job = jobs.find((candidate) => candidate.name === "update");
|
|
321
|
+
if (!job || job.conclusion === "skipped") continue;
|
|
322
|
+
if (job.conclusion !== "success") return {
|
|
323
|
+
state: "failed",
|
|
324
|
+
...base
|
|
325
|
+
};
|
|
326
|
+
const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
|
|
327
|
+
const now = siteVersion(await files.read("package.json") ?? "");
|
|
328
|
+
if (!before || !now || before === now) return {
|
|
329
|
+
state: "up-to-date",
|
|
330
|
+
...base
|
|
331
|
+
};
|
|
332
|
+
const rollback = compareReleases(now, before) < 0;
|
|
333
|
+
return {
|
|
334
|
+
state: "updated",
|
|
335
|
+
...base,
|
|
336
|
+
result: {
|
|
337
|
+
state: "updated",
|
|
338
|
+
from: before,
|
|
339
|
+
to: now,
|
|
340
|
+
...rollback && { rollback }
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
async start(target) {
|
|
346
|
+
try {
|
|
347
|
+
await githubRequest(manage() ?? read, `${path}/dispatches`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: {
|
|
350
|
+
ref: branch,
|
|
351
|
+
inputs: { update: target }
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
} catch (error) {
|
|
355
|
+
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.");
|
|
356
|
+
if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
31
363
|
//#region src/backend.ts
|
|
32
364
|
const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
33
365
|
createCommitOnBranch(input: $input) { commit { oid } }
|
|
@@ -39,6 +371,10 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
|
39
371
|
*/
|
|
40
372
|
var GitHubBackend = class {
|
|
41
373
|
user;
|
|
374
|
+
pages;
|
|
375
|
+
editors;
|
|
376
|
+
ownerAccess;
|
|
377
|
+
updates;
|
|
42
378
|
api;
|
|
43
379
|
repo;
|
|
44
380
|
branch;
|
|
@@ -52,6 +388,11 @@ var GitHubBackend = class {
|
|
|
52
388
|
this.branch = options.branch;
|
|
53
389
|
this.user = options.user;
|
|
54
390
|
this.onSignOut = options.onSignOut;
|
|
391
|
+
const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink);
|
|
392
|
+
this.ownerAccess = owner.access;
|
|
393
|
+
this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
|
|
394
|
+
this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
|
|
395
|
+
this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
|
|
55
396
|
}
|
|
56
397
|
async revision() {
|
|
57
398
|
const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
|
|
@@ -75,8 +416,11 @@ var GitHubBackend = class {
|
|
|
75
416
|
return this.tree(this.pinned ?? await this.revision());
|
|
76
417
|
}
|
|
77
418
|
async read(path) {
|
|
78
|
-
|
|
79
|
-
|
|
419
|
+
return this.readFrom(await this.currentTree(), path);
|
|
420
|
+
}
|
|
421
|
+
readFrom(tree, path) {
|
|
422
|
+
const sha = tree.get(path);
|
|
423
|
+
if (!sha) return Promise.resolve(void 0);
|
|
80
424
|
let blob = this.blobs.get(sha);
|
|
81
425
|
if (!blob) {
|
|
82
426
|
blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
|
|
@@ -131,68 +475,381 @@ var GitHubBackend = class {
|
|
|
131
475
|
if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
|
|
132
476
|
throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
|
|
133
477
|
}
|
|
478
|
+
async history(path, { page = 1, perPage = 20 } = {}) {
|
|
479
|
+
const query = new URLSearchParams({
|
|
480
|
+
sha: this.pinned ?? await this.revision(),
|
|
481
|
+
path,
|
|
482
|
+
per_page: String(perPage),
|
|
483
|
+
page: String(page)
|
|
484
|
+
});
|
|
485
|
+
return (await githubJson(this.api, `/repos/${this.repo}/commits?${query}`)).map((commit) => ({
|
|
486
|
+
revision: commit.sha,
|
|
487
|
+
date: commit.commit.author?.date ?? "",
|
|
488
|
+
author: commit.commit.author?.name || commit.author?.login || void 0,
|
|
489
|
+
message: commit.commit.message.split("\n")[0] ?? ""
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
async readAt(path, revision) {
|
|
493
|
+
return this.readFrom(await this.tree(revision), path);
|
|
494
|
+
}
|
|
134
495
|
async changedPaths(from, to) {
|
|
135
496
|
const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
|
|
136
497
|
return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
|
|
137
498
|
}
|
|
138
499
|
/**
|
|
139
|
-
* Reads the deployment GitHub Pages or Vercel created for a commit
|
|
140
|
-
*
|
|
500
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit, and for
|
|
501
|
+
* GitHub Actions, the workflow run that builds it: a build that fails never
|
|
502
|
+
* creates a deployment. Before either exists, the site counts as building
|
|
503
|
+
* if it has ever been deployed.
|
|
141
504
|
*/
|
|
142
505
|
async deployStatus(revision) {
|
|
143
506
|
try {
|
|
144
507
|
const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
508
|
+
if (latest) {
|
|
509
|
+
const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
|
|
510
|
+
const detailsUrl = status?.log_url || status?.target_url || void 0;
|
|
511
|
+
switch (status?.state) {
|
|
512
|
+
case "success":
|
|
513
|
+
case "inactive": return {
|
|
514
|
+
state: "live",
|
|
515
|
+
detailsUrl
|
|
516
|
+
};
|
|
517
|
+
case "failure":
|
|
518
|
+
case "error": {
|
|
519
|
+
const run = await this.workflowRun(revision);
|
|
520
|
+
return {
|
|
521
|
+
state: "failed",
|
|
522
|
+
detailsUrl,
|
|
523
|
+
problem: run ? await this.runProblem(run) : { step: "deploy" }
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
default: return {
|
|
527
|
+
state: "building",
|
|
528
|
+
detailsUrl
|
|
529
|
+
};
|
|
530
|
+
}
|
|
163
531
|
}
|
|
532
|
+
const run = await this.workflowRun(revision);
|
|
533
|
+
if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
|
|
534
|
+
state: "failed",
|
|
535
|
+
detailsUrl: run.html_url,
|
|
536
|
+
problem: await this.runProblem(run)
|
|
537
|
+
};
|
|
538
|
+
if (run) return {
|
|
539
|
+
state: "building",
|
|
540
|
+
detailsUrl: run.html_url
|
|
541
|
+
};
|
|
542
|
+
return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
|
|
164
543
|
} catch (error) {
|
|
165
544
|
if (error instanceof GitApiError) return { state: "unknown" };
|
|
166
545
|
throw error;
|
|
167
546
|
}
|
|
168
547
|
}
|
|
548
|
+
/** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
|
|
549
|
+
async workflowRun(revision) {
|
|
550
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
|
|
551
|
+
if (!response.ok) return void 0;
|
|
552
|
+
const { workflow_runs: runs = [] } = await response.json();
|
|
553
|
+
return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
|
|
554
|
+
}
|
|
555
|
+
/** Which step of a failed run failed. */
|
|
556
|
+
async runProblem(run) {
|
|
557
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs/${run.id}/jobs?per_page=50`, { allow: [403, 404] });
|
|
558
|
+
const failed = (response.ok ? (await response.json()).jobs ?? [] : []).find((job) => job.conclusion === "failure");
|
|
559
|
+
if (!failed) return {
|
|
560
|
+
step: run.conclusion === "startup_failure" ? "not-started" : "host",
|
|
561
|
+
detail: run.conclusion ?? void 0
|
|
562
|
+
};
|
|
563
|
+
const step = failed.steps?.find((candidate) => candidate.conclusion === "failure");
|
|
564
|
+
if (!step) return {
|
|
565
|
+
step: "not-started",
|
|
566
|
+
detail: failed.name
|
|
567
|
+
};
|
|
568
|
+
return {
|
|
569
|
+
step: buildStepFor(step.name) ?? (/deploy/i.test(failed.name) ? "deploy" : "build"),
|
|
570
|
+
detail: `${failed.name}: ${step.name}`
|
|
571
|
+
};
|
|
572
|
+
}
|
|
169
573
|
signOut() {
|
|
574
|
+
this.ownerAccess.forget();
|
|
170
575
|
this.onSignOut();
|
|
171
576
|
}
|
|
172
577
|
};
|
|
173
578
|
//#endregion
|
|
579
|
+
//#region src/setup.ts
|
|
580
|
+
/** The branch the deploy setups build from. */
|
|
581
|
+
const MAIN = "main";
|
|
582
|
+
/**
|
|
583
|
+
* Links to GitHub's token pages with what creating a site needs: creating the
|
|
584
|
+
* repository (administration), writing its files and its build setup
|
|
585
|
+
* (contents and workflows), and turning on GitHub Pages (pages).
|
|
586
|
+
*/
|
|
587
|
+
function githubSetupTokenLinks(webUrl = "https://github.com") {
|
|
588
|
+
const fineGrained = new URLSearchParams({
|
|
589
|
+
name: "Goodfellow site setup",
|
|
590
|
+
description: "Create a Goodfellow site. It can be deleted once the site is created.",
|
|
591
|
+
expires_in: "7",
|
|
592
|
+
administration: "write",
|
|
593
|
+
contents: "write",
|
|
594
|
+
workflows: "write",
|
|
595
|
+
pages: "write"
|
|
596
|
+
});
|
|
597
|
+
const classic = new URLSearchParams({
|
|
598
|
+
scopes: "repo,workflow",
|
|
599
|
+
description: "Goodfellow site setup"
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
603
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
|
|
607
|
+
function githubSetup(options = {}) {
|
|
608
|
+
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
609
|
+
const links = githubSetupTokenLinks(options.webUrl);
|
|
610
|
+
const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
611
|
+
async function connect(token) {
|
|
612
|
+
const api = {
|
|
613
|
+
apiUrl,
|
|
614
|
+
token,
|
|
615
|
+
fetch: fetcher
|
|
616
|
+
};
|
|
617
|
+
const user = await githubJson(api, "/user");
|
|
618
|
+
async function owners() {
|
|
619
|
+
return [{
|
|
620
|
+
path: user.login,
|
|
621
|
+
name: user.name || user.login,
|
|
622
|
+
kind: "personal"
|
|
623
|
+
}, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
|
|
624
|
+
path: org.login,
|
|
625
|
+
name: org.login,
|
|
626
|
+
kind: "organization"
|
|
627
|
+
}))];
|
|
628
|
+
}
|
|
629
|
+
async function isAvailable(owner, name) {
|
|
630
|
+
return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
|
|
631
|
+
}
|
|
632
|
+
async function createRepository(site) {
|
|
633
|
+
const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
|
|
634
|
+
if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
|
|
635
|
+
try {
|
|
636
|
+
return await githubJson(api, path, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
body: {
|
|
639
|
+
name: site.name,
|
|
640
|
+
private: site.private,
|
|
641
|
+
description: site.description,
|
|
642
|
+
auto_init: true
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
|
|
647
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Makes `main` the default branch, which the deploy setups build from. */
|
|
652
|
+
async function useMainBranch(repo) {
|
|
653
|
+
if (repo.default_branch === MAIN) return;
|
|
654
|
+
const base = `/repos/${repoPath(repo.full_name)}`;
|
|
655
|
+
const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
|
|
656
|
+
await githubRequest(api, `${base}/git/refs`, {
|
|
657
|
+
method: "POST",
|
|
658
|
+
body: {
|
|
659
|
+
ref: `refs/heads/${MAIN}`,
|
|
660
|
+
sha: ref.object.sha
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
await githubRequest(api, base, {
|
|
664
|
+
method: "PATCH",
|
|
665
|
+
body: { default_branch: MAIN }
|
|
666
|
+
});
|
|
667
|
+
await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
|
|
668
|
+
}
|
|
669
|
+
/** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
|
|
670
|
+
async function turnOnPages(repo) {
|
|
671
|
+
try {
|
|
672
|
+
return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
body: { build_type: "workflow" }
|
|
675
|
+
})).html_url;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (error instanceof GitApiError && [
|
|
678
|
+
403,
|
|
679
|
+
404,
|
|
680
|
+
409,
|
|
681
|
+
422
|
|
682
|
+
].includes(error.status)) return void 0;
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Writes every file in one commit, which replaces the starting README rather than following it. */
|
|
687
|
+
async function writeFiles(repo, site) {
|
|
688
|
+
const base = `/repos/${repoPath(repo.full_name)}`;
|
|
689
|
+
const tree = [];
|
|
690
|
+
for (const file of site.files) {
|
|
691
|
+
if ("delete" in file) continue;
|
|
692
|
+
if ("content" in file) {
|
|
693
|
+
tree.push({
|
|
694
|
+
path: file.path,
|
|
695
|
+
mode: "100644",
|
|
696
|
+
type: "blob",
|
|
697
|
+
content: file.content
|
|
698
|
+
});
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const blob = await githubJson(api, `${base}/git/blobs`, {
|
|
702
|
+
method: "POST",
|
|
703
|
+
body: {
|
|
704
|
+
content: encodeBase64Bytes(file.bytes),
|
|
705
|
+
encoding: "base64"
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
tree.push({
|
|
709
|
+
path: file.path,
|
|
710
|
+
mode: "100644",
|
|
711
|
+
type: "blob",
|
|
712
|
+
sha: blob.sha
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const created = await githubJson(api, `${base}/git/trees`, {
|
|
717
|
+
method: "POST",
|
|
718
|
+
body: { tree }
|
|
719
|
+
});
|
|
720
|
+
const commit = await githubJson(api, `${base}/git/commits`, {
|
|
721
|
+
method: "POST",
|
|
722
|
+
body: {
|
|
723
|
+
message: site.message,
|
|
724
|
+
tree: created.sha,
|
|
725
|
+
parents: []
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
|
|
729
|
+
method: "PATCH",
|
|
730
|
+
body: {
|
|
731
|
+
sha: commit.sha,
|
|
732
|
+
force: true
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
|
|
741
|
+
async function protectMain(repo) {
|
|
742
|
+
return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
|
|
743
|
+
method: "POST",
|
|
744
|
+
body: {
|
|
745
|
+
name: "Protect the published history",
|
|
746
|
+
target: "branch",
|
|
747
|
+
enforcement: "active",
|
|
748
|
+
conditions: { ref_name: {
|
|
749
|
+
include: ["~DEFAULT_BRANCH"],
|
|
750
|
+
exclude: []
|
|
751
|
+
} },
|
|
752
|
+
rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
|
|
753
|
+
},
|
|
754
|
+
allow: [
|
|
755
|
+
403,
|
|
756
|
+
404,
|
|
757
|
+
422
|
|
758
|
+
]
|
|
759
|
+
})).ok;
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
user: {
|
|
763
|
+
login: user.login,
|
|
764
|
+
name: user.name ?? void 0,
|
|
765
|
+
avatarUrl: user.avatar_url
|
|
766
|
+
},
|
|
767
|
+
owners,
|
|
768
|
+
isAvailable,
|
|
769
|
+
async createSite(site, onStep) {
|
|
770
|
+
const warnings = [];
|
|
771
|
+
onStep?.("repository");
|
|
772
|
+
const repo = await createRepository(site);
|
|
773
|
+
await useMainBranch(repo);
|
|
774
|
+
let siteUrl;
|
|
775
|
+
if (site.pages) {
|
|
776
|
+
onStep?.("pages");
|
|
777
|
+
siteUrl = await turnOnPages(repo);
|
|
778
|
+
if (!siteUrl) warnings.push("pages-unavailable");
|
|
779
|
+
}
|
|
780
|
+
onStep?.("files");
|
|
781
|
+
await writeFiles(repo, site);
|
|
782
|
+
onStep?.("protection");
|
|
783
|
+
if (!await protectMain(repo)) warnings.push("protection-unavailable");
|
|
784
|
+
return {
|
|
785
|
+
repo: repo.full_name,
|
|
786
|
+
webUrl: repo.html_url,
|
|
787
|
+
siteUrl,
|
|
788
|
+
buildsUrl: `${repo.html_url}/actions`,
|
|
789
|
+
pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
|
|
790
|
+
warnings
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
name: "GitHub",
|
|
797
|
+
canRedirect: false,
|
|
798
|
+
tokenLinks: [{
|
|
799
|
+
url: links.fineGrained,
|
|
800
|
+
label: "signIn.token.create"
|
|
801
|
+
}, {
|
|
802
|
+
url: links.classic,
|
|
803
|
+
label: "signIn.token.createBroad",
|
|
804
|
+
hint: "signIn.token.createBroadHint"
|
|
805
|
+
}],
|
|
806
|
+
async restore() {
|
|
807
|
+
return null;
|
|
808
|
+
},
|
|
809
|
+
async signInWithToken(token) {
|
|
810
|
+
const trimmed = token.trim();
|
|
811
|
+
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
812
|
+
return connect(trimmed);
|
|
813
|
+
},
|
|
814
|
+
async startRedirect() {
|
|
815
|
+
throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
174
820
|
//#region src/index.ts
|
|
175
821
|
/**
|
|
176
822
|
* Links to GitHub's token page with everything filled in except the
|
|
177
823
|
* repository, which GitHub doesn't let links choose.
|
|
178
824
|
*/
|
|
179
825
|
function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
180
|
-
const [
|
|
826
|
+
const [repoOwner = ""] = repo.split("/");
|
|
181
827
|
const fineGrained = new URLSearchParams({
|
|
182
828
|
name: "Goodfellow admin",
|
|
183
829
|
description: `Edit ${repo} in its admin panel`,
|
|
184
|
-
target_name:
|
|
830
|
+
target_name: repoOwner,
|
|
185
831
|
expires_in: "90",
|
|
186
832
|
contents: "write",
|
|
187
|
-
deployments: "read"
|
|
833
|
+
deployments: "read",
|
|
834
|
+
actions: "read"
|
|
188
835
|
});
|
|
189
836
|
const classic = new URLSearchParams({
|
|
190
837
|
scopes: "repo",
|
|
191
838
|
description: `Goodfellow admin for ${repo}`
|
|
192
839
|
});
|
|
840
|
+
const owner = new URLSearchParams({
|
|
841
|
+
name: "Goodfellow owner",
|
|
842
|
+
description: `Manage the editors, domain and updates of ${repo}`,
|
|
843
|
+
target_name: repoOwner,
|
|
844
|
+
expires_in: "1",
|
|
845
|
+
administration: "write",
|
|
846
|
+
pages: "write",
|
|
847
|
+
actions: "write"
|
|
848
|
+
});
|
|
193
849
|
return {
|
|
194
850
|
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
195
|
-
classic: `${webUrl}/settings/tokens/new?${classic}
|
|
851
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`,
|
|
852
|
+
owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
|
|
196
853
|
};
|
|
197
854
|
}
|
|
198
855
|
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
@@ -225,7 +882,11 @@ function github(options) {
|
|
|
225
882
|
name: user.name ?? void 0,
|
|
226
883
|
avatarUrl: user.avatar_url
|
|
227
884
|
},
|
|
228
|
-
onSignOut: () => storage.clear()
|
|
885
|
+
onSignOut: () => storage.clear(),
|
|
886
|
+
ownerTokenLink: {
|
|
887
|
+
url: links.owner,
|
|
888
|
+
label: "owner.token.create"
|
|
889
|
+
}
|
|
229
890
|
});
|
|
230
891
|
}
|
|
231
892
|
return {
|
|
@@ -262,4 +923,4 @@ function github(options) {
|
|
|
262
923
|
};
|
|
263
924
|
}
|
|
264
925
|
//#endregion
|
|
265
|
-
export { GitHubBackend, github, githubTokenLinks };
|
|
926
|
+
export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
|