Skip to content

pr-report

pr-report #6

Workflow file for this run

# Posts the result of pr-validate back to the pull request.
# Triggered by workflow_run so it runs in the BASE repo with write permission —
# this is what lets us set a commit status and comment on PRs opened from forks,
# without ever exposing a write token to the untrusted build in pr-validate.
name: pr-report
on:
workflow_run:
workflows: ["pr-validate"]
types: [completed]
permissions:
statuses: write
pull-requests: write
actions: read
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Download validation report
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish status and comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const run = context.payload.workflow_run;
// Only report on PR builds.
if (run.event !== 'pull_request') { core.info('Not a pull_request run; skipping.'); return; }
const buildOk = run.conclusion === 'success';
// report.json comes from the UNTRUSTED PR build (a fork can put anything in
// it). It is used ONLY as sanitized display text — never to choose which
// commit/PR to write to. Those targets come from the trusted event/API.
let report = {};
try { report = JSON.parse(fs.readFileSync('report.json', 'utf8')); }
catch (e) { core.warning('No report.json artifact found: ' + e.message); }
const clean = (s, n = 300) =>
String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, n);
const cleanName = (s) =>
(String(s || '').match(/[A-Za-z0-9._-]+/) || ['container'])[0].slice(0, 128);
// Report strings are rendered as list items (never at column 0, so they
// cannot open a heading/table), but still neutralize inline injection from
// a fork-controlled report: backticks (code-span breakout) and angle
// brackets (raw HTML / <details>). Control chars incl. newlines are already
// stripped by clean().
const mdSafe = (s, n = 400) =>
clean(s, n).replace(/`/g, "'").replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Trusted target: the head commit of the run, and the PR that owns it.
const sha = run.head_sha;
let prNumber = run.pull_requests && run.pull_requests[0] && run.pull_requests[0].number;
if (!prNumber && sha) {
try {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner, repo: context.repo.repo, commit_sha: sha,
});
const hit = prs.find(p => p.state === 'open') || prs[0];
if (hit) prNumber = hit.number;
} catch (e) { core.warning('PR lookup failed: ' + e.message); }
}
const software = cleanName(report.software || report.container);
const container = cleanName(report.container);
const version = report.version ? cleanName(report.version) : '';
const tag = cleanName(report.tag);
const errors = (Array.isArray(report.errors) ? report.errors : []).slice(0, 20).map(e => mdSafe(e));
const warnings = (Array.isArray(report.warnings) ? report.warnings : []).slice(0, 20).map(w => mdSafe(w));
// Advisory 'a human must look at this line' items from the Dockerfile risk scan.
const checklist = (Array.isArray(report.review_checklist) ? report.review_checklist : [])
.slice(0, 20).map(x => mdSafe(x));
// Commit status on the trusted head SHA; build conclusion gates pass/fail.
if (sha) {
const state = buildOk && errors.length === 0 ? 'success' : 'failure';
const description = state === 'success'
? 'All checks passed'
: (errors[0] || 'Build or tests failed — see the pr-validate run');
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo, sha,
state, context: 'biocontainers/status/check/' + software,
description: description.slice(0, 140),
target_url: run.html_url,
});
}
if (!prNumber) { core.info('No PR number resolved; skipping comment.'); return; }
const lines = [];
const passed = buildOk && errors.length === 0;
lines.push(passed
? `✅ **BioContainers CI passed** for \`${software}\`.`
: `❌ **BioContainers CI failed** for \`${software}\`.`);
if (report.tag) lines.push(`Image tag: \`biocontainers/${container}:${tag}\``);
if (!buildOk && errors.length === 0)
lines.push('\nThe build or test step failed. See the ' +
`[pr-validate run](${run.html_url}) for details.`);
if (errors.length) lines.push('\n**Errors (must fix):**\n' + errors.map(e => `- ${e}`).join('\n'));
if (warnings.length) lines.push('\n**Warnings (advisory):**\n' + warnings.map(w => `- ${w}`).join('\n'));
// Surface Dockerfile lines a reviewer must consciously sign off on (curl|sh,
// http://, odd base image, …). Advisory — it spotlights, it does not block.
if (checklist.length)
lines.push('\n**🔍 Reviewer checklist — please verify these lines before approving:**\n' +
checklist.map(c => `- [ ] ${c}`).join('\n'));
// Bioconda-style: exact commands to pull & test THIS container locally.
if (container && version) {
const dir = `${container}/${version}`;
lines.push('\n<details><summary>🧪 Build &amp; test this container locally</summary>\n\n' +
'```bash\n' +
`gh pr checkout ${prNumber}\n` +
`docker build -t ${container}:review ${dir}/\n` +
`docker run --rm ${container}:review <cmd> # e.g. ${software} --version\n` +
'```\n\n' +
`Add \`${dir}/test-cmds.txt\` (one command per line) and CI runs it automatically.\n` +
'</details>');
}
const marker = '<!-- biocontainers-ci -->';
const body = marker + '\n' + lines.join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, body,
});
}