diff --git a/docs/src/content/docs/targets/gh-pages.md b/docs/src/content/docs/targets/gh-pages.md index 791d0f13..ff906bc0 100644 --- a/docs/src/content/docs/targets/gh-pages.md +++ b/docs/src/content/docs/targets/gh-pages.md @@ -23,6 +23,15 @@ By default, this target: 1. Looks for an artifact named `gh-pages.zip` 2. Extracts its contents 3. Commits to the `gh-pages` branch +4. Waits for GitHub Pages to build the pushed commit + +## Build Verification + +When GitHub Pages builds from the target branch, Craft waits up to 15 minutes for the build of the pushed commit. If the build fails or times out, Craft requests a rebuild, at most 2 times. If the last rebuild also fails, the target fails. + +Craft skips the wait and logs a warning when the token cannot read the Pages settings (HTTP 403 or 404). This happens when Pages is not enabled, or when the token has no Pages permission. Craft also skips the wait when Pages builds from a GitHub Actions workflow or from a different branch. + +To wait for builds, give the token read and write access to Pages. ## Example diff --git a/src/targets/__tests__/ghPages.test.ts b/src/targets/__tests__/ghPages.test.ts new file mode 100644 index 00000000..23e2d746 --- /dev/null +++ b/src/targets/__tests__/ghPages.test.ts @@ -0,0 +1,172 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const repos = { + getPages: vi.fn(), + getLatestPagesBuild: vi.fn(), + requestPagesBuild: vi.fn(), +}; + +vi.mock('../../utils/githubApi', async () => { + const actual = await vi.importActual( + '../../utils/githubApi', + ); + return { + ...actual, + getGitHubClient: vi.fn(() => ({ repos })), + }; +}); + +vi.mock('../../utils/async', async () => { + const actual = + await vi.importActual( + '../../utils/async', + ); + return { ...actual, sleep: vi.fn().mockResolvedValue(undefined) }; +}); + +import { GhPagesTarget } from '../ghPages'; +import { NoneArtifactProvider } from '../../artifact_providers/none'; +import { setGlobals } from '../../utils/helpers'; + +const SHA = 'a8b2579a0d9cebbfa5c58c2c4c2fc5ad66508645'; + +function build(id: number, status: string, commit = SHA, message?: string) { + return { + data: { + url: `https://api.github.com/repos/o/r/pages/builds/${id}`, + status, + commit, + error: { message: message ?? null }, + }, + }; +} + +function httpError(status: number) { + return Object.assign(new Error(`HTTP ${status}`), { status }); +} + +describe('GhPagesTarget', () => { + let target: GhPagesTarget; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = 'test-token'; + setGlobals({ 'dry-run': false, 'log-level': 'Info', 'no-input': true }); + repos.getPages.mockResolvedValue({ + data: { build_type: 'legacy', source: { branch: 'gh-pages' } }, + }); + target = new GhPagesTarget( + { name: 'gh-pages' }, + new NoneArtifactProvider(), + { owner: 'o', repo: 'r' }, + ); + }); + + describe('waitForPagesBuild', () => { + it('returns when the build of the pushed commit is built', async () => { + repos.getLatestPagesBuild + .mockResolvedValueOnce(build(1, 'built', 'previous-sha')) + .mockResolvedValueOnce(build(2, 'building')) + .mockResolvedValueOnce(build(2, 'built')); + + await target.waitForPagesBuild(SHA); + + expect(repos.getLatestPagesBuild).toHaveBeenCalledTimes(3); + expect(repos.requestPagesBuild).not.toHaveBeenCalled(); + }); + + it('requests a rebuild when the build errors', async () => { + repos.getLatestPagesBuild + .mockResolvedValueOnce(build(2, 'errored', SHA, 'Page build failed.')) + // The failed build stays the latest until the rebuild starts. + .mockResolvedValueOnce(build(2, 'errored', SHA, 'Page build failed.')) + .mockResolvedValueOnce(build(3, 'built')); + + await target.waitForPagesBuild(SHA); + + expect(repos.requestPagesBuild).toHaveBeenCalledTimes(1); + expect(repos.requestPagesBuild).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + }); + }); + + it('fails after the rebuilds also error', async () => { + repos.getLatestPagesBuild + .mockResolvedValueOnce(build(2, 'errored', SHA, 'Page build failed.')) + .mockResolvedValueOnce(build(3, 'errored', SHA, 'Page build failed.')) + .mockResolvedValueOnce(build(4, 'errored', SHA, 'Page build failed.')); + + await expect(target.waitForPagesBuild(SHA)).rejects.toThrow( + /failed after 2 rebuilds \(Page build failed\.\).*o\/r\/deployments\/github-pages/, + ); + expect(repos.requestPagesBuild).toHaveBeenCalledTimes(2); + }); + + it('treats a build that never finishes as failed', async () => { + repos.getLatestPagesBuild.mockResolvedValue(build(2, 'building')); + + await expect(target.waitForPagesBuild(SHA)).rejects.toThrow( + /failed after 2 rebuilds \(timed out\)/, + ); + expect(repos.requestPagesBuild).toHaveBeenCalledTimes(2); + }); + + it.each([403, 404])( + 'warns and succeeds when Pages settings return %i', + async status => { + repos.getPages.mockRejectedValue(httpError(status)); + const warn = vi.spyOn(target['logger'], 'warn'); + + await target.waitForPagesBuild(SHA); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`HTTP ${status}`), + ); + expect(repos.getLatestPagesBuild).not.toHaveBeenCalled(); + }, + ); + + it('does not wait when Pages builds from a workflow', async () => { + repos.getPages.mockResolvedValue({ + data: { build_type: 'workflow', source: null }, + }); + + await target.waitForPagesBuild(SHA); + + expect(repos.getLatestPagesBuild).not.toHaveBeenCalled(); + }); + }); + + describe('publish', () => { + it('does not wait for the Pages build in dry-run', async () => { + setGlobals({ 'dry-run': true, 'log-level': 'Info', 'no-input': true }); + vi.spyOn(target, 'getArtifactsForRevision').mockResolvedValue([ + { filename: 'gh-pages.zip' } as any, + ]); + vi.spyOn(target.artifactProvider, 'downloadArtifact').mockResolvedValue( + '/tmp/gh-pages.zip', + ); + vi.spyOn(target, 'commitArchiveToBranch').mockResolvedValue(SHA); + + await target.publish('1.0.0', 'revision'); + + expect(repos.getPages).not.toHaveBeenCalled(); + }); + + it('waits for the Pages build of the pushed commit', async () => { + vi.spyOn(target, 'getArtifactsForRevision').mockResolvedValue([ + { filename: 'gh-pages.zip' } as any, + ]); + vi.spyOn(target.artifactProvider, 'downloadArtifact').mockResolvedValue( + '/tmp/gh-pages.zip', + ); + vi.spyOn(target, 'commitArchiveToBranch').mockResolvedValue(SHA); + repos.getLatestPagesBuild.mockResolvedValue(build(2, 'built')); + + await target.publish('1.0.0', 'revision'); + + expect(repos.getLatestPagesBuild).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/targets/ghPages.ts b/src/targets/ghPages.ts index cd4938ad..58c78ea9 100644 --- a/src/targets/ghPages.ts +++ b/src/targets/ghPages.ts @@ -7,6 +7,7 @@ import { TargetConfig, TypedTargetConfig, } from '../schemas/project_config'; +import { sleep } from '../utils/async'; import { ConfigurationError, reportError } from '../utils/errors'; import { withTempDir } from '../utils/files'; import { @@ -15,6 +16,7 @@ import { GitHubRemote, } from '../utils/githubApi'; import { cloneRepo } from '../utils/git'; +import { isDryRun } from '../utils/helpers'; import { extractZipArchiveWithFlattening } from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; @@ -33,6 +35,26 @@ const DEFAULT_DEPLOY_ARCHIVE_REGEX = /^(?:.+-)?gh-pages\.zip$/; const DEFAULT_DEPLOY_BRANCH = 'gh-pages'; +/** Delay between reads of the latest GitHub Pages build */ +const PAGES_BUILD_POLL_INTERVAL_MS = 10 * 1000; + +/** + * Reads per build attempt: 15 minutes. GitHub's deploy job alone can take + * up to 10 minutes before actions/deploy-pages gives up. + */ +const PAGES_BUILD_MAX_POLLS = 90; + +/** Rebuilds to request after the first GitHub Pages build fails */ +const PAGES_MAX_REBUILDS = 2; + +/** Subset of a GitHub Pages build that the target reads */ +interface PagesBuild { + url: string; + status: string; + commit: string; + error: { message: string | null }; +} + /** Target options for "gh-pages" */ export interface GhPagesConfig { /** GitHub project owner */ @@ -131,6 +153,7 @@ export class GhPagesTarget extends BaseTarget { * @param branch Branch to push * @param archivePath Path to the archive * @param version Version to deploy + * @returns SHA of the pushed commit */ public async commitArchiveToBranch( directory: string, @@ -138,7 +161,7 @@ export class GhPagesTarget extends BaseTarget { branch: string, archivePath: string, version: string, - ): Promise { + ): Promise { this.logger.info( `Cloning "${remote.getRemoteString()}" to "${directory}"...`, ); @@ -176,6 +199,93 @@ export class GhPagesTarget extends BaseTarget { this.logger.info(`Pushing branch "${branch}"...`); await git.push('origin', branch, ['--set-upstream']); + return (await git.revparse(['HEAD'])).trim(); + } + + /** + * Waits until GitHub Pages builds the pushed commit, and requests a + * rebuild when the build fails. + * + * A push to the branch only starts the build. GitHub can fail the build + * later, for example when its deploy job times out (getsentry/craft#960). + * Skips the wait when the token cannot read Pages, or when Pages does not + * build from the configured branch. + * + * @param sha SHA of the pushed commit + */ + public async waitForPagesBuild(sha: string): Promise { + const { githubOwner: owner, githubRepo: repo, branch } = this.ghPagesConfig; + + let pages; + try { + pages = (await this.github.repos.getPages({ owner, repo })).data; + } catch (e) { + if (e.status === 403 || e.status === 404) { + this.logger.warn( + `Cannot read GitHub Pages settings of ${owner}/${repo} (HTTP ${e.status}), not waiting for the Pages build.`, + ); + return; + } + throw e; + } + if (pages.build_type !== 'legacy' || pages.source?.branch !== branch) { + this.logger.info( + `GitHub Pages of ${owner}/${repo} does not build from branch "${branch}", not waiting for the Pages build.`, + ); + return; + } + + let failedBuild: PagesBuild | undefined; + for (let attempt = 0; attempt <= PAGES_MAX_REBUILDS; attempt++) { + if (attempt > 0) { + this.logger.warn( + `GitHub Pages build failed (${describePagesBuild(failedBuild)}), requesting rebuild ${attempt}/${PAGES_MAX_REBUILDS}...`, + ); + await this.github.repos.requestPagesBuild({ owner, repo }); + } + const build = await this.pollPagesBuild(sha, failedBuild?.url); + if (build?.status === 'built') { + this.logger.info(`GitHub Pages build for ${sha} is complete.`); + return; + } + failedBuild = build ?? failedBuild; + } + + reportError( + `GitHub Pages build for ${sha} failed after ${PAGES_MAX_REBUILDS} rebuilds (${describePagesBuild(failedBuild)}). ` + + `See https://github.com/${owner}/${repo}/deployments/github-pages`, + ); + } + + /** + * Reads the latest GitHub Pages build until the build for `sha` finishes + * + * @param sha SHA of the pushed commit + * @param ignoreUrl URL of an earlier failed build of the same commit + * @returns The finished build, or undefined when the wait timed out + */ + private async pollPagesBuild( + sha: string, + ignoreUrl?: string, + ): Promise { + const { githubOwner: owner, githubRepo: repo } = this.ghPagesConfig; + for (let poll = 0; poll < PAGES_BUILD_MAX_POLLS; poll++) { + await sleep(PAGES_BUILD_POLL_INTERVAL_MS); + const build: PagesBuild = ( + await this.github.repos.getLatestPagesBuild({ owner, repo }) + ).data; + if ( + build.commit === sha && + build.url !== ignoreUrl && + (build.status === 'built' || build.status === 'errored') + ) { + return build; + } + this.logger.debug( + `Latest GitHub Pages build: ${build.status} for ${build.commit}`, + ); + } + return undefined; } /** @@ -209,7 +319,7 @@ export class GhPagesTarget extends BaseTarget { getGitHubApiToken(), ); - await withTempDir( + const sha = await withTempDir( async directory => this.commitArchiveToBranch( directory, @@ -222,6 +332,23 @@ export class GhPagesTarget extends BaseTarget { 'craft-gh-pages-', ); + // The push did not happen, so there is no build to wait for. This also + // keeps requestPagesBuild out of dry-run: the dry-run GitHub client does + // not treat "request*" methods as mutating. + if (isDryRun()) { + this.logger.info('[dry-run] Not waiting for the GitHub Pages build.'); + } else { + await this.waitForPagesBuild(sha); + } + this.logger.info('GitHub pages release complete'); } } + +/** Describes a failed Pages build for logs and errors */ +function describePagesBuild(build: PagesBuild | undefined): string { + if (!build) { + return 'timed out'; + } + return build.error.message || build.status; +}