diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml new file mode 100644 index 00000000..76c5caef --- /dev/null +++ b/.github/workflows/docs-preview-deploy.yml @@ -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('') && + 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 = ''; + 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(''), + ); + 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(''), + ); + 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, + }); diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 00000000..15fc3df0 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,101 @@ +name: Docs preview build + +"on": + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +permissions: + contents: read + pull-requests: read + +concurrency: + group: docs-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + changes: + name: Detect documentation changes + runs-on: ubuntu-latest + outputs: + operation: ${{ steps.operation.outputs.operation }} + steps: + - name: Choose preview operation + id: operation + uses: actions/github-script@v8 + with: + script: | + if (context.payload.action === 'closed') { + core.setOutput('operation', 'remove'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_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), + ); + core.setOutput('operation', docsChanged ? 'deploy' : 'remove'); + + build: + name: Build documentation preview + needs: changes + if: needs.changes.outputs.operation == 'deploy' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-docs.txt + + - name: Build documentation + run: scripts/build-docs.sh + + - name: Upload preview request + uses: actions/upload-artifact@v7 + with: + name: docs-preview-${{ github.event.pull_request.number }}-deploy + path: site + if-no-files-found: error + retention-days: 1 + + remove: + name: Request preview removal + needs: changes + if: needs.changes.outputs.operation == 'remove' + runs-on: ubuntu-latest + steps: + - name: Create removal request + run: mkdir preview-removal && touch preview-removal/remove + + - name: Upload removal request + uses: actions/upload-artifact@v7 + with: + name: docs-preview-${{ github.event.pull_request.number }}-remove + path: preview-removal + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a07137d9..4200fdda 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -56,6 +56,17 @@ jobs: - name: Verify generated documentation is committed run: git diff --exit-code -- docs/dev-notes zensical.toml + - name: Upload production documentation + if: >- + github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/upload-artifact@v7 + with: + name: production-docs-${{ github.run_id }} + path: site + if-no-files-found: error + retention-days: 1 + deploy: name: Deploy documentation if: >- @@ -64,7 +75,8 @@ jobs: needs: validate runs-on: ubuntu-latest permissions: - contents: read + actions: read + contents: write pages: write id-token: write environment: @@ -72,29 +84,62 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} concurrency: group: github-pages - cancel-in-progress: false + queue: max steps: - - name: Checkout + - name: Check production revision is current + id: freshness + uses: actions/github-script@v8 + with: + script: | + const { data: branch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'main', + }); + const current = branch.commit.sha === context.sha; + core.setOutput('current', String(current)); + if (!current) { + core.notice(`Skipping stale production revision ${context.sha}.`); + } + + - name: Checkout deployment tooling context + if: steps.freshness.outputs.current == 'true' uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 with: - python-version: "3.12" - cache: pip - cache-dependency-path: requirements-docs.txt + persist-credentials: false - - name: Configure GitHub Pages - uses: actions/configure-pages@v6 + - name: Download production documentation + if: steps.freshness.outputs.current == 'true' + uses: actions/download-artifact@v8 + with: + name: production-docs-${{ github.run_id }} + path: site - - name: Build documentation - run: scripts/build-docs.sh + - name: Update composite Pages branch + if: steps.freshness.outputs.current == 'true' + uses: JamesIves/github-pages-deploy-action@4a3abc783e1a24aeb44c16e869ad83caf6b4cc23 # v4.7.4 + with: + branch: gh-pages + folder: site + clean-exclude: pr-preview + force: false + attempt-limit: 10 + + - name: Checkout composite Pages site + if: steps.freshness.outputs.current == 'true' + uses: actions/checkout@v7 + with: + ref: gh-pages + path: published-site + persist-credentials: false - name: Upload Pages artifact + if: steps.freshness.outputs.current == 'true' uses: actions/upload-pages-artifact@v5 with: - path: site + path: published-site - name: Deploy to GitHub Pages + if: steps.freshness.outputs.current == 'true' id: deployment uses: actions/deploy-pages@v5 diff --git a/docs/development/index.md b/docs/development/index.md index 32e171ec..062eabe5 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -76,5 +76,17 @@ For documentation-site changes, serve the site before handing the task back: ``` Confirm is reachable and report the URL and command being -served. Pull requests validate without deploying; pushes and manual workflow runs -from `main` publish the validated `site/` directory to GitHub Pages. +served. Pull requests from branches in this repository that change documentation +inputs publish the built site under `/pr-preview/pr-/` and receive a +comment linking to that browser-accessible preview. The preview is updated when +the PR changes and removed when the PR closes or no longer changes documentation. +Fork and Dependabot pull requests validate with read-only credentials but do not +publish previews on the production documentation origin. + +The `gh-pages` branch stores the composite production site and active previews; +GitHub Pages remains configured with **GitHub Actions** as its publishing source. +Pushes and manual workflow runs from `main` update the production site while +preserving active previews, then deploy the complete branch through the official +Pages artifact workflow. The first production deployment creates `gh-pages` +automatically. To roll back to a revision before preview support, leave the Pages +source set to **GitHub Actions** and rerun the restored documentation workflow.