Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/gh-pages/gh-pages.ts
12926 views
1
/*
2
* ghpages.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { debug, info } from "../../deno_ral/log.ts";
8
import { dirname, join, relative } from "../../deno_ral/path.ts";
9
import { copy } from "../../deno_ral/fs.ts";
10
import * as colors from "fmt/colors";
11
12
import { Confirm } from "cliffy/prompt/confirm.ts";
13
14
import { removeIfExists } from "../../core/path.ts";
15
import { execProcess } from "../../core/process.ts";
16
17
import { ProjectContext } from "../../project/types.ts";
18
import {
19
AccountToken,
20
PublishFiles,
21
PublishProvider,
22
} from "../provider-types.ts";
23
import { PublishOptions, PublishRecord } from "../types.ts";
24
import { shortUuid } from "../../core/uuid.ts";
25
import { sleep } from "../../core/wait.ts";
26
import { joinUrl } from "../../core/url.ts";
27
import { completeMessage, withSpinner } from "../../core/console.ts";
28
import { renderForPublish } from "../common/publish.ts";
29
import { RenderFlags } from "../../command/render/types.ts";
30
import {
31
gitBranchExists,
32
gitCmds,
33
gitUserIdentityConfigured,
34
gitVersion,
35
} from "../../core/git.ts";
36
import {
37
anonymousAccount,
38
gitHubContextForPublish,
39
verifyContext,
40
} from "../common/git.ts";
41
import { throwUnableToPublish } from "../common/errors.ts";
42
import { createTempContext } from "../../core/temp.ts";
43
import { projectScratchPath } from "../../project/project-scratch.ts";
44
45
export const kGhpages = "gh-pages";
46
const kGhpagesDescription = "GitHub Pages";
47
48
export const ghpagesProvider: PublishProvider = {
49
name: kGhpages,
50
description: kGhpagesDescription,
51
requiresServer: false,
52
listOriginOnly: false,
53
accountTokens,
54
authorizeToken,
55
removeToken,
56
publishRecord,
57
resolveTarget,
58
publish,
59
isUnauthorized,
60
isNotFound,
61
};
62
63
function accountTokens() {
64
return Promise.resolve([anonymousAccount()]);
65
}
66
67
async function authorizeToken(options: PublishOptions) {
68
const ghContext = await gitHubContextForPublish(options.input);
69
verifyContext(ghContext, "GitHub Pages");
70
71
// good to go!
72
return Promise.resolve(anonymousAccount());
73
}
74
75
function removeToken(_token: AccountToken) {
76
}
77
78
async function publishRecord(
79
input: string | ProjectContext,
80
): Promise<PublishRecord | undefined> {
81
const ghContext = await gitHubContextForPublish(input);
82
if (ghContext.ghPagesRemote) {
83
return {
84
id: "gh-pages",
85
url: ghContext.siteUrl || ghContext.originUrl,
86
};
87
}
88
}
89
90
function resolveTarget(
91
_account: AccountToken,
92
target: PublishRecord,
93
): Promise<PublishRecord | undefined> {
94
return Promise.resolve(target);
95
}
96
97
async function publish(
98
_account: AccountToken,
99
type: "document" | "site",
100
input: string,
101
title: string,
102
_slug: string,
103
render: (flags?: RenderFlags) => Promise<PublishFiles>,
104
options: PublishOptions,
105
target?: PublishRecord,
106
): Promise<[PublishRecord | undefined, URL | undefined]> {
107
// convert input to dir if necessary
108
input = Deno.statSync(input).isDirectory ? input : dirname(input);
109
110
// check if git version is new enough
111
const version = await gitVersion();
112
113
// git 2.17.0 appears to be the first to support git-worktree add --track
114
// https://github.com/git/git/blob/master/Documentation/RelNotes/2.17.0.txt#L368
115
if (version.compare("2.17.0") < 0) {
116
throw new Error(
117
"git version 2.17.0 or higher is required to publish to GitHub Pages",
118
);
119
}
120
121
// get context
122
const ghContext = await gitHubContextForPublish(options.input);
123
verifyContext(ghContext, "GitHub Pages");
124
125
// verify git user identity is configured (needed for commits in worktree)
126
if (!await gitUserIdentityConfigured(input)) {
127
throwUnableToPublish(
128
"git user.name and/or user.email is not configured\n" +
129
"(run 'git config user.name \"Your Name\"' and " +
130
"'git config user.email \"[email protected]\"' to set them)",
131
"GitHub Pages",
132
);
133
}
134
135
// create gh pages branch on remote and local if there is none yet
136
const createGhPagesBranchRemote = !ghContext.ghPagesRemote;
137
const createGhPagesBranchLocal = !ghContext.ghPagesLocal;
138
if (createGhPagesBranchRemote) {
139
// confirm
140
let confirmed = await Confirm.prompt({
141
indent: "",
142
message: `Publish site to ${
143
ghContext.siteUrl || ghContext.originUrl
144
} using gh-pages?`,
145
default: true,
146
});
147
if (confirmed && !createGhPagesBranchLocal) {
148
confirmed = await Confirm.prompt({
149
indent: "",
150
message:
151
`A local gh-pages branch already exists. Should it be pushed to remote 'origin'?`,
152
default: true,
153
});
154
}
155
156
if (!confirmed) {
157
throw new Error();
158
}
159
160
const stash = !(await gitDirIsClean(input));
161
if (stash) {
162
await gitStash(input);
163
}
164
const oldBranch = await gitCurrentBranch(input);
165
try {
166
// Create and push if necessary, or just push local branch
167
if (createGhPagesBranchLocal) {
168
await gitCreateGhPages(input);
169
} else {
170
await gitPushGhPages(input);
171
}
172
} catch {
173
// Something failed so clean up, i.e
174
// if we created the branch then delete it.
175
// Example of failure: Auth error on push (https://github.com/quarto-dev/quarto-cli/issues/9585)
176
if (createGhPagesBranchLocal && await gitBranchExists("gh-pages")) {
177
await gitCmds(input, [
178
["checkout", oldBranch],
179
["branch", "-D", "gh-pages"],
180
]);
181
}
182
throw new Error(
183
"Publishing to gh-pages with `quarto publish gh-pages` failed.",
184
);
185
} finally {
186
if (await gitCurrentBranch(input) !== oldBranch) {
187
await gitCmds(input, [["checkout", oldBranch]]);
188
}
189
if (stash) {
190
await gitStashApply(input);
191
}
192
}
193
}
194
195
// sync from remote
196
await gitCmds(input, [
197
["remote", "set-branches", "--add", "origin", "gh-pages"],
198
["fetch", "origin", "gh-pages"],
199
]);
200
201
// render
202
const renderResult = await renderForPublish(
203
render,
204
"gh-pages",
205
type,
206
title,
207
type === "site" ? target?.url : undefined,
208
);
209
210
const kPublishWorktreeDir = "quarto-publish-worktree-";
211
// allocate worktree dir
212
const temp = createTempContext(
213
{ prefix: kPublishWorktreeDir, dir: projectScratchPath(input) },
214
);
215
const tempDir = temp.baseDir;
216
removeIfExists(tempDir);
217
218
// cleaning up leftover by listing folder with prefix .quarto-publish-worktree- and calling git worktree rm on them
219
const worktreeDir = Deno.readDirSync(projectScratchPath(input));
220
for (const entry of worktreeDir) {
221
if (
222
entry.isDirectory && entry.name.startsWith(kPublishWorktreeDir)
223
) {
224
debug(
225
`Cleaning up leftover worktree folder ${entry.name} from past deploys`,
226
);
227
const worktreePath = join(projectScratchPath(input), entry.name);
228
await execProcess({
229
cmd: "git",
230
args: ["worktree", "remove", "--force", worktreePath],
231
cwd: projectScratchPath(input),
232
});
233
removeIfExists(worktreePath);
234
}
235
}
236
237
// create worktree and deploy from it
238
const deployId = shortUuid();
239
debug(`Deploying from worktree ${tempDir} with deployId ${deployId}`);
240
await withWorktree(input, relative(input, tempDir), async () => {
241
// copy output to tempdir and add .nojekyll (include deployId
242
// in .nojekyll so we can poll for completed deployment)
243
await copy(renderResult.baseDir, tempDir, { overwrite: true });
244
Deno.writeTextFileSync(join(tempDir, ".nojekyll"), deployId);
245
246
// push
247
await gitCmds(tempDir, [
248
["add", "-Af", "."],
249
["commit", "--allow-empty", "-m", "Built site for gh-pages"],
250
["remote", "-v"],
251
["push", "--force", "origin", "HEAD:gh-pages"],
252
]);
253
});
254
temp.cleanup();
255
info("");
256
257
// if this is the creation of gh-pages AND this is a user home/default site
258
// then tell the user they need to switch it to use gh-pages. also do this
259
// if the site is getting a 404 error
260
let notifyGhPagesBranch = false;
261
let defaultSiteMatch: RegExpMatchArray | null;
262
if (ghContext.siteUrl) {
263
defaultSiteMatch = ghContext.siteUrl.match(
264
/^https:\/\/(.+?)\.github\.io\/$/,
265
);
266
if (defaultSiteMatch) {
267
if (createGhPagesBranchRemote) {
268
notifyGhPagesBranch = true;
269
} else {
270
try {
271
const response = await fetch(ghContext.siteUrl);
272
if (response.status === 404) {
273
notifyGhPagesBranch = true;
274
}
275
} catch {
276
//
277
}
278
}
279
}
280
}
281
282
// if this is an update then warn that updates may require a browser refresh
283
if (!createGhPagesBranchRemote && !notifyGhPagesBranch) {
284
info(colors.yellow(
285
"NOTE: GitHub Pages sites use caching so you might need to click the refresh\n" +
286
"button within your web browser to see changes after deployment.\n",
287
));
288
}
289
290
// wait for deployment if we are opening a browser
291
let verified = false;
292
const start = new Date();
293
294
if (options.browser && ghContext.siteUrl && !notifyGhPagesBranch) {
295
await withSpinner({
296
message:
297
"Deploying gh-pages branch to website (this may take a few minutes)",
298
}, async () => {
299
const noJekyllUrl = joinUrl(ghContext.siteUrl!, ".nojekyll");
300
while (true) {
301
const now = new Date();
302
const elapsed = now.getTime() - start.getTime();
303
if (elapsed > 1000 * 60 * 5) {
304
info(colors.yellow(
305
"Deployment took longer than 5 minutes, giving up waiting for deployment to complete",
306
));
307
break;
308
}
309
await sleep(2000);
310
const response = await fetch(noJekyllUrl);
311
if (response.status === 200) {
312
if ((await response.text()).trim() === deployId) {
313
verified = true;
314
await sleep(2000);
315
break;
316
}
317
} else if (response.status !== 404) {
318
break;
319
}
320
}
321
});
322
}
323
324
completeMessage(`Published to ${ghContext.siteUrl || ghContext.originUrl}`);
325
info("");
326
327
if (notifyGhPagesBranch) {
328
info(
329
colors.yellow(
330
"To complete publishing, change the source branch for this site to " +
331
colors.bold("gh-pages") + ".\n\n" +
332
`Set the source branch at: ` +
333
colors.underline(
334
`https://github.com/${defaultSiteMatch![1]}/${
335
defaultSiteMatch![1]
336
}.github.io/settings/pages`,
337
) + "\n",
338
),
339
);
340
} else if (!verified) {
341
info(colors.yellow(
342
"NOTE: GitHub Pages deployments normally take a few minutes (your site updates\n" +
343
"will be visible once the deploy completes)\n",
344
));
345
}
346
347
return Promise.resolve([
348
undefined,
349
verified ? new URL(ghContext.siteUrl!) : undefined,
350
]);
351
}
352
353
function isUnauthorized(_err: Error) {
354
return false;
355
}
356
357
function isNotFound(_err: Error) {
358
return false;
359
}
360
361
async function gitStash(dir: string) {
362
const result = await execProcess({
363
cmd: "git",
364
args: ["stash"],
365
cwd: dir,
366
});
367
if (!result.success) {
368
throw new Error();
369
}
370
}
371
372
async function gitStashApply(dir: string) {
373
const result = await execProcess({
374
cmd: "git",
375
args: ["stash", "apply"],
376
cwd: dir,
377
});
378
if (!result.success) {
379
throw new Error();
380
}
381
}
382
383
async function gitDirIsClean(dir: string) {
384
const result = await execProcess({
385
cmd: "git",
386
args: ["diff", "HEAD"],
387
cwd: dir,
388
stdout: "piped",
389
});
390
if (result.success) {
391
return result.stdout!.trim().length === 0;
392
} else {
393
throw new Error();
394
}
395
}
396
397
async function gitCurrentBranch(dir: string) {
398
const result = await execProcess({
399
cmd: "git",
400
args: ["rev-parse", "--abbrev-ref", "HEAD"],
401
cwd: dir,
402
stdout: "piped",
403
});
404
if (result.success) {
405
return result.stdout!.trim();
406
} else {
407
throw new Error();
408
}
409
}
410
411
async function withWorktree(
412
dir: string,
413
siteDir: string,
414
f: () => Promise<void>,
415
) {
416
await execProcess({
417
cmd: "git",
418
args: [
419
"worktree",
420
"add",
421
"--track",
422
"-B",
423
"gh-pages",
424
siteDir,
425
"origin/gh-pages",
426
],
427
cwd: dir,
428
});
429
430
// remove files in existing site, i.e. start clean
431
await execProcess({
432
cmd: "git",
433
args: ["rm", "-r", "--quiet", "."],
434
cwd: join(dir, siteDir),
435
});
436
437
try {
438
await f();
439
} finally {
440
await execProcess({
441
cmd: "git",
442
args: ["worktree", "remove", "--force", siteDir],
443
cwd: dir,
444
});
445
}
446
}
447
448
async function gitCreateGhPages(dir: string) {
449
await gitCmds(dir, [
450
["checkout", "--orphan", "gh-pages"],
451
["rm", "-rf", "--quiet", "."],
452
["commit", "--allow-empty", "-m", "Initializing gh-pages branch"],
453
]);
454
await gitPushGhPages(dir);
455
}
456
457
async function gitPushGhPages(dir: string) {
458
if (await gitCurrentBranch(dir) !== "gh-pages") {
459
await gitCmds(dir, [["checkout", "gh-pages"]]);
460
}
461
await gitCmds(dir, [["push", "origin", "HEAD:gh-pages"]]);
462
}
463
464