mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-09-30 22:19:41 +00:00
A fix to tools/addon/all-templates.sh got no comment because only ct/ and install/ were recognised. Each script now also says where it runs, read from what it calls: the Proxmox VE host, inside a guest, or PBS/PMG/PDM.
228 lines
9.9 KiB
YAML
Generated
228 lines
9.9 KiB
YAML
Generated
name: PR test command
|
||
|
||
# Posts a ready-to-run test command for reviewers.
|
||
# It uses this PR’s script branch with the production engine, since both resolve
|
||
# independently. ct/ scripts need community-scripts/core; vm/, tools/ and
|
||
# turnkey/ scripts run straight from the branch.
|
||
#
|
||
# pull_request_target allows comments on fork PRs. No PR code is checked out or
|
||
# executed; API inputs are validated and the comment is assembled in JavaScript.
|
||
|
||
on:
|
||
pull_request_target:
|
||
branches: ["main"]
|
||
types: [opened, synchronize, reopened]
|
||
paths:
|
||
- "ct/**"
|
||
- "install/**"
|
||
- "vm/**"
|
||
- "tools/**"
|
||
- "turnkey/**"
|
||
|
||
jobs:
|
||
comment:
|
||
if: github.repository == 'community-scripts/ProxmoxVE'
|
||
runs-on: self-hosted
|
||
permissions:
|
||
pull-requests: write
|
||
contents: read
|
||
steps:
|
||
- uses: actions/github-script@v9
|
||
with:
|
||
script: |
|
||
const MARKER = '<!-- pr-test-command -->';
|
||
const MAX_APPS = 10;
|
||
|
||
const pr = context.payload.pull_request;
|
||
const head = pr.head.repo; // null when the fork is gone
|
||
if (!head) return;
|
||
|
||
const owner = context.repo.owner;
|
||
const repo = context.repo.repo;
|
||
|
||
// Git allows backticks in a ref name, and this one ends up inside a
|
||
// fenced block. Anything outside the ordinary set is not worth
|
||
// rendering, so bail rather than escape.
|
||
const ref = pr.head.ref;
|
||
if (!/^[A-Za-z0-9._\/-]+$/.test(ref)) return;
|
||
const base = `https://raw.githubusercontent.com/${head.full_name}/${ref}`;
|
||
|
||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||
owner, repo, pull_number: pr.number, per_page: 100,
|
||
});
|
||
|
||
// ct/foo.sh and install/foo-install.sh are the same app. A removed
|
||
// file has nothing left to run.
|
||
const apps = new Map(); // slug -> {ct, install}
|
||
const others = [];
|
||
for (const f of files) {
|
||
if (f.status === 'removed') continue;
|
||
let m = f.filename.match(/^ct\/([a-z0-9][a-z0-9._-]*)\.sh$/);
|
||
if (m) { apps.set(m[1], { ...apps.get(m[1]), ct: true }); continue; }
|
||
m = f.filename.match(/^install\/([a-z0-9][a-z0-9._-]*)-install\.sh$/);
|
||
if (m) { apps.set(m[1], { ...apps.get(m[1]), install: true }); continue; }
|
||
if (/^(vm|tools|turnkey)\/[A-Za-z0-9._\/-]+\.sh$/.test(f.filename)) others.push(f.filename);
|
||
}
|
||
if (apps.size === 0 && others.length === 0) return;
|
||
others.sort();
|
||
|
||
// Where a tool runs is read from the script itself; it is never run.
|
||
const HOST = /(^|[\s;&|(`$])(pct|qm|pveam|pvesm|pvesh|pveversion)\s/m;
|
||
async function runsOn(path) {
|
||
if (path.startsWith('vm/')) return 'pve';
|
||
const appliance = (path.split('/').pop().match(/(?:^|-)(pbs|pmg|pdm)(?=[0-9-]|\.sh$)/) || [])[1];
|
||
if (path.startsWith('tools/pve/')) return appliance || 'pve';
|
||
let text = '';
|
||
try {
|
||
const res = await github.rest.repos.getContent({
|
||
owner: head.owner.login, repo: head.name, path, ref: pr.head.sha,
|
||
});
|
||
text = Buffer.from(res.data.content || '', 'base64').toString('utf8');
|
||
} catch (e) {}
|
||
if (HOST.test(text.replace(/^\s*#.*$/gm, '').replace(/command -v \S+/g, ''))) return 'pve';
|
||
if (appliance) return appliance;
|
||
return path.startsWith('tools/addon/') ? 'guest' : 'unknown';
|
||
}
|
||
const WHERE = {
|
||
pve: 'in the Proxmox VE shell',
|
||
guest: 'inside the LXC or VM that should get it',
|
||
pbs: 'in the Proxmox Backup Server shell',
|
||
pmg: 'in the Proxmox Mail Gateway shell',
|
||
pdm: 'in the Proxmox Datacenter Manager shell',
|
||
unknown: 'wherever the script is meant to run',
|
||
};
|
||
|
||
// Read the ct script at the PR head to see which engine it loads.
|
||
// Read only -- it is never sourced or run.
|
||
async function bootstrapOf(slug) {
|
||
try {
|
||
const res = await github.rest.repos.getContent({
|
||
owner: head.owner.login, repo: head.name,
|
||
path: `ct/${slug}.sh`, ref: pr.head.sha,
|
||
});
|
||
if (!res.data.content) return 'unknown';
|
||
const text = Buffer.from(res.data.content, 'base64').toString('utf8');
|
||
const firstLines = text.split('\n').slice(0, 12).join('\n');
|
||
return /_cs_boot=/.test(firstLines) ? 'core' : 'legacy';
|
||
} catch (e) {
|
||
return e.status === 404 ? 'missing' : 'unknown';
|
||
}
|
||
}
|
||
|
||
const ready = [], legacy = [], missing = [];
|
||
for (const slug of [...apps.keys()].sort()) {
|
||
const kind = await bootstrapOf(slug);
|
||
if (kind === 'core') ready.push(slug);
|
||
else if (kind === 'legacy') legacy.push(slug);
|
||
else if (kind === 'missing') missing.push(slug);
|
||
}
|
||
|
||
const lines = [MARKER];
|
||
|
||
if (ready.length > 0) {
|
||
const shown = ready.slice(0, MAX_APPS);
|
||
lines.push(
|
||
'### Try this branch',
|
||
'',
|
||
'The engine and the scripts resolve independently, so this runs the changed',
|
||
'`ct/` and `install/` scripts against the **production** engine:',
|
||
'',
|
||
);
|
||
for (const slug of shown) {
|
||
lines.push(
|
||
'```bash',
|
||
`export COMMUNITY_SCRIPTS_URL=${base}`,
|
||
`bash -c "$(curl -fsSL "$COMMUNITY_SCRIPTS_URL/ct/${slug}.sh")"`,
|
||
'```',
|
||
'',
|
||
);
|
||
}
|
||
if (ready.length > shown.length) {
|
||
lines.push(
|
||
`${ready.length - shown.length} more script(s) changed; same command, different slug.`,
|
||
'',
|
||
);
|
||
}
|
||
lines.push(
|
||
'Both lines are needed. Without `COMMUNITY_SCRIPTS_URL` the engine falls back to',
|
||
'`main`, so curling the branch URL on its own gives you the `ct/` script from',
|
||
'this PR and the `install/` script from `main`. Frequently the one you meant to test.',
|
||
'',
|
||
'The same command works on an Incus host: the engine detects the platform and',
|
||
'loads the matching backend, while the scripts still come from this branch.',
|
||
'',
|
||
'<details><summary>Useful while testing</summary>',
|
||
'',
|
||
'`dev_mode=net` logs every fetch with status and URL, which is the quickest way',
|
||
'to confirm the branch is really being used. `dev_mode=keep` stops a failed',
|
||
'build from deleting the container along with the evidence.',
|
||
'',
|
||
'```bash',
|
||
`export COMMUNITY_SCRIPTS_URL=${base}`,
|
||
`dev_mode=net,keep bash -c "$(curl -fsSL "$COMMUNITY_SCRIPTS_URL/ct/${shown[0]}.sh")"`,
|
||
'```',
|
||
'</details>',
|
||
);
|
||
}
|
||
|
||
if (others.length > 0) {
|
||
const shown = others.slice(0, MAX_APPS);
|
||
lines.push(...(ready.length > 0 ? ['', '---', ''] : ['### Try this branch', '']));
|
||
for (const path of shown) {
|
||
lines.push(
|
||
`\`${path}\`, run ${WHERE[await runsOn(path)]}:`,
|
||
'```bash',
|
||
`bash -c "$(curl -fsSL "${base}/${path}")"`,
|
||
'```',
|
||
'',
|
||
);
|
||
}
|
||
if (others.length > shown.length) {
|
||
lines.push(
|
||
`${others.length - shown.length} more script(s) changed; same command, different path.`,
|
||
'',
|
||
);
|
||
}
|
||
lines.push(
|
||
'Only the script itself comes from this branch. Whatever it loads, the engine',
|
||
'or the `misc/` helpers, still comes from `main`.',
|
||
);
|
||
}
|
||
|
||
if (legacy.length > 0) {
|
||
lines.push(
|
||
'',
|
||
ready.length > 0 ? '---' : '### Not testable this way yet',
|
||
'',
|
||
`\`${legacy.join('`, `')}\` still uses the older one-liner bootstrap, which`,
|
||
'resolves everything from `ProxmoxVE/main` and ignores `COMMUNITY_SCRIPTS_URL`.',
|
||
'There is no way to point it at this branch — test it from a checkout on the',
|
||
'host instead, or migrate the script to the `_cs_boot` bootstrap first.',
|
||
);
|
||
}
|
||
|
||
if (missing.length > 0) {
|
||
lines.push(
|
||
'',
|
||
`No \`ct/\` script found for \`${missing.join('`, `')}\`, so there is nothing to`,
|
||
'run. If the install script was renamed, its `ct/` counterpart needs the same',
|
||
'name.',
|
||
);
|
||
}
|
||
|
||
if (lines.length === 1) return; // marker only, nothing worth saying
|
||
const body = lines.join('\n');
|
||
|
||
// Update in place rather than posting again on every push.
|
||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||
owner, repo, issue_number: pr.number, per_page: 100,
|
||
});
|
||
const mine = comments.find(c => c.body.includes(MARKER));
|
||
if (mine) {
|
||
if (mine.body !== body) {
|
||
await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
|
||
}
|
||
} else {
|
||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||
}
|