name: PR script test # Builds the ct/ and vm/ scripts a pull request touches on real Proxmox and Incus hosts and # reports the outcome on the PR, as a check run and as one comment kept up to # date. # # A self-hosted runner that executes pull request code as root is only # acceptable if a human decides what runs on it. So it starts on a push only # for trusted PRs: those the push-app-to-main app opens on a branch of this # repository when a script moves over from DevScripts (already reviewed and # tested there; only writers can push to such a branch). Those are built # automatically and approved when every script passes. Everything else waits # for someone with write access to comment `/test` (optionally followed by a # full SHA to pin, and the ct/ paths to build). Only a commit that existed when # the comment was written gets built: a push after it is never covered, not # even one that lands in the seconds before this job runs. # # gate ubuntu-latest who asked, which scripts, which commit # test proxmox-test / build + checks, one matrix job per script, platform # incus-test (pve or incus) and OS # report ubuntu-latest check run + PR comment # # The test job gets a read-only token and never sees a secret: the host it runs # on executes untrusted code and has to be treated as compromised. # # This file is identical in DevScripts and ProxmoxVE; github.repository decides # which URLs get rewritten and which container ID range is used. on: issue_comment: types: [created] # Trusted PRs only, see above. pull_request_target runs this file from main, # never the PR's copy of it. pull_request_target: types: [opened, reopened, synchronize, ready_for_review] paths: - "ct/**" - "install/**" workflow_dispatch: inputs: pr: description: "Pull request number" required: true type: number sha: description: "Pin a commit (full 40-character SHA); empty = the latest, if nothing was pushed since" required: false type: string scripts: description: "Space-separated ct/ or vm/ paths (empty = everything the PR touches)" required: false type: string permissions: {} jobs: gate: if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/test')) || (github.event_name == 'pull_request_target' && !github.event.pull_request.draft && github.event.pull_request.user.login == 'push-app-to-main[bot]' && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest permissions: actions: read # when the run was dispatched, for a /test without SHA checks: write contents: read issues: write pull-requests: write outputs: matrix: ${{ steps.plan.outputs.matrix }} pr: ${{ steps.plan.outputs.pr }} repo: ${{ steps.plan.outputs.repo }} sha: ${{ steps.plan.outputs.sha }} check_id: ${{ steps.plan.outputs.check_id }} actor: ${{ steps.plan.outputs.actor }} not_built: ${{ steps.plan.outputs.not_built }} trusted: ${{ steps.plan.outputs.trusted }} kind: ${{ steps.plan.outputs.kind }} steps: - id: plan uses: actions/github-script@v8 with: script: | const { owner, repo } = context.repo; const MAX = 10; core.setOutput('matrix', '[]'); // Trusted: a PR the push-app-to-main app opened on a branch of this // repository, i.e. a script moving over from DevScripts. const TRUSTED_AUTHOR = 'push-app-to-main[bot]'; const isTrusted = p => !!p && p.user && p.user.login === TRUSTED_AUTHOR && p.user.type === 'Bot' && !!p.head.repo && p.head.repo.full_name === `${owner}/${repo}`; let prNumber, requested = ''; if (context.eventName === 'pull_request_target') { const p = context.payload.pull_request; if (!isTrusted(p)) return; prNumber = p.number; // Exactly the pushed commit; a later push starts its own run. requested = p.head.sha; } else if (context.eventName === 'workflow_dispatch') { prNumber = Number(context.payload.inputs.pr); requested = `${context.payload.inputs.sha || ''} ${context.payload.inputs.scripts || ''}`; } else { prNumber = context.payload.issue.number; const body = context.payload.comment.body.trim(); if (!/^\/test(\s|$)/.test(body)) return; // author_association hides private org members, so ask for the permission itself. const user = context.payload.comment.user.login; const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: user }); if (!['admin', 'maintain', 'write'].includes(perm.permission)) { core.notice(`${user} has ${perm.permission} access; /test needs write`); return; } await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: context.payload.comment.id, content: 'rocket', }); requested = body.split('\n')[0].slice('/test'.length); } // `/test ` in an issue (not a PR) builds that app from the // default branch as it is right now, e.g. to see whether a reported // error reproduces. No PR, so no check run, no review, no approval. const isIssue = context.eventName === 'issue_comment' && !context.payload.issue.pull_request; let pr = null, head, headSha; if (isIssue) { const { data: info } = await github.rest.repos.get({ owner, repo }); const { data: branch } = await github.rest.repos.getBranch({ owner, repo, branch: info.default_branch }); headSha = branch.commit.sha; head = { owner: { login: owner }, name: repo, full_name: `${owner}/${repo}` }; } else { ({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber })); if (pr.state !== 'open' || !pr.head.repo) { core.notice('PR is closed or its fork is gone'); return; } head = pr.head.repo; headSha = pr.head.sha; if (context.eventName === 'pull_request_target' && headSha !== requested) { core.notice('Pushed to again since; the newer push has its own run'); return; } } core.setOutput('kind', isIssue ? 'issue' : 'pr'); core.setOutput('trusted', !isIssue && isTrusted(pr) ? 'true' : 'false'); const MARK = ''; const sticky = async body => { const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber }); // Only our own comment: anyone can paste the marker into theirs. const mine = comments.find(c => c.body.includes(MARK) && c.user.type === 'Bot'); if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); }; // Which commit the approval covers. Reading pr.head.sha alone would // build whatever was pushed between the comment and this job // starting, i.e. code nobody looked at. Two ways to be sure: // // /test that exact commit, and only while it is the head. A // full SHA: the author writes both commits, so a short // prefix is a quick birthday collision away. // /test the latest commit, if nothing reached the fork after // the comment. pushed_at is set by GitHub on every push // and read after the PR, so a push in between shows up. // Branches of this repository belong to people with // write access, who could build anything anyway. const tokens = requested.split(/\s+/).filter(Boolean); const isSha = t => /^[0-9a-f]{7,40}$/.test(t); const approved = isIssue ? [] : tokens.filter(isSha); let entries = isIssue ? [] : tokens.filter(t => !isSha(t)); const refuse = async (why, retry) => { await sticky([MARK, '### Proxmox script test', '', `⚠️ ${why} Nothing was built.`, '', retry].join('\n')); core.notice(why); }; if (isIssue) { // App names (bark-server) or ct/ and vm/ paths; the default branch // is trusted. A name without a ct/ script falls back to vm/ below. entries = tokens.map(t => /^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(t) ? t : /^[A-Za-z0-9._-]+$/.test(t) ? `ct/${t.replace(/\.sh$/, '')}.sh` : t); if (entries.length === 0) { await refuse('In an issue, `/test` needs to know which app.', 'Comment `/test `, for example `/test bark-server` (the name of its `ct/` script).'); return; } } else if (approved.length > 1) { await refuse('`/test` takes at most one commit.', 'Comment `/test` to build the latest commit.'); return; } if (approved.length === 1 && approved[0] !== headSha) { await refuse( approved[0].length !== 40 && headSha.startsWith(approved[0]) ? 'A short SHA is not enough, it has to be all 40 characters.' : `\`${approved[0]}\` is not the head of this PR (any more).`, 'Comment `/test` to build the latest commit.'); return; } if (!isIssue && approved.length === 0 && head.full_name !== `${owner}/${repo}`) { const askedAt = context.eventName === 'workflow_dispatch' ? (await github.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId })).data.created_at : context.payload.comment.created_at; const { data: fork } = await github.rest.repos.get({ owner: head.owner.login, repo: head.name }); if (new Date(fork.pushed_at) >= new Date(askedAt)) { await refuse( `Something was pushed to ${head.full_name} after the \`/test\`, so it is not certain which commit was meant.`, 'Comment `/test` again once you have looked at the latest commit.'); return; } } if (entries.some(e => !/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(e))) { await refuse( isIssue ? 'Only app names (as in `ct/.sh` or `vm/.sh`) can be tested.' : 'Only `ct/.sh` and `vm/.sh` paths can be requested.', isIssue ? 'For example: `/test bark-server` or `/test debian-vm`' : 'For example: `/test ct/bark-server.sh`, or just `/test`.'); return; } // Everything the PR changes that this test cannot build is named in // the comment, so a /test never ends in silence. const notBuilt = []; const code = s => '`' + String(s).replace(/[`\r\n]/g, '') + '`'; if (entries.length === 0) { const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber }); const set = new Set(); for (const f of files) { if (f.status === 'removed') continue; let m; if ((m = f.filename.match(/^install\/([A-Za-z0-9._-]+)-install\.sh$/))) set.add(`ct/${m[1]}.sh`); else if (/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(f.filename)) set.add(f.filename); else if (/^(tools|turnkey)\/.*\.sh$/.test(f.filename)) notBuilt.push(`${code(f.filename)}: only \`ct/\` and \`vm/\` scripts are built`); } entries = [...set].sort(); } const include = []; const fetchScript = async p => { try { const res = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path: p, ref: headSha }); return Buffer.from(res.data.content || '', 'base64').toString('utf8'); } catch (e) { return null; } }; for (let script of entries.slice(0, MAX)) { let text = await fetchScript(script); // /test debian-vm in an issue: there is no ct/debian-vm.sh, but a vm/ one. if (text === null && isIssue && script.startsWith('ct/')) { const vm = `vm/${script.slice(3)}`; if ((text = await fetchScript(vm)) !== null) script = vm; } if (text === null) { notBuilt.push(isIssue ? (script.startsWith('ct/') ? `${code(script.slice(3, -3))}: neither \`ct/\` nor \`vm/\` has it on the default branch` : `${code(script)} does not exist on the default branch`) : `\`${script}\` does not exist in this PR (an \`install/\` script is built through the \`ct/\` script of the same name)`); continue; } const isVm = script.startsWith('vm/'); // The test hosts are amd64; an ARM-only VM script refuses to run there. if (isVm && /^\s*vm_require_arch\s+arm64\b/m.test(text)) { notBuilt.push(`${code(script)}: ARM64 only, the test hosts are amd64`); continue; } // Scripts that ask for the OS get built once per OS. const oses = !isVm && /msg_menu\s+"Choose the container OS"/.test(text) ? ['debian', 'alpine'] : ['']; const name = isVm ? `vm-${script.slice(3, -3)}` : script.slice(3, -3); // Every ct/ script on every platform: the same files run on Proxmox // and, through community-scripts/core's Incus backend, on Incus. // VMs are Proxmox only. const platforms = isVm ? ['pve'] : ['pve', 'incus']; for (const platform of platforms) { for (const os of oses) include.push({ script, os, platform, name: `${os ? `${name}-${os}` : name}-${platform}` }); } } for (const script of entries.slice(MAX)) notBuilt.push(`\`${script}\`: at most ${MAX} scripts per run, name it with \`/test ${script}\``); if (include.length === 0) { await sticky([ MARK, '### Proxmox script test', '', isIssue ? 'ℹ️ Nothing to build:' : 'ℹ️ Nothing to build: this test builds `ct/` and `vm/` scripts (and changed `install/` scripts through their `ct/` script).', '', ...(notBuilt.length ? notBuilt.map(n => `- ${n}`) : ['- this PR changes no `ct/`, `vm/` or `install/` script']), ].join('\n')); core.notice('No ct/ or vm/ script to build'); return; } const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; // A check run belongs to a PR's commit; an issue has none. let checkId = ''; if (!isIssue) { const { data: check } = await github.rest.checks.create({ owner, repo, name: 'Proxmox script test', head_sha: headSha, status: 'in_progress', details_url: runUrl, output: { title: `Building ${include.length}`, summary: include.map(i => `- \`${i.script}\` ${[i.platform, i.os].filter(Boolean).join(' · ')}`).join('\n') }, }); checkId = String(check.id); } core.setOutput('matrix', JSON.stringify(include)); core.setOutput('pr', String(prNumber)); core.setOutput('repo', head.full_name); core.setOutput('sha', headSha); core.setOutput('check_id', checkId); core.setOutput('not_built', JSON.stringify(notBuilt)); // Who asked: the /test commenter, or whoever dispatched the run. core.setOutput('actor', context.actor); await sticky([ MARK, '### Proxmox script test', '', `⏳ Building ${include.length} at ${headSha.slice(0, 7)}${isIssue ? ' (default branch)' : ''} — [watch it live](${runUrl}) (open the \`test\` job)`, '', ...include.map(i => `- \`${i.script}\` (${[i.platform, i.os].filter(Boolean).join(' · ')})`), ...(notBuilt.length ? ['', '**Not built:**', ...notBuilt.map(n => `- ${n}`)] : []), ].join('\n')); test: needs: gate if: needs.gate.outputs.matrix != '[]' && needs.gate.outputs.matrix != '' # Only `proxmox-test`, never `self-hosted`: the runner is registered without # default labels so that no other workflow can be scheduled onto it. runs-on: ${{ matrix.platform == 'incus' && 'incus-test' || 'proxmox-test' }} permissions: contents: read timeout-minutes: 90 # install (45) + update (30) + checks strategy: fail-fast: false matrix: include: ${{ fromJSON(needs.gate.outputs.matrix) }} name: test (${{ matrix.name }}) steps: # The default branch, never the PR: the harness itself must be trusted. - uses: actions/checkout@v5 with: sparse-checkout: .github/workflows/scripts persist-credentials: false - name: Build and check ${{ matrix.script }} env: PR_REPO: ${{ needs.gate.outputs.repo }} PR_SHA: ${{ needs.gate.outputs.sha }} SCRIPT: ${{ matrix.script }} OS: ${{ matrix.os }} PLATFORM: ${{ matrix.platform }} UPSTREAM: ${{ github.repository }} CTID_BASE: ${{ github.repository == 'community-scripts/ProxmoxVE' && '9050' || '9000' }} # Only so release lookups are not rate limited: unauthenticated, all # test containers share the lab's one public IP and 60 calls an hour. # This job's token can read public contents and expires with the job. GH_API_TOKEN: ${{ github.token }} OUT_DIR: ${{ runner.temp }}/result run: bash .github/workflows/scripts/pve-run-script.sh - if: always() uses: actions/upload-artifact@v4 with: name: result-${{ matrix.name }} path: ${{ runner.temp }}/result if-no-files-found: ignore retention-days: 14 report: needs: [gate, test] if: always() && needs.gate.result == 'success' && needs.gate.outputs.matrix != '[]' && needs.gate.outputs.matrix != '' runs-on: ubuntu-latest permissions: actions: read checks: write contents: write # the ci-screenshots branch only; main is protected issues: write pull-requests: write steps: - uses: actions/download-artifact@v5 with: pattern: result-* path: results - uses: actions/github-script@v8 env: MATRIX: ${{ needs.gate.outputs.matrix }} PR: ${{ needs.gate.outputs.pr }} SHA: ${{ needs.gate.outputs.sha }} CHECK_ID: ${{ needs.gate.outputs.check_id }} TEST_RESULT: ${{ needs.test.result }} ACTOR: ${{ needs.gate.outputs.actor }} NOT_BUILT: ${{ needs.gate.outputs.not_built }} TRUSTED: ${{ needs.gate.outputs.trusted }} KIND: ${{ needs.gate.outputs.kind }} with: script: | const fs = require('fs'); const { owner, repo } = context.repo; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; const matrix = JSON.parse(process.env.MATRIX); // download-artifact unpacks a single artifact straight into results/ // and several into results//, so look for every result.json // and match it by what it says it is. const path = require('path'); const found = new Map(); const walk = dir => { if (!fs.existsSync(dir)) return; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name === 'result.json') { try { const r = JSON.parse(fs.readFileSync(p, 'utf8')); // Written on the host that ran the PR's code: anything but a // known status is an error, never something new. if (!['pass', 'fail', 'unsupported', 'error'].includes(r.status)) r.status = 'error'; const log = path.join(dir, 'install.log'); r.install_log = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : ''; const shot = path.join(dir, 'screenshot.png'); r.shot_file = fs.existsSync(shot) ? shot : ''; if (!['updated', 'no-update', 'fail', 'error', ''].includes(r.update_status || '')) r.update_status = ''; // Installed fine, but the update could not be tested (e.g. a // GitHub rate limit): not a pass, and not the PR's fault. if (r.status === 'pass' && r.update_status === 'error') { r.status = 'error'; r.step = `update ${r.update_step}`; } const ulog = path.join(dir, 'update.log'); r.update_log = fs.existsSync(ulog) ? fs.readFileSync(ulog, 'utf8') : ''; const ushot = path.join(dir, 'screenshot-update.png'); r.shot_update_file = fs.existsSync(ushot) ? ushot : ''; // Only the two platforms this workflow knows; an unknown one would // never match a matrix row anyway. if (!['pve', 'incus'].includes(r.platform)) r.platform = 'pve'; found.set(`${r.script}|${r.platform}|${r.os}`, r); } catch (err) { core.warning(`unreadable ${p}: ${err.message}`); } } } }; walk('results'); // A job that died before writing result.json still gets a row. const rows = matrix.map(m => ({ ...(found.get(`${m.script}|${m.platform || 'pve'}|${m.os || 'default'}`) || { script: m.script, os: m.os || 'default', platform: m.platform || 'pve', status: process.env.TEST_RESULT === 'cancelled' ? 'cancelled' : 'error', step: 'no result (runner or job failure)', duration: 0, log_tail: '', install_log: '', shot_file: '', update_status: '', update_log: '', shot_update_file: '', }), name: m.name })); // Where a row ran: "pve", "incus · alpine", or "VM" (Proxmox only). const isVm = r => r.script.startsWith('vm/'); const where = r => isVm(r) ? 'VM' : r.os && r.os !== 'default' ? `${r.platform} · ${r.os}` : r.platform; // Screenshots go to an orphan branch, because a comment can only show // an image that has a URL. One file per PR and script, overwritten on // every run; the link names the commit, so no cache shows an old one. // The PNG comes from the host that ran the PR's code: check it again. const BRANCH = 'ci-screenshots'; const prNum = Number(process.env.PR); const isPng = buf => buf.length > 8 && buf.length < 3000000 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); const ensureBranch = async () => { try { await github.rest.git.getRef({ owner, repo, ref: `heads/${BRANCH}` }); return; } catch (e) { if (e.status !== 404) throw e; } const { data: blob } = await github.rest.git.createBlob({ owner, repo, encoding: 'utf-8', content: 'Screenshots taken by pr-script-test.yml for the PR comments. Not code; never merge this branch.\n' }); const { data: tree } = await github.rest.git.createTree({ owner, repo, tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: blob.sha }] }); const { data: commit } = await github.rest.git.createCommit({ owner, repo, message: 'Start ci-screenshots', tree: tree.sha, parents: [] }); await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${BRANCH}`, sha: commit.sha }); }; const uploadShot = async (r, local, suffix) => { const buf = fs.readFileSync(local); if (!isPng(buf)) { core.warning(`${r.script}: screenshot is not a plausible PNG, skipped`); return ''; } const file = `${process.env.KIND === 'issue' ? 'issue' : 'pr'}-${prNum}/${r.name}${suffix}.png`; for (let attempt = 1; attempt <= 3; attempt++) { try { await ensureBranch(); let sha; try { sha = (await github.rest.repos.getContent({ owner, repo, path: file, ref: BRANCH })).data.sha; } catch (e) { if (e.status !== 404) throw e; } const { data } = await github.rest.repos.createOrUpdateFileContents({ owner, repo, branch: BRANCH, path: file, sha, content: buf.toString('base64'), message: `Screenshot: ${r.script} (${where(r)})${suffix} for #${prNum} at ${process.env.SHA.slice(0, 7)}`, }); return `https://raw.githubusercontent.com/${owner}/${repo}/${data.commit.sha}/${file}`; } catch (e) { // Two reports at once race on the branch head; try again. core.warning(`${r.script}: screenshot upload attempt ${attempt} failed: ${e.message}`); await new Promise(res => setTimeout(res, 2000 * attempt)); } } return ''; }; for (const r of rows) { if (r.shot_file) r.shot_url = await uploadShot(r, r.shot_file, ''); if (r.shot_update_file) r.shot_update_url = await uploadShot(r, r.shot_update_file, '-after-update'); } // unsupported: the script needs a CPU feature the test host lacks // (AVX2 and the like). Not the PR's fault, so not a failure. const icon = { pass: '✅', fail: '❌', error: '⚠️', cancelled: '⏹️', unsupported: '🖥️' }; const dur = s => `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`; // Text from the build lands outside code blocks here: no table breaks, // no code spans, and no @mention of a stranger (zero-width space). const clean = s => String(s || '').replace(/[|\r\n]/g, ' ').replace(/`/g, "'").replace(/@/g, '@​').slice(0, 200); const fence = s => String(s || '').replace(/~~~~/g, '~~~'); // fail: the app broke, the PR's business. error/cancelled: our test // setup did, not the PR's. unsupported: the host's CPU is too old. const count = s => rows.filter(r => r.status === s).length; const passed = count('pass'); const untestable = count('unsupported'); const broken = count('fail'); const setupTrouble = count('error') + count('cancelled'); const allPass = passed === rows.length; const cancelled = rows.every(r => r.status === 'cancelled'); const of = `**${passed}/${rows.length} passed**`; const headline = broken ? `❌ ${of}` : setupTrouble ? `⚠️ ${of}, ${setupTrouble} could not be tested because of a problem with the test setup (not the PR)` : untestable ? `🖥️ ${of}, ${untestable} need${untestable === 1 ? 's' : ''} a newer CPU than the test host has (nothing failed)` : `✅ ${of}`; // Update: the installed app is set back a version and updated with // the PR's update_script. ➖ = it ran but had nothing to update. const updateIcon = { updated: '✅', 'no-update': '➖', fail: '❌', error: '⚠️' }; const detail = r => { if (r.status !== 'pass') return clean(r.step); const ip = /^[0-9.]{7,15}$/.test(r.ip || '') ? r.ip : ''; if (isVm(r) && !r.url) return ip ? `booted, got ${ip}` : 'booted, no DHCP lease (an installer ISO, or no network)'; const reach = r.url ? `${clean(r.url)} → ${r.http_code}` : 'no URL to check'; return r.update_status === 'no-update' ? `${reach}; update: nothing to update${r.update_step ? ` (${clean(r.update_step)})` : ''}` : reach; }; const table = [ '| Script | Platform | Install | Update | Duration | Detail |', '|---|---|---|---|---|---|', ...rows.map(r => `| \`${r.script}\` | ${where(r)} | ${r.update_status ? '✅' : icon[r.status] || '❔'} | ${updateIcon[r.update_status] || '–'} | ${dur(r.duration || 0)} | ${detail(r)} |`), ].join('\n'); // A comment holds 65536 characters. Each script gets an equal share // and a log that does not fit loses its beginning: the end is where // a failure shows. const budget = Math.floor(52000 / rows.length); const tail = (text, max) => { text = fence(text).trimEnd(); if (text.length <= max) return text; const kept = text.slice(text.length - max); const cut = text.slice(0, text.length - max).split('\n').length; return `… ${cut} earlier lines cut, the full log is in the run artifacts\n${kept.slice(kept.indexOf('\n') + 1)}`; }; const details = (summaryLine, text, max) => [ `
${summaryLine}`, '', '~~~~text', tail(text, max), '~~~~', '
', ].join('\n'); const logs = rows.map(r => { const parts = []; const failed = r.status !== 'pass'; // A VM script's own output is all there is: no install log inside. const buildShare = (failed || isVm(r)) && r.log_tail ? Math.min(r.log_tail.length, Math.floor(budget / 3)) : 0; if (buildShare) parts.push(details(`${icon[r.status] || '❔'} ${r.script} (${where(r)}) — build log`, r.log_tail, buildShare)); if (isVm(r)) return parts.join('\n\n'); // The update log gets up to a third of what is left, the install log the rest. const updateShare = r.update_log ? Math.min(r.update_log.length, Math.floor((budget - buildShare) / 3)) : 0; if (r.install_log) { const lines = r.install_log.trimEnd().split('\n').length; parts.push(details(`📄 ${r.script} (${where(r)}) — install log (${lines} lines)`, r.install_log, budget - buildShare - updateShare - 400)); } else if (!buildShare) { parts.push(`${r.script} (${where(r)}): no install log was captured`); } if (updateShare) { const lines = r.update_log.trimEnd().split('\n').length; parts.push(details(`🔄 ${r.script} (${where(r)}) — update log (${lines} lines)`, r.update_log, updateShare)); } return parts.join('\n\n'); }); const shots = rows.filter(r => r.shot_url || r.shot_update_url).map(r => [ `
📸 ${r.script} (${where(r)}) — ${r.url ? clean(r.url) : 'console'}${r.shot_url && r.shot_update_url ? ': after install / after update' : r.shot_update_url ? ': after update' : ''}`, '', [r.shot_url, r.shot_update_url].filter(Boolean) .map((u, i, all) => `Screenshot of ${r.script}`).join(' '), '', '
', ].join('\n')); const summary = [ `${headline} at \`${process.env.SHA.slice(0, 7)}\``, '', table, '', ...shots, '', ...(() => { try { const n = JSON.parse(process.env.NOT_BUILT || '[]'); return n.length ? ['**Not built:**', ...n.map(x => `- ${x}`), ''] : []; } catch (e) { return []; } })(), ...logs, '', `Full logs: [workflow run](${runUrl}) (artifacts \`result-*\`). Comment \`/test${process.env.KIND === 'issue' ? ' ' + [...new Set(rows.map(r => r.script.slice(3, -3)))].join(' ') : ''}\` to build again.`, ].join('\n'); // Issues have no check run (there is no PR commit to attach it to). if (process.env.CHECK_ID) { await github.rest.checks.update({ owner, repo, check_run_id: Number(process.env.CHECK_ID), status: 'completed', // Untestable here is "test it by hand", not a pass: action_required, // because a required check would count neutral as passed. conclusion: cancelled ? 'cancelled' : allPass ? 'success' : broken ? 'failure' : 'action_required', output: { title: `${passed}/${rows.length} passed`, summary: summary.slice(0, 65000) }, }); } // The result pings whoever started the test. A mention added by // editing a comment does not reliably notify, so the old comment // goes and the result is posted as a new one, at the end of the // thread where it is looked for. // Whoever started the test and the PR's author, once each; no bots. const pr = Number(process.env.PR); const isIssue = process.env.KIND === 'issue'; const { data: prInfo } = isIssue ? await github.rest.issues.get({ owner, repo, issue_number: pr }) : await github.rest.pulls.get({ owner, repo, pull_number: pr }); const author = prInfo.user && prInfo.user.type === 'User' ? prInfo.user.login : ''; const login = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; const ping = [...new Set([process.env.ACTOR || '', author])].filter(l => login.test(l)).map(l => `@${l} `).join(''); const MARK = ''; const body = `${MARK}\n### Proxmox script test\n\n${ping}${summary}`.slice(0, 65000); const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr }); for (const c of comments.filter(c => c.body.includes(MARK) && c.user.type === 'Bot')) { await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }); } const { data: posted } = await github.rest.issues.createComment({ owner, repo, issue_number: pr, body }); // An issue has nothing to review or approve: the comment is all. if (isIssue) return; // A real failure requests changes on the tested commit. Untestable // (CPU), runner errors and cancelled runs are not the PR's fault and // leave the review state alone. Our previous "changes requested" is // always dismissed first: a pass lifts it, a new failure replaces it. const REVIEW_MARK = ''; const failedRows = cancelled ? [] : rows.filter(r => r.status === 'fail'); const sha7 = process.env.SHA.slice(0, 7); // Trusted PRs (scripts moving over from DevScripts) are approved when // every script passes -- not on untestable or setup trouble, which // means "not tested", not "good". That approval counts toward the // ruleset's required review; a push dismisses it as stale, and a // later run that does not fully pass withdraws it. // Anyone with write access can push to a migration branch, and the // approval counts toward the required review. So it only covers // what a migration contains: ct/ and install/ scripts. A PR that // also changes anything else (workflows, misc/, ...) is tested but // never approved. let approve = process.env.TRUSTED === 'true' && allPass; let noApproveWhy = ''; if (approve) { const changed = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr }); const other = changed.map(f => f.filename).filter(n => !/^(ct|install)\/[A-Za-z0-9._-]+\.sh$/.test(n)); if (other.length) { approve = false; noApproveWhy = `Not approved automatically: besides \`ct/\` and \`install/\` scripts this PR changes ${other.slice(0, 5).map(n => '`' + n.replace(/[`\r\n]/g, '') + '`').join(', ')}${other.length > 5 ? ', …' : ''}. That needs a human review.`; } } if (noApproveWhy) { await github.rest.issues.updateComment({ owner, repo, comment_id: posted.id, body: `${body}\n\n> ${noApproveWhy}`.slice(0, 65000) }); } try { const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr }); const ours = state => reviews.filter(v => v.user && v.user.type === 'Bot' && v.state === state && (v.body || '').includes(REVIEW_MARK)); for (const v of ours('CHANGES_REQUESTED')) { await github.rest.pulls.dismissReview({ owner, repo, pull_number: pr, review_id: v.id, message: failedRows.length ? 'Replaced by a newer Proxmox script test.' : `Proxmox script test passed at ${sha7}.`, }); } if (!approve && !cancelled) { for (const v of ours('APPROVED')) { await github.rest.pulls.dismissReview({ owner, repo, pull_number: pr, review_id: v.id, message: `Withdrawn: the Proxmox script test at ${sha7} did not fully pass.`, }); } } if (approve && !ours('APPROVED').some(v => v.commit_id === process.env.SHA)) { await github.rest.pulls.createReview({ owner, repo, pull_number: pr, commit_id: process.env.SHA, event: 'APPROVE', body: [ REVIEW_MARK, `**Proxmox script test passed** at \`${sha7}\`: ${rows.map(r => `\`${r.script}\``).join(', ')}.`, '', `Script moved over from DevScripts, built and checked on a real Proxmox host. [Test result](${posted.html_url}).`, ].join('\n'), }); } if (failedRows.length) { await github.rest.pulls.createReview({ owner, repo, pull_number: pr, commit_id: process.env.SHA, event: 'REQUEST_CHANGES', body: [ REVIEW_MARK, `**Proxmox script test failed** at \`${sha7}\`:`, '', ...failedRows.map(r => `- \`${r.script}\` (${where(r)}): ${clean(r.step)}`), '', `Logs and details: [test result](${posted.html_url}). Push a fix, then ask for \`/test\` again.`, ].join('\n'), }); } } catch (e) { core.warning(`Could not update the review state: ${e.message}`); }