Docs preview deploy #247
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| }); |