Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 346 additions & 0 deletions .github/workflows/docs-preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
name: Docs preview deploy

"on":
workflow_run:
workflows:
- Docs preview build
types:
- completed

permissions:
actions: read
contents: write
pages: write
pull-requests: write
id-token: write

concurrency:
group: github-pages
queue: max

jobs:
deploy-preview:
name: Publish documentation preview
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.pages.outputs.page_url }}
steps:
- name: Resolve trusted preview request
id: request
uses: actions/github-script@v8
with:
script: |
const run = context.payload.workflow_run;
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const requests = artifacts.artifacts.flatMap((artifact) => {
const match = artifact.name.match(/^docs-preview-([1-9][0-9]*)-(deploy|remove)$/);
return match ? [{ artifact, number: Number(match[1]), operation: match[2] }] : [];
});

if (requests.length !== 1) {
core.setFailed(`Expected one preview request artifact, found ${requests.length}.`);
return;
}

const request = requests[0];
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: request.number,
});
const associated = run.pull_requests.some(({ number }) => number === request.number);
const sameHead = pullRequest.head.sha === run.head_sha;
const sameRepository = pullRequest.head.repo?.full_name === run.head_repository?.full_name;
if (!associated && (!sameHead || !sameRepository)) {
core.setFailed('Preview request is not associated with the completed workflow run.');
return;
}

const internalHead = `${context.repo.owner}/${context.repo.repo}`;
if (run.head_repository?.full_name !== internalHead ||
pullRequest.head.repo?.full_name !== internalHead) {
core.notice('Hosted previews are limited to trusted repository branches.');
core.setOutput('operation', 'none');
return;
}
const dependabot = pullRequest.user?.login === 'dependabot[bot]';
if (dependabot) {
core.notice('Dependabot previews are not published on the production origin.');
}

if (pullRequest.state === 'open' && !sameHead) {
core.notice('A newer pull request revision exists; ignoring this stale preview request.');
core.setOutput('operation', 'none');
return;
}

let operation = 'remove';
if (pullRequest.state === 'open' && !dependabot) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: request.number,
per_page: 100,
});
const exactInputs = new Set([
'.github/workflows/docs.yml',
'.github/workflows/docs-preview.yml',
'.github/workflows/docs-preview-deploy.yml',
'requirements-docs.txt',
'scripts/build-docs.sh',
'scripts/render-dev-notes.py',
'tests/test_render_dev_notes.py',
'zensical.toml',
]);
const docsChanged = files.some(
({ filename }) => filename.startsWith('docs/') || exactInputs.has(filename),
);
operation = docsChanged ? 'deploy' : 'remove';
}

if (operation === 'deploy' && request.operation !== 'deploy') {
core.notice('The current pull request needs a newer build; ignoring this request.');
core.setOutput('operation', 'none');
return;
}

if (operation === 'remove') {
let previewStored = true;
try {
await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: `pr-preview/pr-${request.number}`,
ref: 'gh-pages',
});
} catch (error) {
if (error.status === 404) {
previewStored = false;
} else {
throw error;
}
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: request.number,
per_page: 100,
});
const activeComment = comments.some(
(comment) => comment.user?.type === 'Bot' &&
comment.body?.includes('<!-- docs-preview -->') &&
comment.body?.includes('View the deployed preview'),
);
if (!previewStored && !activeComment) {
core.notice('No published preview exists; nothing to remove.');
core.setOutput('operation', 'none');
return;
}
}

core.setOutput('artifact', request.artifact.name);
core.setOutput('number', String(request.number));
core.setOutput('operation', operation);

- name: Download preview site
if: steps.request.outputs.operation == 'deploy'
uses: actions/download-artifact@v8
with:
name: ${{ steps.request.outputs.artifact }}
path: preview-site
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

- name: Create empty removal directory
if: steps.request.outputs.operation == 'remove'
run: mkdir preview-site

