report-e2e.yml 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. name: Report E2E
  2. on:
  3. workflow_run:
  4. workflows: ["CI"]
  5. types:
  6. - completed
  7. permissions:
  8. contents: read
  9. jobs:
  10. report-e2e:
  11. name: Comment on PR
  12. runs-on: ubuntu-latest
  13. # This job needs "pull-requests: write" to comment on the pull request and
  14. # "contents: write" to push the failure screenshots to the "e2e-screenshots"
  15. # branch so they can be embedded in the comment. We can't checkout the PR
  16. # code in this workflow, and we never execute anything from the artifacts.
  17. # Reference:
  18. # https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
  19. permissions:
  20. contents: write
  21. pull-requests: write
  22. if: github.event.workflow_run.event == 'pull_request' &&
  23. (github.event.workflow_run.conclusion == 'success' ||
  24. github.event.workflow_run.conclusion == 'failure')
  25. steps:
  26. # Using actions/download-artifact doesn't work here
  27. # https://github.com/actions/download-artifact/issues/60
  28. - name: Download artifacts
  29. uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
  30. id: download
  31. with:
  32. script: |
  33. const fs = require('fs/promises');
  34. const runId = context.payload.workflow_run.id;
  35. const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
  36. owner: context.repo.owner,
  37. repo: context.repo.repo,
  38. run_id: runId,
  39. });
  40. await fs.mkdir('screenshots', { recursive: true });
  41. for (const artifact of artifacts) {
  42. if (!artifact.name.startsWith('Output screenshots-') || artifact.expired) continue;
  43. const download = await github.rest.actions.downloadArtifact({
  44. owner: context.repo.owner,
  45. repo: context.repo.repo,
  46. artifact_id: artifact.id,
  47. archive_format: 'zip',
  48. });
  49. await fs.writeFile(`${artifact.id}.zip`, Buffer.from(download.data));
  50. // -j flattens paths so a hostile archive can't write outside the directory
  51. await exec.exec('unzip', ['-q', '-j', '-o', `${artifact.id}.zip`, '-d', 'screenshots']);
  52. }
  53. // The e2e test only writes these files for examples that failed.
  54. // Only strictly named jpgs are accepted; everything else is ignored.
  55. const pattern = /^([a-zA-Z0-9_-]+)-(actual|expected|diff)\.jpg$/;
  56. const examples = new Map();
  57. for (const file of await fs.readdir('screenshots')) {
  58. const match = pattern.exec(file);
  59. if (match === null) continue;
  60. if (!examples.has(match[1])) examples.set(match[1], {});
  61. examples.get(match[1])[match[2]] = file;
  62. }
  63. const names = [...examples.keys()].sort();
  64. // Cap the embedded images so a broken build failing hundreds of
  65. // examples doesn't blow up the comment. The rest are listed by name.
  66. const MAX_EMBEDDED = 10;
  67. const embedded = names.slice(0, MAX_EMBEDDED);
  68. await fs.mkdir(`payload/${runId}`, { recursive: true });
  69. for (const name of embedded) {
  70. for (const file of Object.values(examples.get(name))) {
  71. await fs.copyFile(`screenshots/${file}`, `payload/${runId}/${file}`);
  72. }
  73. }
  74. const report = {
  75. total: names.length,
  76. embedded: embedded.map((name) => ({ name, ...examples.get(name) })),
  77. omitted: names.slice(MAX_EMBEDDED),
  78. };
  79. await fs.writeFile('report.json', JSON.stringify(report));
  80. core.setOutput('failed', names.length);
  81. # Each run force-pushes a fresh orphan commit, so the branch stays a
  82. # single small commit. The comment links images by commit sha, which
  83. # keeps working until GitHub garbage-collects the unreachable commit.
  84. - name: Publish images
  85. id: publish
  86. if: steps.download.outputs.failed != '0'
  87. env:
  88. GITHUB_TOKEN: ${{ github.token }}
  89. run: |
  90. cd payload
  91. git init --quiet --initial-branch=e2e-screenshots
  92. git config user.name "github-actions[bot]"
  93. git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
  94. git add .
  95. git commit --quiet -m "E2E screenshots for run ${{ github.event.workflow_run.id }}"
  96. git push --quiet --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:refs/heads/e2e-screenshots
  97. echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
  98. - name: Comment on PR
  99. uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
  100. env:
  101. IMAGES_SHA: ${{ steps.publish.outputs.sha }}
  102. with:
  103. script: |
  104. const fs = require('fs/promises');
  105. const run = context.payload.workflow_run;
  106. const { owner, repo } = context.repo;
  107. // workflow_run.pull_requests is empty for PRs from forks, so fall
  108. // back to looking the PR up by its head. The head sha check also
  109. // skips stale runs where the PR has received a newer push.
  110. let pr = run.pull_requests.find((p) => p.head.sha === run.head_sha);
  111. if (pr === undefined && run.head_repository !== null) {
  112. const pulls = await github.rest.pulls.list({
  113. owner,
  114. repo,
  115. state: 'open',
  116. head: `${run.head_repository.owner.login}:${run.head_branch}`,
  117. per_page: 100,
  118. });
  119. pr = pulls.data.find((p) => p.head.sha === run.head_sha);
  120. }
  121. if (pr === undefined) {
  122. core.info('No open PR matches this run, skipping.');
  123. return;
  124. }
  125. const report = JSON.parse(await fs.readFile('report.json', 'utf8'));
  126. const marker = '<!-- e2e-report -->';
  127. const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${run.id}`;
  128. const comments = await github.paginate(github.rest.issues.listComments, {
  129. owner,
  130. repo,
  131. issue_number: pr.number,
  132. per_page: 100,
  133. });
  134. const previous = comments.find((comment) => comment.body && comment.body.includes(marker));
  135. let body;
  136. if (report.total > 0) {
  137. const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${process.env.IMAGES_SHA}/${run.id}`;
  138. const cell = (file) => file === undefined ? '' :
  139. `<a href="${raw}/${file}"><img src="${raw}/${file}" width="200"></a>`;
  140. const rows = report.embedded.map((example) =>
  141. `| \`${example.name}\` | ${cell(example.expected)} | ${cell(example.actual)} | ${cell(example.diff)} |`
  142. );
  143. const lines = [
  144. marker,
  145. '### 🖼️ E2E screenshot tests',
  146. '',
  147. `❌ **${report.total}** example(s) failed ([full artifacts](${runUrl})).`,
  148. '',
  149. '| Example | Expected | Actual | Diff |',
  150. '|:--|:-:|:-:|:-:|',
  151. ...rows,
  152. ];
  153. if (report.omitted.length > 0) {
  154. lines.push('', `…and ${report.omitted.length} more: ${report.omitted.map((name) => `\`${name}\``).join(', ')}`);
  155. }
  156. body = lines.join('\n');
  157. } else {
  158. // Only turn a previous failure report green; don't comment on
  159. // pull requests that never failed.
  160. if (previous === undefined) return;
  161. // The run can also fail on lint or unit tests. Only report
  162. // success if the e2e jobs themselves all passed.
  163. const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
  164. owner,
  165. repo,
  166. run_id: run.id,
  167. filter: 'latest',
  168. });
  169. const e2eJobs = jobs.filter((job) => job.name.startsWith('E2E testing'));
  170. if (e2eJobs.length === 0 || !e2eJobs.every((job) => job.conclusion === 'success')) return;
  171. body = `${marker}\n### 🖼️ E2E screenshot tests\n\n✅ All examples render correctly again ([run](${runUrl})).`;
  172. }
  173. if (previous === undefined) {
  174. await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
  175. } else {
  176. await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
  177. }
粤ICP备19079148号