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