- name: Checkout deployment tooling context
if: steps.request.outputs.operation != 'none'
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Update composite Pages branch
if: steps.request.outputs.operation != 'none'
uses: JamesIves/github-pages-deploy-action@4a3abc783e1a24aeb44c16e869ad83caf6b4cc23 # v4.7.4
with:
branch: gh-pages
folder: preview-site
target-folder: pr-preview/pr-${{ steps.request.outputs.number }}
force: false
attempt-limit: 10

- name: Checkout composite Pages site
if: steps.request.outputs.operation != 'none'
uses: actions/checkout@v7
with:
ref: gh-pages
path: published-site
persist-credentials: false

- name: Upload Pages artifact
if: steps.request.outputs.operation != 'none'
uses: actions/upload-pages-artifact@v5
with:
path: published-site

- name: Deploy to GitHub Pages
if: steps.request.outputs.operation != 'none'
id: pages
uses: actions/deploy-pages@v5

- name: Add or update preview comment
if: steps.request.outputs.operation != 'none'
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
OPERATION: ${{ steps.request.outputs.operation }}
PAGE_URL: ${{ steps.pages.outputs.page_url }}
PR_NUMBER: ${{ steps.request.outputs.number }}
with:
script: |
const marker = '<!-- docs-preview -->';
const number = Number(process.env.PR_NUMBER);
const rootUrl = process.env.PAGE_URL.replace(/\/?$/, '/');
const previewUrl = `${rootUrl}pr-preview/pr-${number}/`;
const body = process.env.OPERATION === 'deploy'
? `${marker}\n### Documentation preview\n\n[View the deployed preview](${previewUrl})` +
`\n\nBuilt from \`${process.env.HEAD_SHA.slice(0, 7)}\`.`
: `${marker}\n### Documentation preview\n\nThe preview has been removed.`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
per_page: 100,
});
const existing = comments.find(
(comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker),
);

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else if (process.env.OPERATION === 'deploy') {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
body,
});
}
core.setOutput('url', previewUrl);

- name: Mark an existing preview as stale after deployment failure
if: failure() && steps.request.outputs.number != ''
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
PR_NUMBER: ${{ steps.request.outputs.number }}
with:
script: |
const number = Number(process.env.PR_NUMBER);
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
});
if (pullRequest.state !== 'open' || pullRequest.head.sha !== process.env.HEAD_SHA) {
return;
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
per_page: 100,
});
const existing = comments.find(
(comment) => comment.user?.type === 'Bot' &&
comment.body?.includes('<!-- docs-preview -->'),
);
if (!existing) {
return;
}

const body = existing.body.replace(/\n\n> ⚠️ Latest preview update failed[\s\S]*$/, '') +
`\n\n> ⚠️ Latest preview update failed for ` +
`\`${process.env.HEAD_SHA.slice(0, 7)}\`; the linked preview may be stale. ` +
`[View workflow logs](${context.serverUrl}/${context.repo.owner}/` +
`${context.repo.repo}/actions/runs/${context.runId}).`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});

mark-build-failure:
name: Mark failed preview build
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'success'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Mark an existing preview as stale
uses: actions/github-script@v8
with:
script: |
const run = context.payload.workflow_run;
const association = run.pull_requests[0];
const internalHead = `${context.repo.owner}/${context.repo.repo}`;
if (!association || run.head_repository?.full_name !== internalHead) {
return;
}

const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: association.number,
});
if (pullRequest.user?.login === 'dependabot[bot]' ||
pullRequest.state !== 'open' || pullRequest.head.sha !== run.head_sha) {
return;
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: association.number,
per_page: 100,
});
const existing = comments.find(
(comment) => comment.user?.type === 'Bot' &&
comment.body?.includes('<!-- docs-preview -->'),
);
if (!existing) {
return;
}

const body = existing.body.replace(/\n\n> ⚠️ Latest preview update failed[\s\S]*$/, '') +
`\n\n> ⚠️ Latest preview update failed for ` +
`\`${run.head_sha.slice(0, 7)}\`; the linked preview may be stale. ` +
`[View workflow logs](${run.html_url}).`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
Loading