From 354d9bf74e8031c541d62f1f6a9e3fd8eb6874b9 Mon Sep 17 00:00:00 2001 From: jp-ayyappan Date: Tue, 6 Oct 2026 12:15:55 -0400 Subject: [PATCH 1/2] fix(ci): keep Surge preview comment current --- .github/scripts/preview-status.cjs | 66 +++++++++++++++ .github/scripts/preview-status.test.cjs | 85 +++++++++++++++++++ .github/workflows/surge-preview-deploy.yaml | 93 ++++++++++++++------- .github/workflows/test-deploy.yaml | 3 + 4 files changed, 216 insertions(+), 31 deletions(-) create mode 100644 .github/scripts/preview-status.cjs create mode 100644 .github/scripts/preview-status.test.cjs diff --git a/.github/scripts/preview-status.cjs b/.github/scripts/preview-status.cjs new file mode 100644 index 00000000..e55f48e7 --- /dev/null +++ b/.github/scripts/preview-status.cjs @@ -0,0 +1,66 @@ +const marker = ""; + +function isPreviewComment(comment) { + if (comment.user?.login !== "github-actions[bot]") { + return false; + } + + const body = comment.body || ""; + return body.includes(marker) || + body.startsWith("โŒ Surge preview build failed") || + body.startsWith("๐Ÿ“„ Preview deployed to https://opentdf-docs-pr-"); +} + +function statusBody(status, previewUrl, runUrl) { + switch (status) { + case "deployed": + return `๐Ÿ“„ Preview deployed to ${previewUrl}`; + case "build-failed": + return `โŒ Surge preview build failed. The preview was not updated. [Build logs](${runUrl})`; + case "deploy-failed": + return `โŒ Surge preview deployment failed. [Workflow logs](${runUrl})`; + case "removed": + return "๐Ÿงน Surge preview removed."; + case "teardown-failed": + return `โŒ Surge preview teardown failed. [Workflow logs](${runUrl})`; + default: + throw new Error(`Unknown preview status: ${status}`); + } +} + +async function updatePreviewComment({ github, context, prNumber, status, runUrl }) { + if (!Number.isInteger(prNumber) || prNumber < 1) { + throw new Error(`Invalid PR number: ${prNumber}`); + } + + const { owner, repo } = context.repo; + const issue_number = prNumber; + const previewUrl = `https://opentdf-docs-pr-${prNumber}.surge.sh`; + const workflowUrl = runUrl || `${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${context.runId}`; + const body = `${marker}\n${statusBody(status, previewUrl, workflowUrl)}`; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const previewComments = comments.filter(isPreviewComment); + const current = [...previewComments].reverse().find((comment) => comment.body.includes(marker)) || + previewComments[previewComments.length - 1]; + + if (current) { + if (current.body !== body) { + await github.rest.issues.updateComment({ owner, repo, comment_id: current.id, body }); + } + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + for (const comment of previewComments) { + if (comment.id !== current?.id) { + await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); + } + } +} + +module.exports = { updatePreviewComment }; diff --git a/.github/scripts/preview-status.test.cjs b/.github/scripts/preview-status.test.cjs new file mode 100644 index 00000000..00c5c5c1 --- /dev/null +++ b/.github/scripts/preview-status.test.cjs @@ -0,0 +1,85 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { updatePreviewComment } = require("./preview-status.cjs"); + +function fixture(comments = []) { + const calls = []; + const context = { repo: { owner: "opentdf", repo: "docs" }, runId: 123 }; + const issues = { + listComments() {}, + async createComment({ body }) { + calls.push(["create", body]); + comments.push({ id: 1000, body, user: { login: "github-actions[bot]" } }); + }, + async updateComment({ comment_id, body }) { + calls.push(["update", comment_id, body]); + comments.find((comment) => comment.id === comment_id).body = body; + }, + async deleteComment({ comment_id }) { + calls.push(["delete", comment_id]); + comments.splice(comments.findIndex((comment) => comment.id === comment_id), 1); + }, + }; + const github = { + rest: { issues }, + async paginate(method, options) { + assert.equal(method, issues.listComments); + assert.equal(options.issue_number, 398); + return comments; + }, + }; + + return { calls, comments, context, github }; +} + +test("a successful build replaces a legacy failure comment", async () => { + const state = fixture([{ + id: 42, + body: "โŒ Surge preview build failed โ€” no preview was deployed.", + user: { login: "github-actions[bot]" }, + }]); + + await updatePreviewComment({ + github: state.github, + context: state.context, + prNumber: 398, + status: "deployed", + }); + + assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [["update", 42]]); + assert.match(state.comments[0].body, /Preview deployed to https:\/\/opentdf-docs-pr-398\.surge\.sh/); +}); + +test("duplicate preview comments are removed without touching other comments", async () => { + const state = fixture([ + { id: 1, body: "๐Ÿ“„ Preview deployed to https://opentdf-docs-pr-398.surge.sh", user: { login: "github-actions[bot]" } }, + { id: 2, body: "โŒ Surge preview build failed", user: { login: "github-actions[bot]" } }, + { id: 3, body: "\nโŒ Surge preview build failed", user: { login: "github-actions[bot]" } }, + { id: 4, body: "โŒ Surge preview build failed", user: { login: "reviewer" } }, + ]); + + await updatePreviewComment({ + github: state.github, + context: state.context, + prNumber: 398, + status: "deployed", + }); + + assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [ + ["update", 3], ["delete", 1], ["delete", 2], + ]); + assert.deepEqual(state.comments.map((comment) => comment.id), [3, 4]); +}); + +test("new status comments are reused for later runs", async () => { + const state = fixture(); + const args = { github: state.github, context: state.context, prNumber: 398 }; + + await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" }); + await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" }); + await updatePreviewComment({ ...args, status: "deployed" }); + + assert.deepEqual(state.calls.map((call) => call[0]), ["create", "update"]); + assert.match(state.calls[0][1], /actions\/runs\/7/); + assert.equal(state.comments.length, 1); +}); diff --git a/.github/workflows/surge-preview-deploy.yaml b/.github/workflows/surge-preview-deploy.yaml index e546ac7d..8e4b2231 100644 --- a/.github/workflows/surge-preview-deploy.yaml +++ b/.github/workflows/surge-preview-deploy.yaml @@ -27,8 +27,13 @@ jobs: name: Deploy Preview runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: 22 @@ -48,6 +53,7 @@ jobs: echo "build_success=$(jq -r '.build_success' pr-metadata.json)" >> $GITHUB_OUTPUT - 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 env: @@ -55,39 +61,33 @@ jobs: SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Teardown Surge preview + id: teardown if: steps.meta.outputs.action == 'closed' run: npx surge teardown opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh env: 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' - ) + - name: Update preview status comment + if: always() && steps.meta.outcome == 'success' 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' - 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: `โŒ 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\`\`\`` - }) + const action = '${{ steps.meta.outputs.action }}'; + const buildSucceeded = '${{ steps.meta.outputs.build_success }}' === 'true'; + const status = action === 'closed' + ? ('${{ steps.teardown.outcome }}' === 'success' ? 'removed' : 'teardown-failed') + : !buildSucceeded + ? 'build-failed' + : '${{ steps.deploy.outcome }}' === 'success' ? 'deployed' : 'deploy-failed'; + const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); + await updatePreviewComment({ + github, + context, + prNumber: Number('${{ steps.meta.outputs.pr_number }}'), + status, + runUrl: status === 'build-failed' ? context.payload.workflow_run.html_url : undefined, + }); # Manual dispatch: builds and deploys in a fully trusted context. # Use this to trigger a preview for PRs outside the auto-trigger path filters. @@ -96,9 +96,11 @@ jobs: name: Build and Deploy Preview (Manual) runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: actions/checkout@v4 + id: checkout with: fetch-depth: 0 - uses: actions/setup-node@v4 @@ -115,6 +117,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build website + id: build run: npm run gen-api-docs-all && npx docusaurus build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -123,33 +126,61 @@ jobs: run: cp build/index.html build/200.html - name: Deploy to Surge + id: deploy run: npx surge build/ https://opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh env: SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - - name: Comment with preview URL + - name: Update preview status comment + if: always() && steps.checkout.outcome == 'success' uses: actions/github-script@v7 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 status = '${{ steps.build.outcome }}' !== 'success' + ? 'build-failed' + : '${{ steps.deploy.outcome }}' === 'success' ? 'deployed' : 'deploy-failed'; + const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); + await updatePreviewComment({ + github, + context, + prNumber: Number('${{ inputs.pr_number }}'), + status, + }); teardown-manual: if: github.event_name == 'workflow_dispatch' && inputs.teardown == true name: Teardown Preview (Manual) runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: + - uses: actions/checkout@v4 + id: checkout + with: + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: 22 - name: Teardown Surge preview + id: teardown run: npx surge teardown opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh env: SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} + + - name: Update preview status comment + if: always() && steps.checkout.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const status = '${{ steps.teardown.outcome }}' === 'success' ? 'removed' : 'teardown-failed'; + const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); + await updatePreviewComment({ + github, + context, + prNumber: Number('${{ inputs.pr_number }}'), + status, + }); 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 From 349451ef10330faff860e7826ee7594d9c91c51c Mon Sep 17 00:00:00 2001 From: jp-ayyappan Date: Tue, 6 Oct 2026 13:55:39 -0400 Subject: [PATCH 2/2] fix(ci): validate and serialize preview status updates --- .github/scripts/preview-status.cjs | 80 +++++-- .github/scripts/preview-status.test.cjs | 22 +- .github/workflows/surge-preview-deploy.yaml | 222 +++++++++++++++++--- 3 files changed, 279 insertions(+), 45 deletions(-) diff --git a/.github/scripts/preview-status.cjs b/.github/scripts/preview-status.cjs index e55f48e7..1dc790a9 100644 --- a/.github/scripts/preview-status.cjs +++ b/.github/scripts/preview-status.cjs @@ -1,5 +1,7 @@ -const marker = ""; +const marker = "/; +/** Identify comments owned by this workflow, including its older comment formats. */ function isPreviewComment(comment) { if (comment.user?.login !== "github-actions[bot]") { return false; @@ -11,6 +13,7 @@ function isPreviewComment(comment) { body.startsWith("๐Ÿ“„ Preview deployed to https://opentdf-docs-pr-"); } +/** Format the current result for the PR timeline. */ function statusBody(status, previewUrl, runUrl) { switch (status) { case "deployed": @@ -28,25 +31,72 @@ function statusBody(status, previewUrl, runUrl) { } } -async function updatePreviewComment({ github, context, prNumber, status, runUrl }) { - if (!Number.isInteger(prNumber) || prNumber < 1) { +/** Order runs by the build start time, then by run ID when starts share a second. */ +function sourceOrder(context, sourceTime, sourceRunId) { + const time = Date.parse(sourceTime); + const runId = Number(sourceRunId ?? context.runId); + if (!Number.isSafeInteger(time) || !Number.isSafeInteger(runId) || runId < 1) { + throw new Error("Invalid preview run timestamp or ID"); + } + return { time, runId }; +} + +/** Read the run order recorded in a managed comment; older comments have none. */ +function commentOrder(comment) { + const match = comment.body?.match(versionedMarker); + return match ? { time: Number(match[1]), runId: Number(match[2]) } : undefined; +} + +/** Compare two run orders. */ +function compareOrder(left, right) { + return left.time - right.time || left.runId - right.runId; +} + +/** Find the newest managed comment, falling back to the last legacy comment. */ +function currentComment(comments) { + const versioned = comments.filter((comment) => commentOrder(comment)); + return versioned.reduce((current, comment) => + compareOrder(commentOrder(comment), commentOrder(current)) > 0 ? comment : current, + versioned[0], + ) || comments[comments.length - 1]; +} + +/** List preview status comments on a PR. */ +async function previewComments(github, context, prNumber) { + if (!Number.isSafeInteger(prNumber) || prNumber < 1) { throw new Error(`Invalid PR number: ${prNumber}`); } + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: prNumber, + per_page: 100, + }); + return comments.filter(isPreviewComment); +} + +/** Skip a build that started before the status already displayed on this PR. */ +async function isStalePreviewRun({ github, context, prNumber, sourceTime, sourceRunId }) { + const order = sourceOrder(context, sourceTime, sourceRunId); + const current = currentComment(await previewComments(github, context, prNumber)); + const existingOrder = current && commentOrder(current); + return Boolean(existingOrder && compareOrder(order, existingOrder) < 0); +} +/** Create or update one preview status comment and remove older duplicates. */ +async function updatePreviewComment({ github, context, prNumber, status, runUrl, sourceTime, sourceRunId }) { + const order = sourceOrder(context, sourceTime, sourceRunId); const { owner, repo } = context.repo; const issue_number = prNumber; const previewUrl = `https://opentdf-docs-pr-${prNumber}.surge.sh`; const workflowUrl = runUrl || `${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${context.runId}`; - const body = `${marker}\n${statusBody(status, previewUrl, workflowUrl)}`; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const previewComments = comments.filter(isPreviewComment); - const current = [...previewComments].reverse().find((comment) => comment.body.includes(marker)) || - previewComments[previewComments.length - 1]; + const body = `${marker}:${order.time}:${order.runId} -->\n${statusBody(status, previewUrl, workflowUrl)}`; + const comments = await previewComments(github, context, prNumber); + const current = currentComment(comments); + const existingOrder = current && commentOrder(current); + + if (existingOrder && compareOrder(order, existingOrder) < 0) { + return; + } if (current) { if (current.body !== body) { @@ -56,11 +106,11 @@ async function updatePreviewComment({ github, context, prNumber, status, runUrl await github.rest.issues.createComment({ owner, repo, issue_number, body }); } - for (const comment of previewComments) { + for (const comment of comments) { if (comment.id !== current?.id) { await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); } } } -module.exports = { updatePreviewComment }; +module.exports = { isStalePreviewRun, updatePreviewComment }; diff --git a/.github/scripts/preview-status.test.cjs b/.github/scripts/preview-status.test.cjs index 00c5c5c1..8928e4ea 100644 --- a/.github/scripts/preview-status.test.cjs +++ b/.github/scripts/preview-status.test.cjs @@ -1,6 +1,7 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { updatePreviewComment } = require("./preview-status.cjs"); +const { isStalePreviewRun, updatePreviewComment } = require("./preview-status.cjs"); +const sourceTime = "2026-10-06T15:00:00Z"; function fixture(comments = []) { const calls = []; @@ -44,6 +45,7 @@ test("a successful build replaces a legacy failure comment", async () => { context: state.context, prNumber: 398, status: "deployed", + sourceTime, }); assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [["update", 42]]); @@ -63,6 +65,7 @@ test("duplicate preview comments are removed without touching other comments", a context: state.context, prNumber: 398, status: "deployed", + sourceTime, }); assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [ @@ -73,7 +76,7 @@ test("duplicate preview comments are removed without touching other comments", a test("new status comments are reused for later runs", async () => { const state = fixture(); - const args = { github: state.github, context: state.context, prNumber: 398 }; + const args = { github: state.github, context: state.context, prNumber: 398, sourceTime }; await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" }); await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" }); @@ -83,3 +86,18 @@ test("new status comments are reused for later runs", async () => { assert.match(state.calls[0][1], /actions\/runs\/7/); assert.equal(state.comments.length, 1); }); + +test("an older run cannot replace a newer status", async () => { + const state = fixture(); + const args = { github: state.github, context: state.context, prNumber: 398 }; + + await updatePreviewComment({ ...args, status: "deployed", sourceTime: "2026-10-06T16:00:00Z", sourceRunId: 200 }); + const stale = await isStalePreviewRun({ ...args, sourceTime: "2026-10-06T15:00:00Z", sourceRunId: 100 }); + const sameSecondOlderRun = await isStalePreviewRun({ ...args, sourceTime: "2026-10-06T16:00:00Z", sourceRunId: 199 }); + await updatePreviewComment({ ...args, status: "build-failed", sourceTime: "2026-10-06T15:00:00Z", sourceRunId: 100 }); + + assert.equal(stale, true); + assert.equal(sameSecondOlderRun, true); + assert.deepEqual(state.calls.map((call) => call[0]), ["create"]); + 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 8e4b2231..3773214a 100644 --- a/.github/workflows/surge-preview-deploy.yaml +++ b/.github/workflows/surge-preview-deploy.yaml @@ -16,17 +16,78 @@ on: 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. - deploy: + # Validate the artifact before its PR number reaches a privileged job. + prepare: if: > github.event_name == 'workflow_run' && ( github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' ) + name: Validate Preview Metadata + runs-on: ubuntu-latest + permissions: + actions: read + pull-requests: read + outputs: + pr_number: ${{ steps.meta.outputs.pr_number }} + action: ${{ steps.meta.outputs.action }} + build_success: ${{ steps.meta.outputs.build_success }} + steps: + - name: Download preview artifact + uses: actions/download-artifact@v4 + with: + name: surge-preview + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Validate metadata + id: meta + env: + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + pr_number=$(jq -er '.pr_number | select(type == "number" and . > 0 and . == floor and . <= 2147483647) | floor' pr-metadata.json) + [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] || exit 1 + action=$(jq -er '.action | select(. == "opened" or . == "synchronize" or . == "reopened" or . == "closed")' pr-metadata.json) + build_success=$(jq -er '.build_success | select(type == "boolean") | tostring' pr-metadata.json) + if [[ "$RUN_CONCLUSION" == success ]]; then expected=true; else expected=false; fi + [[ "$build_success" == "$expected" ]] || exit 1 + printf 'pr_number=%s\naction=%s\nbuild_success=%s\n' "$pr_number" "$action" "$build_success" >> "$GITHUB_OUTPUT" + + - name: Validate PR source + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} + PR_ACTION: ${{ steps.meta.outputs.action }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + ...context.repo, + pull_number: Number(process.env.PR_NUMBER), + }); + const run = context.payload.workflow_run; + const matchesHead = pr.head.sha === run.head_sha && + pr.head.repo?.full_name === run.head_repository?.full_name; + const matchesMerge = process.env.PR_ACTION === 'closed' && pr.merged && + pr.merge_commit_sha === run.head_sha; + const matchesState = process.env.PR_ACTION === 'closed' + ? pr.state === 'closed' + : pr.state === 'open'; + if ((!matchesHead && !matchesMerge) || !matchesState) { + throw new Error('Preview artifact does not match the triggering PR'); + } + + # 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. + deploy: + needs: prepare + if: needs.prepare.result == 'success' name: Deploy Preview runs-on: ubuntu-latest + concurrency: + group: surge-preview-pr-${{ needs.prepare.outputs.pr_number }} + cancel-in-progress: false permissions: + actions: read contents: read pull-requests: write steps: @@ -45,48 +106,68 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Read metadata - 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 + - name: Check preview run order + id: order + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} + with: + script: | + const { isStalePreviewRun } = require('./.github/scripts/preview-status.cjs'); + const stale = await isStalePreviewRun({ + github, + context, + prNumber: Number(process.env.PR_NUMBER), + sourceTime: context.payload.workflow_run.created_at, + sourceRunId: context.payload.workflow_run.id, + }); + core.setOutput('stale', String(stale)); - 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 + if: steps.order.outputs.stale != 'true' && needs.prepare.outputs.action != 'closed' && needs.prepare.outputs.build_success == 'true' + run: npx surge build/ "https://opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Teardown Surge preview id: teardown - if: steps.meta.outputs.action == 'closed' - run: npx surge teardown opentdf-docs-pr-${{ steps.meta.outputs.pr_number }}.surge.sh + if: steps.order.outputs.stale != 'true' && needs.prepare.outputs.action == 'closed' + run: npx surge teardown "opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Update preview status comment - if: always() && steps.meta.outcome == 'success' + if: always() && steps.order.outcome == 'success' && steps.order.outputs.stale != 'true' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} + PR_ACTION: ${{ needs.prepare.outputs.action }} + BUILD_SUCCESS: ${{ needs.prepare.outputs.build_success }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + TEARDOWN_OUTCOME: ${{ steps.teardown.outcome }} with: script: | - const action = '${{ steps.meta.outputs.action }}'; - const buildSucceeded = '${{ steps.meta.outputs.build_success }}' === 'true'; + const action = process.env.PR_ACTION; + const buildSucceeded = process.env.BUILD_SUCCESS === 'true'; const status = action === 'closed' - ? ('${{ steps.teardown.outcome }}' === 'success' ? 'removed' : 'teardown-failed') + ? (process.env.TEARDOWN_OUTCOME === 'success' ? 'removed' : 'teardown-failed') : !buildSucceeded ? 'build-failed' - : '${{ steps.deploy.outcome }}' === 'success' ? 'deployed' : 'deploy-failed'; + : process.env.DEPLOY_OUTCOME === 'success' ? 'deployed' : 'deploy-failed'; const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); await updatePreviewComment({ github, context, - prNumber: Number('${{ steps.meta.outputs.pr_number }}'), + prNumber: Number(process.env.PR_NUMBER), status, runUrl: status === 'build-failed' ? context.payload.workflow_run.html_url : undefined, + sourceTime: context.payload.workflow_run.created_at, + sourceRunId: context.payload.workflow_run.id, }); # Manual dispatch: builds and deploys in a fully trusted context. @@ -95,7 +176,11 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.teardown == false name: Build and Deploy Preview (Manual) runs-on: ubuntu-latest + concurrency: + group: surge-preview-pr-${{ inputs.pr_number }} + cancel-in-progress: false permissions: + actions: read contents: read pull-requests: write steps: @@ -103,56 +188,101 @@ jobs: id: checkout with: fetch-depth: 0 + - name: Validate PR number + id: validate_pr + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 1 + printf 'pr_number=%s\n' "$PR_NUMBER" >> "$GITHUB_OUTPUT" + - name: Check preview run order + id: order + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} + with: + script: | + const { data: run } = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: context.runId, + }); + const { isStalePreviewRun } = require('./.github/scripts/preview-status.cjs'); + const stale = await isStalePreviewRun({ + github, + context, + prNumber: Number(process.env.PR_NUMBER), + sourceTime: run.created_at, + sourceRunId: context.runId, + }); + core.setOutput('stale', String(stale)); + core.setOutput('source_time', run.created_at); - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Install dependencies + if: steps.order.outputs.stale != 'true' run: npm ci - name: Update vendored OpenAPI specs + if: steps.order.outputs.stale != 'true' run: npm run update-vendored-yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build website id: build + if: steps.order.outputs.stale != 'true' run: npm run gen-api-docs-all && npx docusaurus build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Add SPA fallback for Surge + if: steps.order.outputs.stale != 'true' run: cp build/index.html build/200.html - name: Deploy to Surge id: deploy - run: npx surge build/ https://opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh + if: steps.order.outputs.stale != 'true' + run: npx surge build/ "https://opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Update preview status comment - if: always() && steps.checkout.outcome == 'success' + if: always() && steps.order.outcome == 'success' && steps.order.outputs.stale != 'true' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} + BUILD_OUTCOME: ${{ steps.build.outcome }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + SOURCE_TIME: ${{ steps.order.outputs.source_time }} with: script: | - const status = '${{ steps.build.outcome }}' !== 'success' + const status = process.env.BUILD_OUTCOME !== 'success' ? 'build-failed' - : '${{ steps.deploy.outcome }}' === 'success' ? 'deployed' : 'deploy-failed'; + : process.env.DEPLOY_OUTCOME === 'success' ? 'deployed' : 'deploy-failed'; const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); await updatePreviewComment({ github, context, - prNumber: Number('${{ inputs.pr_number }}'), + prNumber: Number(process.env.PR_NUMBER), status, + sourceTime: process.env.SOURCE_TIME, + sourceRunId: context.runId, }); teardown-manual: if: github.event_name == 'workflow_dispatch' && inputs.teardown == true name: Teardown Preview (Manual) runs-on: ubuntu-latest + concurrency: + group: surge-preview-pr-${{ inputs.pr_number }} + cancel-in-progress: false permissions: + actions: read contents: read pull-requests: write steps: @@ -160,27 +290,63 @@ jobs: id: checkout with: persist-credentials: false + - name: Validate PR number + id: validate_pr + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 1 + printf 'pr_number=%s\n' "$PR_NUMBER" >> "$GITHUB_OUTPUT" + - name: Check preview run order + id: order + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} + with: + script: | + const { data: run } = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: context.runId, + }); + const { isStalePreviewRun } = require('./.github/scripts/preview-status.cjs'); + const stale = await isStalePreviewRun({ + github, + context, + prNumber: Number(process.env.PR_NUMBER), + sourceTime: run.created_at, + sourceRunId: context.runId, + }); + core.setOutput('stale', String(stale)); + core.setOutput('source_time', run.created_at); - uses: actions/setup-node@v4 with: node-version: 22 - name: Teardown Surge preview id: teardown - run: npx surge teardown opentdf-docs-pr-${{ inputs.pr_number }}.surge.sh + if: steps.order.outputs.stale != 'true' + run: npx surge teardown "opentdf-docs-pr-${PR_NUMBER}.surge.sh" env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }} SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }} - name: Update preview status comment - if: always() && steps.checkout.outcome == 'success' + if: always() && steps.order.outcome == 'success' && steps.order.outputs.stale != 'true' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.validate_pr.outputs.pr_number }} + TEARDOWN_OUTCOME: ${{ steps.teardown.outcome }} + SOURCE_TIME: ${{ steps.order.outputs.source_time }} with: script: | - const status = '${{ steps.teardown.outcome }}' === 'success' ? 'removed' : 'teardown-failed'; + const status = process.env.TEARDOWN_OUTCOME === 'success' ? 'removed' : 'teardown-failed'; const { updatePreviewComment } = require('./.github/scripts/preview-status.cjs'); await updatePreviewComment({ github, context, - prNumber: Number('${{ inputs.pr_number }}'), + prNumber: Number(process.env.PR_NUMBER), status, + sourceTime: process.env.SOURCE_TIME, + sourceRunId: context.runId, });