Build Timing Report #1064
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: Build Timing Report | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} | |
| cancel-in-progress: false | |
| jobs: | |
| report: | |
| if: >- | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.conclusion == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Locate pull request and timing artifact | |
| id: timing-context | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const run = context.payload.workflow_run; | |
| const artifactName = 'arklib-build-timing-data'; | |
| let pullNumber = run.pull_requests?.[0]?.number; | |
| if (!pullNumber) { | |
| const pullRequests = await github.paginate( | |
| github.rest.pulls.list, | |
| { owner, repo, state: 'open', per_page: 100 } | |
| ); | |
| const headRepo = run.head_repository?.full_name; | |
| const matchedPullRequest = pullRequests.find(candidate => { | |
| if (candidate.head.sha === run.head_sha) { | |
| return true; | |
| } | |
| return candidate.head.ref === run.head_branch && | |
| (!headRepo || candidate.head.repo?.full_name === headRepo); | |
| }); | |
| pullNumber = matchedPullRequest?.number; | |
| } | |
| if (!pullNumber) { | |
| core.info('CI run is not associated with an open pull request.'); | |
| core.setOutput('should-report', 'false'); | |
| return; | |
| } | |
| const pullRequestResponse = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: pullNumber, | |
| }); | |
| const pullRequest = pullRequestResponse.data; | |
| const artifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner, | |
| repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }); | |
| const artifact = artifactsResponse.data.artifacts.find(candidate => | |
| candidate.name === artifactName && !candidate.expired | |
| ); | |
| if (!artifact) { | |
| core.info(`No unexpired \`${artifactName}\` artifact found for CI run ${run.id}.`); | |
| core.setOutput('should-report', 'false'); | |
| return; | |
| } | |
| core.setOutput('should-report', 'true'); | |
| core.setOutput('artifact-name', artifactName); | |
| core.setOutput('pr-number', String(pullRequest.number)); | |
| core.setOutput('head-ref', pullRequest.head.ref); | |
| core.setOutput('head-sha', pullRequest.head.sha); | |
| core.setOutput('base-ref', pullRequest.base.ref); | |
| core.setOutput('base-sha', pullRequest.base.sha); | |
| core.setOutput('source-subject', run.display_title || ''); | |
| - name: Check out reporting scripts | |
| if: steps.timing-context.outputs.should-report == 'true' | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ steps.timing-context.outputs.base-ref }} | |
| - name: Download current timing artifact | |
| if: steps.timing-context.outputs.should-report == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ steps.timing-context.outputs.artifact-name }} | |
| path: ${{ runner.temp }}/build-timing-current | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ github.token }} | |
| - name: Determine comparison baseline artifact | |
| if: steps.timing-context.outputs.should-report == 'true' | |
| id: timing-baseline | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const artifactName = '${{ steps.timing-context.outputs.artifact-name }}'; | |
| const workflowName = 'CI'; | |
| const currentRunId = Number('${{ github.event.workflow_run.id }}'); | |
| const currentSha = '${{ steps.timing-context.outputs.head-sha }}'; | |
| const pullNumber = Number('${{ steps.timing-context.outputs.pr-number }}'); | |
| const headRef = '${{ steps.timing-context.outputs.head-ref }}'; | |
| const baseRef = '${{ steps.timing-context.outputs.base-ref }}'; | |
| const baseSha = '${{ steps.timing-context.outputs.base-sha }}'; | |
| async function firstRunWithArtifact(runs) { | |
| for (const candidateRun of runs) { | |
| if (candidateRun.id === currentRunId) { | |
| continue; | |
| } | |
| if (candidateRun.name !== workflowName || candidateRun.conclusion !== 'success') { | |
| continue; | |
| } | |
| const artifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner, | |
| repo, | |
| run_id: candidateRun.id, | |
| per_page: 100, | |
| }); | |
| const artifact = artifactsResponse.data.artifacts.find(candidate => | |
| candidate.name === artifactName && !candidate.expired | |
| ); | |
| if (artifact) { | |
| return candidateRun; | |
| } | |
| } | |
| return null; | |
| } | |
| const prRunsResponse = await github.rest.actions.listWorkflowRunsForRepo({ | |
| owner, | |
| repo, | |
| event: 'pull_request', | |
| branch: headRef, | |
| status: 'completed', | |
| per_page: 100, | |
| }); | |
| const previousPrCandidates = prRunsResponse.data.workflow_runs.filter(candidateRun => { | |
| if (candidateRun.head_sha === currentSha) { | |
| return false; | |
| } | |
| const prs = candidateRun.pull_requests || []; | |
| return prs.length === 0 || prs.some(pr => pr.number === pullNumber); | |
| }); | |
| const previousPrRun = await firstRunWithArtifact(previousPrCandidates); | |
| if (previousPrRun) { | |
| core.setOutput('run-id', String(previousPrRun.id)); | |
| core.setOutput('sha', previousPrRun.head_sha); | |
| core.setOutput('label', 'the previous successful PR update'); | |
| return; | |
| } | |
| const baseRunsResponse = await github.rest.actions.listWorkflowRunsForRepo({ | |
| owner, | |
| repo, | |
| event: 'push', | |
| branch: baseRef, | |
| status: 'completed', | |
| per_page: 100, | |
| }); | |
| const baseCandidates = baseRunsResponse.data.workflow_runs.filter(candidateRun => | |
| candidateRun.name === workflowName && candidateRun.conclusion === 'success' | |
| ); | |
| const exactBaseRun = await firstRunWithArtifact( | |
| baseCandidates.filter(candidateRun => candidateRun.head_sha === baseSha) | |
| ); | |
| const baseRun = exactBaseRun ?? await firstRunWithArtifact( | |
| baseCandidates.filter(candidateRun => candidateRun.head_sha !== baseSha) | |
| ); | |
| if (baseRun) { | |
| const isExactBase = baseRun.head_sha === baseSha; | |
| core.setOutput('run-id', String(baseRun.id)); | |
| core.setOutput('sha', baseRun.head_sha); | |
| core.setOutput( | |
| 'label', | |
| isExactBase | |
| ? `current base of \`${baseRef}\`` | |
| : `the latest successful \`${baseRef}\` run` | |
| ); | |
| return; | |
| } | |
| core.info('No comparison baseline artifact found.'); | |
| core.setOutput('run-id', ''); | |
| core.setOutput('sha', ''); | |
| core.setOutput('label', ''); | |
| - name: Download comparison baseline artifact | |
| if: steps.timing-context.outputs.should-report == 'true' && steps.timing-baseline.outputs.run-id != '' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ steps.timing-context.outputs.artifact-name }} | |
| path: ${{ runner.temp }}/build-timing-baseline | |
| run-id: ${{ steps.timing-baseline.outputs.run-id }} | |
| github-token: ${{ github.token }} | |
| - name: Render build timing report | |
| if: steps.timing-context.outputs.should-report == 'true' | |
| env: | |
| BUILD_TIMING_RESULTS: ${{ runner.temp }}/build-timing-current/results.jsonl | |
| BUILD_TIMING_LOG_DIR: ${{ runner.temp }}/build-timing-current | |
| BUILD_TIMING_REPORT: ${{ runner.temp }}/build-timing.md | |
| BUILD_TIMING_COMMENT: ${{ runner.temp }}/build-timing-comment.md | |
| BUILD_TIMING_SOURCE_SHA: ${{ steps.timing-context.outputs.head-sha }} | |
| BUILD_TIMING_SOURCE_SUBJECT: ${{ steps.timing-context.outputs.source-subject }} | |
| BUILD_TIMING_SOURCE_BRANCH: ${{ steps.timing-context.outputs.head-ref }} | |
| BUILD_TIMING_BASELINE_LABEL: ${{ steps.timing-baseline.outputs.label }} | |
| BUILD_TIMING_BASELINE_SHA: ${{ steps.timing-baseline.outputs.sha }} | |
| BUILD_TIMING_TEST_NAME: Validation wrapper | |
| BUILD_TIMING_TEST_COMMAND: ./scripts/validate.sh | |
| run: | | |
| baseline_dir="${{ runner.temp }}/build-timing-baseline" | |
| if [ -f "$baseline_dir/results.jsonl" ]; then | |
| bash scripts/build_timing_report.sh render "$BUILD_TIMING_RESULTS" "$baseline_dir" > "$BUILD_TIMING_REPORT" | |
| else | |
| bash scripts/build_timing_report.sh render "$BUILD_TIMING_RESULTS" > "$BUILD_TIMING_REPORT" | |
| fi | |
| cat "$BUILD_TIMING_REPORT" >> "$GITHUB_STEP_SUMMARY" | |
| { | |
| echo '<!-- arklib-build-timing-report -->' | |
| echo | |
| cat "$BUILD_TIMING_REPORT" | |
| } > "$BUILD_TIMING_COMMENT" | |
| - name: Upsert build timing PR comment | |
| if: steps.timing-context.outputs.should-report == 'true' | |
| uses: actions/github-script@v9 | |
| env: | |
| BUILD_TIMING_COMMENT: ${{ runner.temp }}/build-timing-comment.md | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const marker = '<!-- arklib-build-timing-report -->'; | |
| const body = fs.readFileSync(process.env.BUILD_TIMING_COMMENT, 'utf8'); | |
| const { owner, repo } = context.repo; | |
| const issue_number = Number('${{ steps.timing-context.outputs.pr-number }}'); | |
| const expectedCommentApiStatuses = new Set([403, 404, 410, 422, 429]); | |
| const warnExpectedCommentApiFailure = (operation, error) => { | |
| if (!Number.isInteger(error.status) || | |
| !expectedCommentApiStatuses.has(error.status)) { | |
| throw error; | |
| } | |
| const apiMessage = error.response?.data?.message || error.message || 'unknown error'; | |
| const permissionHint = error.status === 403 | |
| ? ' Check that the report job has `pull-requests: write` and that ' + | |
| 'repository or organization Actions settings permit requested workflow permissions.' | |
| : ''; | |
| core.warning( | |
| `Could not ${operation} the build timing PR comment ` + | |
| `(HTTP ${error.status}: ${apiMessage}).${permissionHint} ` + | |
| 'The report is still available in the job summary above.' | |
| ); | |
| }; | |
| let comments; | |
| try { | |
| comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { owner, repo, issue_number, per_page: 100 } | |
| ); | |
| } catch (error) { | |
| warnExpectedCommentApiFailure('list existing comments for', error); | |
| return; | |
| } | |
| const existing = comments.find(comment => | |
| comment.user?.type === 'Bot' && comment.body?.includes(marker) | |
| ); | |
| if (existing) { | |
| try { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } catch (error) { | |
| warnExpectedCommentApiFailure('update', error); | |
| } | |
| } else { | |
| try { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body, | |
| }); | |
| } catch (error) { | |
| warnExpectedCommentApiFailure('create', error); | |
| } | |
| } |