diff --git a/.github/scripts/preview-status.cjs b/.github/scripts/preview-status.cjs new file mode 100644 index 00000000..0ec8a6b0 --- /dev/null +++ b/.github/scripts/preview-status.cjs @@ -0,0 +1,40 @@ +const marker = ""; + +/** Reuse one bot preview comment and remove older duplicate statuses. */ +async function updatePreviewComment({ github, context, prNumber, status, runUrl }) { + if (!Number.isSafeInteger(prNumber) || prNumber < 1) { + throw new Error("Invalid PR number"); + } + const previewUrl = `https://opentdf-docs-pr-${prNumber}.surge.sh`; + const message = { + deployed: `šŸ“„ Preview deployed to ${previewUrl}`, + "build-failed": `āŒ Surge preview build failed. [Build logs](${runUrl})`, + "deploy-failed": `āŒ Surge preview deployment failed. [Workflow logs](${runUrl})`, + }[status]; + if (!message) throw new Error(`Unknown preview status: ${status}`); + + const comments = (await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: prNumber, per_page: 100, + })).filter((comment) => comment.user?.login === "github-actions[bot]" && ( + comment.body?.startsWith("\nāŒ Surge preview build failed", user: { login: "github-actions[bot]" } }, + { id: 4, body: "āŒ Surge preview build failed", user: { login: "reviewer" } }, + ]); + await updatePreviewComment({ ...state, prNumber: 398, status: "deployed" }); + assert.deepEqual(state.calls, ["update", "delete", "delete"]); + assert.deepEqual(state.comments.map((comment) => comment.id), [3, 4]); +}); + +test("repeat failures reuse the same comment, then success clears the failure", async () => { + const state = fixture(); + const args = { ...state, prNumber: 398, runUrl: "https://github.com/opentdf/docs/actions/runs/7" }; + await updatePreviewComment({ ...args, status: "build-failed" }); + await updatePreviewComment({ ...args, status: "build-failed" }); + await updatePreviewComment({ ...args, status: "deployed" }); + assert.deepEqual(state.calls, ["create", "update"]); + assert.equal(state.comments.length, 1); + assert.match(state.comments[0].body, /Preview deployed/); +}); diff --git a/.github/workflows/surge-preview-deploy.yaml b/.github/workflows/surge-preview-deploy.yaml index e546ac7d..4b6669d9 100644 --- a/.github/workflows/surge-preview-deploy.yaml +++ b/.github/workflows/surge-preview-deploy.yaml @@ -15,11 +15,9 @@ on: default: false jobs: - # Triggered automatically after the build workflow completes (success or failure). - # Runs in a trusted context so it has access to secrets and a write token, - # even when the build was triggered by a fork PR. + # Runs with secrets even when a fork PR produced the build artifact. deploy: - if: > + if: >- github.event_name == 'workflow_run' && ( github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' @@ -27,8 +25,15 @@ jobs: name: Deploy Preview runs-on: ubuntu-latest permissions: + actions: read + contents: read pull-requests: write steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + path: .preview-workflow + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: 22 @@ -40,67 +45,88 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Read metadata + - name: Validate metadata and PR source id: meta - run: | - echo "pr_number=$(jq -r '.pr_number' pr-metadata.json)" >> $GITHUB_OUTPUT - echo "action=$(jq -r '.action' pr-metadata.json)" >> $GITHUB_OUTPUT - echo "build_success=$(jq -r '.build_success' pr-metadata.json)" >> $GITHUB_OUTPUT + uses: actions/github-script@v7 + with: + script: | + const meta = JSON.parse(require('node:fs').readFileSync('pr-metadata.json', 'utf8')); + const run = context.payload.workflow_run; + if (!Number.isSafeInteger(meta.pr_number) || meta.pr_number < 1 || + meta.pr_number > 2147483647 || + !['opened', 'synchronize', 'reopened', 'closed'].includes(meta.action) || + typeof meta.build_success !== 'boolean' || + meta.build_success !== (run.conclusion === 'success')) { + throw new Error('Invalid preview metadata'); + } + const { data: pr } = await github.rest.pulls.get({ + ...context.repo, pull_number: meta.pr_number, + }); + const headMatches = pr.head.sha === run.head_sha && + pr.head.repo?.full_name === run.head_repository?.full_name; + const mergeMatches = meta.action === 'closed' && pr.merged && + pr.merge_commit_sha === run.head_sha; + if ((!headMatches && !mergeMatches) || + (meta.action === 'closed' ? pr.state !== 'closed' : pr.state !== 'open')) { + throw new Error('Preview artifact does not match the triggering PR'); + } + core.setOutput('pr_number', meta.pr_number); + core.setOutput('action', meta.action); + core.setOutput('build_success', meta.build_success); - name: Deploy to Surge + id: deploy if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.build_success == 'true' - run: npx surge build/ https://opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh + run: npx surge build/ "https://opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Teardown Surge preview if: steps.meta.outputs.action == 'closed' - run: npx surge teardown opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh + run: npx surge teardown "opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - - name: Comment with preview URL - if: > - steps.meta.outputs.build_success == 'true' && ( - steps.meta.outputs.action == 'opened' || - steps.meta.outputs.action == 'reopened' - ) - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({ - issue_number: ${{ steps.meta.outputs.pr_number }}, - owner: context.repo.owner, - repo: context.repo.repo, - body: 'šŸ“„ Preview deployed to https://opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh' - }) - - - name: Comment build failure - if: steps.meta.outputs.build_success == 'false' && steps.meta.outputs.action != 'closed' + - name: Update preview status comment + if: always() && steps.meta.outcome == 'success' && steps.meta.outputs.action != 'closed' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} + BUILD_SUCCESS: ${{ steps.meta.outputs.build_success }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} with: script: | - github.rest.issues.createComment({ - issue_number: ${{ steps.meta.outputs.pr_number }}, - owner: context.repo.owner, - repo: context.repo.repo, - body: `āŒ Surge preview build failed — no preview was deployed. [Check the workflow logs](${{ github.event.workflow_run.html_url }}) for details.\n\nOnce the build passes, the preview will be at: https://opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh\n\n**Common cause:** If the build failed on vendored YAML validation, run the following locally and commit the result:\n\`\`\`\nnpm run update-vendored-yaml\ngit add specs/\ngit commit -m "chore(deps): update vendored OpenAPI specs"\n\`\`\`` - }) - - # Manual dispatch: builds and deploys in a fully trusted context. - # Use this to trigger a preview for PRs outside the auto-trigger path filters. + const status = process.env.BUILD_SUCCESS === 'false' ? 'build-failed' : + process.env.DEPLOY_OUTCOME === 'success' ? 'deployed' : 'deploy-failed'; + const runUrl = status === 'build-failed' + ? context.payload.workflow_run.html_url + : `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const { updatePreviewComment } = require('./.preview-workflow/.github/scripts/preview-status.cjs'); + await updatePreviewComment({ + github, context, prNumber: Number(process.env.PR_NUMBER), status, runUrl, + }); + + # Manual dispatch builds and deploys in a trusted context. deploy-manual: if: github.event_name == 'workflow_dispatch' && inputs.teardown == false name: Build and Deploy Preview (Manual) runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Validate PR number + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + [[ "$PR_NUMBER" =~ ^[1-9][0-9]{0,9}$ ]] && (( PR_NUMBER <= 2147483647 )) - uses: actions/setup-node@v4 with: node-version: 22 @@ -108,48 +134,49 @@ jobs: - name: Install dependencies run: npm ci - - name: Update vendored OpenAPI specs run: npm run update-vendored-yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build website run: npm run gen-api-docs-all && npx docusaurus build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Add SPA fallback for Surge run: cp build/index.html build/200.html - - name: Deploy to Surge - run: npx surge build/ https://opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh + run: npx surge build/ "https://opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ inputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - - - name: Comment with preview URL + - name: Update preview status comment uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr_number }} with: script: | - github.rest.issues.createComment({ - issue_number: ${{ inputs.pr_number }}, - owner: context.repo.owner, - repo: context.repo.repo, - body: 'šŸ“„ Preview deployed to https://opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh' - }) + const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); + await updatePreviewComment({ + github, context, prNumber: Number(process.env.PR_NUMBER), status: 'deployed', + }); teardown-manual: if: github.event_name == 'workflow_dispatch' && inputs.teardown == true name: Teardown Preview (Manual) runs-on: ubuntu-latest steps: + - name: Validate PR number + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + [[ "$PR_NUMBER" =~ ^[1-9][0-9]{0,9}$ ]] && (( PR_NUMBER <= 2147483647 )) - uses: actions/setup-node@v4 with: node-version: 22 - - name: Teardown Surge preview - run: npx surge teardown opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh + run: npx surge teardown "opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ inputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} diff --git a/.github/workflows/test-deploy.yaml b/.github/workflows/test-deploy.yaml index 9fa16b4e..76d7d5d2 100644 --- a/.github/workflows/test-deploy.yaml +++ b/.github/workflows/test-deploy.yaml @@ -20,6 +20,9 @@ jobs: node-version: 22 cache: npm + - name: Test preview comment updates + run: node --test .github/scripts/preview-status.test.cjs + - name: Install dependencies run: npm ci - name: Test build website