mirror of
https://github.com/community-scripts/ProxmoxVE
synced 2026-11-13 09:20:39 +00:00
725 lines
42 KiB
YAML
Generated
725 lines
42 KiB
YAML
Generated
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, incus, arm) and OS; arm runs on GitHub's
|
||
# ubuntu-24.04-arm arm64 runner and is information only
|
||
# 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 <app>` 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 = '<!-- pr-script-test -->';
|
||
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 <sha> 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 <app>`, 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/<app>.sh` or `vm/<name>.sh`) can be tested.' : 'Only `ct/<name>.sh` and `vm/<name>.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. ARM (Incus on GitHub's arm64 runner) is
|
||
// information only and never decides the result; a script that
|
||
// says var_arm64=no is not built there. Unset is built with
|
||
// var_arm64=yes, to see whether it would work.
|
||
const arm = (text.match(/^[ \t]*var_arm64="\$\{var_arm64:-(yes|no)\}"/m) || [])[1] || 'unset';
|
||
const platforms = isVm ? ['pve'] : arm === 'no' ? ['pve', 'incus'] : ['pve', 'incus', 'arm'];
|
||
if (!isVm && arm === 'no') notBuilt.push(`${code(script)} on ARM64: the script sets \`var_arm64=no\``);
|
||
for (const platform of platforms) {
|
||
for (const os of oses) include.push({ script, os, platform, arm64: platform === 'arm' ? arm : '', 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` / `incus-test`, never `self-hosted`: the runners are
|
||
# registered without default labels so that no other workflow can be
|
||
# scheduled onto them. ARM runs on GitHub's own arm64 runner, a fresh VM
|
||
# per job (free for public repositories).
|
||
runs-on: ${{ matrix.platform == 'arm' && 'ubuntu-24.04-arm' || matrix.platform == 'incus' && 'incus-test' || 'proxmox-test' }}
|
||
# ARM is information only: a failed or missing ARM job (no runner, no
|
||
# minutes left, ...) never turns the run red. The report leaves it out of
|
||
# the result as well.
|
||
continue-on-error: ${{ matrix.platform == 'arm' }}
|
||
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
|
||
|
||
# The arm64 runner is a plain Ubuntu VM: Incus and the screenshot
|
||
# browser are set up on it for this one job.
|
||
- name: Set up the ARM runner
|
||
if: matrix.platform == 'arm'
|
||
timeout-minutes: 20 # about 2 when healthy; a hang must not hold the report
|
||
run: sudo bash .github/workflows/scripts/arm-runner-setup.sh
|
||
|
||
- 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 }}
|
||
# ARM is the Incus harness on another machine.
|
||
PLATFORM: ${{ matrix.platform == 'arm' && 'incus' || matrix.platform }}
|
||
VAR_ARM64: ${{ matrix.platform == 'arm' && 'yes' || '' }}
|
||
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
|
||
# The self-hosted runners run as root; the GitHub one needs sudo (pct
|
||
# and incus do).
|
||
run: >-
|
||
${{ matrix.platform == 'arm' && 'sudo --preserve-env=PR_REPO,PR_SHA,SCRIPT,OS,PLATFORM,VAR_ARM64,UPSTREAM,CTID_BASE,GH_API_TOKEN,OUT_DIR,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT' || '' }}
|
||
bash .github/workflows/scripts/pve-run-script.sh
|
||
|
||
- if: always() && matrix.platform == 'arm'
|
||
run: sudo chmod -R a+rX "$RUNNER_TEMP/result" 2>/dev/null || true
|
||
|
||
- 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/<name>/, 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 : '';
|
||
// ARM runs the Incus harness on an arm64 machine; result.json
|
||
// says so in arch. Only the platforms this workflow knows: an
|
||
// unknown one would never match a matrix row anyway.
|
||
if (r.arch === 'aarch64') r.platform = 'arm';
|
||
else 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", "arm64", or "VM" (Proxmox only).
|
||
const isVm = r => r.script.startsWith('vm/');
|
||
const isArm = r => r.platform === 'arm';
|
||
const where = r => {
|
||
if (isVm(r)) return 'VM';
|
||
const p = isArm(r) ? 'arm64' : r.platform;
|
||
return r.os && r.os !== 'default' ? `${p} · ${r.os}` : p;
|
||
};
|
||
// ARM is information only: shown, never counted. The result, the
|
||
// check, the review and the approval come from Proxmox and Incus.
|
||
const coreRows = rows.filter(r => !isArm(r));
|
||
const armRows = rows.filter(isArm);
|
||
|
||
// 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 => coreRows.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 === coreRows.length;
|
||
const cancelled = coreRows.every(r => r.status === 'cancelled');
|
||
const of = `**${passed}/${coreRows.length} passed**`;
|
||
const armPassed = armRows.filter(r => r.status === 'pass').length;
|
||
const armLine = armRows.length
|
||
? `ARM64 (information only, not counted): ${armPassed}/${armRows.length} passed${armPassed < armRows.length ? `; failures there do not block this PR` : ''}.`
|
||
: '';
|
||
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) => [
|
||
`<details><summary>${summaryLine}</summary>`, '',
|
||
'~~~~text', tail(text, max), '~~~~', '</details>',
|
||
].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(`<sub>${r.script} (${where(r)}): no install log was captured</sub>`);
|
||
}
|
||
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 => [
|
||
`<details open><summary>📸 ${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' : ''}</summary>`, '',
|
||
[r.shot_url, r.shot_update_url].filter(Boolean)
|
||
.map((u, i, all) => `<img src="${u}" width="${all.length > 1 ? 420 : 640}" alt="Screenshot of ${r.script}">`).join(' '),
|
||
'', '</details>',
|
||
].join('\n'));
|
||
|
||
const summary = [
|
||
`${headline} at \`${process.env.SHA.slice(0, 7)}\``, '',
|
||
table, '',
|
||
...(armLine ? [`<sub>${armLine}</sub>`, ''] : []),
|
||
...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}/${coreRows.length} passed${armRows.length ? ` (+ ARM ${armPassed}/${armRows.length}, not counted)` : ''}`, 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 = '<!-- pr-script-test -->';
|
||
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 = '<!-- pr-script-test-review -->';
|
||
const failedRows = cancelled ? [] : coreRows.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}`);
|
||
}
|