Path: blob/main/src/publish/gh-pages/gh-pages.ts
12926 views
/*1* ghpages.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { debug, info } from "../../deno_ral/log.ts";7import { dirname, join, relative } from "../../deno_ral/path.ts";8import { copy } from "../../deno_ral/fs.ts";9import * as colors from "fmt/colors";1011import { Confirm } from "cliffy/prompt/confirm.ts";1213import { removeIfExists } from "../../core/path.ts";14import { execProcess } from "../../core/process.ts";1516import { ProjectContext } from "../../project/types.ts";17import {18AccountToken,19PublishFiles,20PublishProvider,21} from "../provider-types.ts";22import { PublishOptions, PublishRecord } from "../types.ts";23import { shortUuid } from "../../core/uuid.ts";24import { sleep } from "../../core/wait.ts";25import { joinUrl } from "../../core/url.ts";26import { completeMessage, withSpinner } from "../../core/console.ts";27import { renderForPublish } from "../common/publish.ts";28import { RenderFlags } from "../../command/render/types.ts";29import {30gitBranchExists,31gitCmds,32gitUserIdentityConfigured,33gitVersion,34} from "../../core/git.ts";35import {36anonymousAccount,37gitHubContextForPublish,38verifyContext,39} from "../common/git.ts";40import { throwUnableToPublish } from "../common/errors.ts";41import { createTempContext } from "../../core/temp.ts";42import { projectScratchPath } from "../../project/project-scratch.ts";4344export const kGhpages = "gh-pages";45const kGhpagesDescription = "GitHub Pages";4647export const ghpagesProvider: PublishProvider = {48name: kGhpages,49description: kGhpagesDescription,50requiresServer: false,51listOriginOnly: false,52accountTokens,53authorizeToken,54removeToken,55publishRecord,56resolveTarget,57publish,58isUnauthorized,59isNotFound,60};6162function accountTokens() {63return Promise.resolve([anonymousAccount()]);64}6566async function authorizeToken(options: PublishOptions) {67const ghContext = await gitHubContextForPublish(options.input);68verifyContext(ghContext, "GitHub Pages");6970// good to go!71return Promise.resolve(anonymousAccount());72}7374function removeToken(_token: AccountToken) {75}7677async function publishRecord(78input: string | ProjectContext,79): Promise<PublishRecord | undefined> {80const ghContext = await gitHubContextForPublish(input);81if (ghContext.ghPagesRemote) {82return {83id: "gh-pages",84url: ghContext.siteUrl || ghContext.originUrl,85};86}87}8889function resolveTarget(90_account: AccountToken,91target: PublishRecord,92): Promise<PublishRecord | undefined> {93return Promise.resolve(target);94}9596async function publish(97_account: AccountToken,98type: "document" | "site",99input: string,100title: string,101_slug: string,102render: (flags?: RenderFlags) => Promise<PublishFiles>,103options: PublishOptions,104target?: PublishRecord,105): Promise<[PublishRecord | undefined, URL | undefined]> {106// convert input to dir if necessary107input = Deno.statSync(input).isDirectory ? input : dirname(input);108109// check if git version is new enough110const version = await gitVersion();111112// git 2.17.0 appears to be the first to support git-worktree add --track113// https://github.com/git/git/blob/master/Documentation/RelNotes/2.17.0.txt#L368114if (version.compare("2.17.0") < 0) {115throw new Error(116"git version 2.17.0 or higher is required to publish to GitHub Pages",117);118}119120// get context121const ghContext = await gitHubContextForPublish(options.input);122verifyContext(ghContext, "GitHub Pages");123124// verify git user identity is configured (needed for commits in worktree)125if (!await gitUserIdentityConfigured(input)) {126throwUnableToPublish(127"git user.name and/or user.email is not configured\n" +128"(run 'git config user.name \"Your Name\"' and " +129"'git config user.email \"[email protected]\"' to set them)",130"GitHub Pages",131);132}133134// create gh pages branch on remote and local if there is none yet135const createGhPagesBranchRemote = !ghContext.ghPagesRemote;136const createGhPagesBranchLocal = !ghContext.ghPagesLocal;137if (createGhPagesBranchRemote) {138// confirm139let confirmed = await Confirm.prompt({140indent: "",141message: `Publish site to ${142ghContext.siteUrl || ghContext.originUrl143} using gh-pages?`,144default: true,145});146if (confirmed && !createGhPagesBranchLocal) {147confirmed = await Confirm.prompt({148indent: "",149message:150`A local gh-pages branch already exists. Should it be pushed to remote 'origin'?`,151default: true,152});153}154155if (!confirmed) {156throw new Error();157}158159const stash = !(await gitDirIsClean(input));160if (stash) {161await gitStash(input);162}163const oldBranch = await gitCurrentBranch(input);164try {165// Create and push if necessary, or just push local branch166if (createGhPagesBranchLocal) {167await gitCreateGhPages(input);168} else {169await gitPushGhPages(input);170}171} catch {172// Something failed so clean up, i.e173// if we created the branch then delete it.174// Example of failure: Auth error on push (https://github.com/quarto-dev/quarto-cli/issues/9585)175if (createGhPagesBranchLocal && await gitBranchExists("gh-pages")) {176await gitCmds(input, [177["checkout", oldBranch],178["branch", "-D", "gh-pages"],179]);180}181throw new Error(182"Publishing to gh-pages with `quarto publish gh-pages` failed.",183);184} finally {185if (await gitCurrentBranch(input) !== oldBranch) {186await gitCmds(input, [["checkout", oldBranch]]);187}188if (stash) {189await gitStashApply(input);190}191}192}193194// sync from remote195await gitCmds(input, [196["remote", "set-branches", "--add", "origin", "gh-pages"],197["fetch", "origin", "gh-pages"],198]);199200// render201const renderResult = await renderForPublish(202render,203"gh-pages",204type,205title,206type === "site" ? target?.url : undefined,207);208209const kPublishWorktreeDir = "quarto-publish-worktree-";210// allocate worktree dir211const temp = createTempContext(212{ prefix: kPublishWorktreeDir, dir: projectScratchPath(input) },213);214const tempDir = temp.baseDir;215removeIfExists(tempDir);216217// cleaning up leftover by listing folder with prefix .quarto-publish-worktree- and calling git worktree rm on them218const worktreeDir = Deno.readDirSync(projectScratchPath(input));219for (const entry of worktreeDir) {220if (221entry.isDirectory && entry.name.startsWith(kPublishWorktreeDir)222) {223debug(224`Cleaning up leftover worktree folder ${entry.name} from past deploys`,225);226const worktreePath = join(projectScratchPath(input), entry.name);227await execProcess({228cmd: "git",229args: ["worktree", "remove", "--force", worktreePath],230cwd: projectScratchPath(input),231});232removeIfExists(worktreePath);233}234}235236// create worktree and deploy from it237const deployId = shortUuid();238debug(`Deploying from worktree ${tempDir} with deployId ${deployId}`);239await withWorktree(input, relative(input, tempDir), async () => {240// copy output to tempdir and add .nojekyll (include deployId241// in .nojekyll so we can poll for completed deployment)242await copy(renderResult.baseDir, tempDir, { overwrite: true });243Deno.writeTextFileSync(join(tempDir, ".nojekyll"), deployId);244245// push246await gitCmds(tempDir, [247["add", "-Af", "."],248["commit", "--allow-empty", "-m", "Built site for gh-pages"],249["remote", "-v"],250["push", "--force", "origin", "HEAD:gh-pages"],251]);252});253temp.cleanup();254info("");255256// if this is the creation of gh-pages AND this is a user home/default site257// then tell the user they need to switch it to use gh-pages. also do this258// if the site is getting a 404 error259let notifyGhPagesBranch = false;260let defaultSiteMatch: RegExpMatchArray | null;261if (ghContext.siteUrl) {262defaultSiteMatch = ghContext.siteUrl.match(263/^https:\/\/(.+?)\.github\.io\/$/,264);265if (defaultSiteMatch) {266if (createGhPagesBranchRemote) {267notifyGhPagesBranch = true;268} else {269try {270const response = await fetch(ghContext.siteUrl);271if (response.status === 404) {272notifyGhPagesBranch = true;273}274} catch {275//276}277}278}279}280281// if this is an update then warn that updates may require a browser refresh282if (!createGhPagesBranchRemote && !notifyGhPagesBranch) {283info(colors.yellow(284"NOTE: GitHub Pages sites use caching so you might need to click the refresh\n" +285"button within your web browser to see changes after deployment.\n",286));287}288289// wait for deployment if we are opening a browser290let verified = false;291const start = new Date();292293if (options.browser && ghContext.siteUrl && !notifyGhPagesBranch) {294await withSpinner({295message:296"Deploying gh-pages branch to website (this may take a few minutes)",297}, async () => {298const noJekyllUrl = joinUrl(ghContext.siteUrl!, ".nojekyll");299while (true) {300const now = new Date();301const elapsed = now.getTime() - start.getTime();302if (elapsed > 1000 * 60 * 5) {303info(colors.yellow(304"Deployment took longer than 5 minutes, giving up waiting for deployment to complete",305));306break;307}308await sleep(2000);309const response = await fetch(noJekyllUrl);310if (response.status === 200) {311if ((await response.text()).trim() === deployId) {312verified = true;313await sleep(2000);314break;315}316} else if (response.status !== 404) {317break;318}319}320});321}322323completeMessage(`Published to ${ghContext.siteUrl || ghContext.originUrl}`);324info("");325326if (notifyGhPagesBranch) {327info(328colors.yellow(329"To complete publishing, change the source branch for this site to " +330colors.bold("gh-pages") + ".\n\n" +331`Set the source branch at: ` +332colors.underline(333`https://github.com/${defaultSiteMatch![1]}/${334defaultSiteMatch![1]335}.github.io/settings/pages`,336) + "\n",337),338);339} else if (!verified) {340info(colors.yellow(341"NOTE: GitHub Pages deployments normally take a few minutes (your site updates\n" +342"will be visible once the deploy completes)\n",343));344}345346return Promise.resolve([347undefined,348verified ? new URL(ghContext.siteUrl!) : undefined,349]);350}351352function isUnauthorized(_err: Error) {353return false;354}355356function isNotFound(_err: Error) {357return false;358}359360async function gitStash(dir: string) {361const result = await execProcess({362cmd: "git",363args: ["stash"],364cwd: dir,365});366if (!result.success) {367throw new Error();368}369}370371async function gitStashApply(dir: string) {372const result = await execProcess({373cmd: "git",374args: ["stash", "apply"],375cwd: dir,376});377if (!result.success) {378throw new Error();379}380}381382async function gitDirIsClean(dir: string) {383const result = await execProcess({384cmd: "git",385args: ["diff", "HEAD"],386cwd: dir,387stdout: "piped",388});389if (result.success) {390return result.stdout!.trim().length === 0;391} else {392throw new Error();393}394}395396async function gitCurrentBranch(dir: string) {397const result = await execProcess({398cmd: "git",399args: ["rev-parse", "--abbrev-ref", "HEAD"],400cwd: dir,401stdout: "piped",402});403if (result.success) {404return result.stdout!.trim();405} else {406throw new Error();407}408}409410async function withWorktree(411dir: string,412siteDir: string,413f: () => Promise<void>,414) {415await execProcess({416cmd: "git",417args: [418"worktree",419"add",420"--track",421"-B",422"gh-pages",423siteDir,424"origin/gh-pages",425],426cwd: dir,427});428429// remove files in existing site, i.e. start clean430await execProcess({431cmd: "git",432args: ["rm", "-r", "--quiet", "."],433cwd: join(dir, siteDir),434});435436try {437await f();438} finally {439await execProcess({440cmd: "git",441args: ["worktree", "remove", "--force", siteDir],442cwd: dir,443});444}445}446447async function gitCreateGhPages(dir: string) {448await gitCmds(dir, [449["checkout", "--orphan", "gh-pages"],450["rm", "-rf", "--quiet", "."],451["commit", "--allow-empty", "-m", "Initializing gh-pages branch"],452]);453await gitPushGhPages(dir);454}455456async function gitPushGhPages(dir: string) {457if (await gitCurrentBranch(dir) !== "gh-pages") {458await gitCmds(dir, [["checkout", "gh-pages"]]);459}460await gitCmds(dir, [["push", "origin", "HEAD:gh-pages"]]);461}462463464