Semantic Review #4553
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: Semantic Review | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| permissions: | |
| actions: read | |
| contents: read | |
| jobs: | |
| pr-quality-summary: | |
| if: github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Verify workflow run and pull request for summary | |
| id: pr | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const workflowId = Number(run.workflow_id || 0); | |
| if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id"); | |
| const { data: workflow } = await github.rest.actions.getWorkflow({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: workflowId, | |
| }); | |
| if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`); | |
| if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`); | |
| if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`); | |
| if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`); | |
| if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch"); | |
| if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch"); | |
| if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha"); | |
| const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : []; | |
| if (runPRs.length > 1) { | |
| throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`); | |
| } | |
| let prNumber = Number(runPRs[0]?.number || 0); | |
| const eventBaseSha = runPRs[0]?.base?.sha || ""; | |
| const eventHeadSha = runPRs[0]?.head?.sha || ""; | |
| const targetHeadSha = run.head_sha; | |
| if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha"); | |
| if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) { | |
| core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head"); | |
| } | |
| const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i; | |
| const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }); | |
| const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name)); | |
| let factsArtifactName = ""; | |
| let artifactBaseSha = ""; | |
| let artifactError = ""; | |
| if (factsArtifacts.length !== 1) { | |
| artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`; | |
| } else { | |
| factsArtifactName = factsArtifacts[0].name; | |
| const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern); | |
| if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) { | |
| artifactError = "facts artifact head sha does not match verified PR head sha"; | |
| factsArtifactName = ""; | |
| } else { | |
| artifactBaseSha = parsedBaseSha; | |
| if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) { | |
| core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base"); | |
| } | |
| } | |
| } | |
| if (!prNumber) { | |
| const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: targetHeadSha, | |
| }); | |
| const candidatePRs = associatedPRs.filter((candidate) => | |
| candidate.base?.repo?.id === context.payload.repository.id && | |
| candidate.head?.sha === targetHeadSha | |
| ); | |
| const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); | |
| if (openCandidatePRs.length > 1) { | |
| throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); | |
| } | |
| if (openCandidatePRs.length === 1) { | |
| prNumber = openCandidatePRs[0].number; | |
| } else if (candidatePRs.length > 0) { | |
| core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| } | |
| if (!prNumber) { | |
| const candidatePRs = await github.paginate(github.rest.pulls.list, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: "all", | |
| per_page: 100, | |
| }).then((prs) => prs.filter((candidate) => | |
| candidate.base?.repo?.id === context.payload.repository.id && | |
| candidate.head?.sha === targetHeadSha | |
| )); | |
| const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); | |
| if (openCandidatePRs.length > 1) { | |
| throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); | |
| } | |
| if (openCandidatePRs.length === 1) { | |
| prNumber = openCandidatePRs[0].number; | |
| } else if (candidatePRs.length > 0) { | |
| core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } else { | |
| throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`); | |
| } | |
| } | |
| if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding"); | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch"); | |
| if (pr.state !== "open") { | |
| core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| if (pr.head.sha !== targetHeadSha) { | |
| core.notice("PR quality summary skipped: workflow_run is stale for this PR head"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha; | |
| if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha"); | |
| if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) { | |
| core.notice("PR quality summary skipped: workflow_run is stale for this PR base"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| if (artifactError) { | |
| core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`); | |
| } | |
| core.setOutput("pr_number", String(prNumber)); | |
| core.setOutput("head_sha", targetHeadSha); | |
| core.setOutput("base_sha", baseSha); | |
| core.setOutput("run_id", String(run.id)); | |
| core.setOutput("facts_artifact_name", factsArtifactName); | |
| core.setOutput("artifact_error", artifactError); | |
| core.setOutput("stale", "false"); | |
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| id: checkout | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| with: | |
| ref: ${{ steps.pr.outputs.base_sha }} | |
| persist-credentials: false | |
| - name: Verify summary facts artifact metadata | |
| id: artifact | |
| if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}"; | |
| const { data } = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }); | |
| const artifacts = data.artifacts.filter(a => a.name === factsArtifactName); | |
| if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`); | |
| const artifact = artifacts[0]; | |
| if (artifact.expired) throw new Error("quality-gate-facts artifact expired"); | |
| if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) { | |
| throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`); | |
| } | |
| if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response"); | |
| core.setOutput("artifact_id", String(artifact.id)); | |
| core.setOutput("artifact_digest", artifact.digest); | |
| - name: Download facts artifact zip | |
| if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| id: download | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}"); | |
| if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id"); | |
| const { data } = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: artifactId, | |
| archive_format: "zip", | |
| }); | |
| const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip"); | |
| fs.writeFileSync(zipPath, Buffer.from(data)); | |
| core.setOutput("zip_path", zipPath); | |
| - name: Verify and extract summary facts artifact | |
| if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }} | |
| env: | |
| SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }} | |
| SEMANTIC_REVIEW_DECISION_OUT: decision.json | |
| SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md | |
| run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}' | |
| - name: Publish PR quality summary | |
| if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| env: | |
| CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} | |
| CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }} | |
| CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | |
| CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }} | |
| CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }} | |
| with: | |
| script: | | |
| const { publish } = require("./scripts/ci-quality-summary-publish.js"); | |
| await publish({ github, context, core }); | |
| semantic-review: | |
| needs: pr-quality-summary | |
| if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| checks: write | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Verify workflow run and pull request | |
| id: pr | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const workflowId = Number(run.workflow_id || 0); | |
| if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id"); | |
| const { data: workflow } = await github.rest.actions.getWorkflow({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: workflowId, | |
| }); | |
| if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`); | |
| if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`); | |
| if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`); | |
| if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`); | |
| if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`); | |
| if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch"); | |
| if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch"); | |
| if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha"); | |
| const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : []; | |
| if (runPRs.length > 1) { | |
| throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`); | |
| } | |
| let prNumber = Number(runPRs[0]?.number || 0); | |
| const eventBaseSha = runPRs[0]?.base?.sha || ""; | |
| const eventHeadSha = runPRs[0]?.head?.sha || ""; | |
| const targetHeadSha = run.head_sha; | |
| if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha"); | |
| if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) { | |
| core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head"); | |
| } | |
| const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i; | |
| const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }); | |
| const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name)); | |
| let factsArtifactName = ""; | |
| let artifactBaseSha = ""; | |
| let artifactError = ""; | |
| if (factsArtifacts.length !== 1) { | |
| artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`; | |
| } else { | |
| factsArtifactName = factsArtifacts[0].name; | |
| const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern); | |
| if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) { | |
| artifactError = "facts artifact head sha does not match verified PR head sha"; | |
| factsArtifactName = ""; | |
| } else { | |
| artifactBaseSha = parsedBaseSha; | |
| if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) { | |
| core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base"); | |
| } | |
| } | |
| } | |
| if (!prNumber) { | |
| const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: targetHeadSha, | |
| }); | |
| const candidatePRs = associatedPRs.filter((candidate) => | |
| candidate.base?.repo?.id === context.payload.repository.id && | |
| candidate.head?.sha === targetHeadSha | |
| ); | |
| const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); | |
| if (openCandidatePRs.length > 1) { | |
| throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); | |
| } | |
| if (openCandidatePRs.length === 1) { | |
| prNumber = openCandidatePRs[0].number; | |
| } else if (candidatePRs.length > 0) { | |
| core.notice("semantic review skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| } | |
| if (!prNumber) { | |
| const candidatePRs = await github.paginate(github.rest.pulls.list, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: "all", | |
| per_page: 100, | |
| }).then((prs) => prs.filter((candidate) => | |
| candidate.base?.repo?.id === context.payload.repository.id && | |
| candidate.head?.sha === targetHeadSha | |
| )); | |
| const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); | |
| if (openCandidatePRs.length > 1) { | |
| throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); | |
| } | |
| if (openCandidatePRs.length === 1) { | |
| prNumber = openCandidatePRs[0].number; | |
| } else if (candidatePRs.length > 0) { | |
| core.notice("semantic review skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } else { | |
| throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`); | |
| } | |
| } | |
| if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding"); | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch"); | |
| if (pr.state !== "open") { | |
| core.notice("semantic review skipped: workflow_run target PR is no longer open"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| if (!pr.head.repo) { | |
| core.notice("semantic review skipped: workflow_run target PR head repository is unavailable"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| if (pr.head.sha !== targetHeadSha) { | |
| core.notice("semantic review skipped: workflow_run is stale for this PR head"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha; | |
| if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha"); | |
| if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) { | |
| core.notice("semantic review skipped: workflow_run is stale for this PR base"); | |
| core.setOutput("stale", "true"); | |
| return; | |
| } | |
| if (artifactError) { | |
| core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`); | |
| } | |
| core.setOutput("pr_number", String(prNumber)); | |
| core.setOutput("head_sha", targetHeadSha); | |
| core.setOutput("base_sha", baseSha); | |
| core.setOutput("head_owner", pr.head.repo.owner.login); | |
| core.setOutput("head_repo", pr.head.repo.name); | |
| core.setOutput("head_repo_id", String(pr.head.repo.id)); | |
| core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false"); | |
| core.setOutput("run_id", String(run.id)); | |
| core.setOutput("facts_artifact_name", factsArtifactName); | |
| core.setOutput("artifact_error", artifactError); | |
| core.setOutput("stale", "false"); | |
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| id: checkout | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| with: | |
| ref: ${{ steps.pr.outputs.base_sha }} | |
| persist-credentials: false | |
| - name: Publish pre-checkout semantic review failure | |
| if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| env: | |
| SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }} | |
| SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} | |
| SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }} | |
| SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | |
| SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }} | |
| with: | |
| script: | | |
| const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true"; | |
| const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0); | |
| const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || ""; | |
| const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || ""; | |
| if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) { | |
| throw new Error("missing verified semantic review target"); | |
| } | |
| const { data: pull } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr, | |
| }); | |
| if (pull.state !== "open") { | |
| core.notice("semantic review skipped infrastructure failure check: PR is no longer open"); | |
| return; | |
| } | |
| if (pull.head.sha !== headSha) { | |
| core.notice("semantic review skipped infrastructure failure check: PR head changed"); | |
| return; | |
| } | |
| if (pull.base.sha !== baseSha) { | |
| core.notice("semantic review skipped infrastructure failure check: PR base changed"); | |
| return; | |
| } | |
| if (pull.base.repo.id !== context.payload.repository.id) { | |
| throw new Error("PR base repo mismatch before infrastructure failure check"); | |
| } | |
| await github.rest.checks.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe", | |
| head_sha: headSha, | |
| status: "completed", | |
| conclusion: runtimeBlockMode ? "failure" : "neutral", | |
| output: { | |
| title: "Semantic review infrastructure failure", | |
| summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.", | |
| }, | |
| }); | |
| - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| with: | |
| go-version-file: go.mod | |
| - name: Verify semantic facts artifact metadata | |
| id: artifact | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}"; | |
| if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) { | |
| throw new Error("missing verified facts artifact binding"); | |
| } | |
| const { data } = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }); | |
| const artifacts = data.artifacts.filter(a => a.name === factsArtifactName); | |
| if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`); | |
| const artifact = artifacts[0]; | |
| if (artifact.expired) throw new Error("quality-gate-facts artifact expired"); | |
| if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) { | |
| throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`); | |
| } | |
| if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response"); | |
| core.setOutput("artifact_id", String(artifact.id)); | |
| core.setOutput("artifact_digest", artifact.digest); | |
| - name: Download facts artifact zip | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| id: download | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}"); | |
| if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id"); | |
| const { data } = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: artifactId, | |
| archive_format: "zip", | |
| }); | |
| const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip"); | |
| fs.writeFileSync(zipPath, Buffer.from(data)); | |
| core.setOutput("zip_path", zipPath); | |
| - name: Verify and extract semantic facts artifact | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| env: | |
| SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }} | |
| SEMANTIC_REVIEW_DECISION_OUT: decision.json | |
| SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md | |
| run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}' | |
| - name: Download PR semantic waiver config | |
| id: waiver_config | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| env: | |
| SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} | |
| SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }} | |
| SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }} | |
| SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }} | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || ""; | |
| if (!/^[a-f0-9]{40}$/i.test(headSha)) { | |
| throw new Error("missing verified semantic review target"); | |
| } | |
| const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || ""; | |
| const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || ""; | |
| if (!headOwner || !headRepo) { | |
| throw new Error("missing verified semantic review head repository"); | |
| } | |
| const waiverPath = "internal/qualitygate/config/semantic/waivers.txt"; | |
| const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt"); | |
| const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true"; | |
| if (!headIsBaseRepo) { | |
| core.notice("fork PR semantic waiver config is ignored"); | |
| core.setOutput("path", ""); | |
| return; | |
| } | |
| let content = ""; | |
| try { | |
| const { data } = await github.rest.repos.getContent({ | |
| owner: headOwner, | |
| repo: headRepo, | |
| path: waiverPath, | |
| ref: headSha, | |
| }); | |
| if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") { | |
| throw new Error(`${waiverPath} is not a base64 file at PR head`); | |
| } | |
| if (data.size > 256 * 1024) { | |
| throw new Error(`${waiverPath} is too large: ${data.size} bytes`); | |
| } | |
| content = Buffer.from(data.content, "base64").toString("utf8"); | |
| } catch (err) { | |
| if (err.status !== 404) { | |
| throw err; | |
| } | |
| } | |
| fs.writeFileSync(outPath, content); | |
| core.setOutput("path", outPath); | |
| - name: Run semantic review | |
| id: semantic | |
| if: ${{ steps.pr.outputs.stale != 'true' }} | |
| env: | |
| ARK_API_KEY: ${{ secrets.ARK_API_KEY }} | |
| ARK_BASE_URL: ${{ vars.ARK_BASE_URL }} | |
| ARK_MODEL: ${{ vars.ARK_MODEL }} | |
| ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }} | |
| SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }} | |
| run: | | |
| args=( | |
| --repo . | |
| --facts facts.json | |
| --decision-out decision.json | |
| --markdown-out semantic-review.md | |
| ) | |
| if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then | |
| args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}') | |
| fi | |
| if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then | |
| args+=(--block) | |
| fi | |
| go run ./internal/qualitygate/cmd/semantic-review "${args[@]}" | |
| - name: Publish semantic review | |
| if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }} | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| env: | |
| SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }} | |
| SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} | |
| SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }} | |
| SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | |
| SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }} | |
| with: | |
| script: | | |
| const { publish } = require("./scripts/semantic-review-publish.js"); | |
| await publish({ github, context, core }); |