Skip to content

Docs preview deploy #247

Docs preview deploy

Docs preview deploy #247

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/publish-agent-markdown.py',
'scripts/render-dev-notes.py',
'scripts/stage-project-docs.py',
'tests/test_agent_markdown.py',
'tests/test_docs_404.py',
'tests/test_render_dev_notes.py',
'tests/test_stage_project_docs.py',
'zensical.toml',
]);
const docsChanged = files.some(
({ filename }) => filename.startsWith('docs/') ||
filename.startsWith('overrides/') ||
/^projects\/[^/]+\/docs\//.test(filename) ||
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: Checkout deployment tooling context
if: steps.request.outputs.operation != 'none'
uses: actions/checkout@v7
with:
persist-credentials: false
- 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: 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,
});