Compare commits

...

2 Commits

Author SHA1 Message Date
CanbiZ (MickLesk)
a6e0075591 Update .github/workflows/pr-test-command.yml
Co-authored-by: Sam Heinz <sam@samheinz.com>
2026-08-28 13:22:03 +02:00
MickLesk
9ba61e4cd9 Post the command that tests a ct/ or install/ change
Reviewing a script change meant working out the URL yourself, and the
obvious guess is wrong: curling the branch URL alone gives you the ct/
script from the PR and the install/ script from main, because each script
pins _CS_DEFAULT_URL to main and that pin is what fills
COMMUNITY_SCRIPTS_URL when it is unset. Frequently the install script is
the only thing that changed.

So the comment spells out both lines, per changed app.

Only for scripts already on the core bootstrap. The older one-liner
resolves everything from ProxmoxVE/main and ignores the variable, so a
command built for it would install main and look like it passed --  worse
than no comment. Those are named instead, with what to do about them.

pull_request_target for fork PRs, and nothing from the PR is checked out
or executed: the file list and the bootstrap line come from the API, and
a branch name that is not [A-Za-z0-9._/-]+ stops the run rather than
reaching a fenced code block.
2026-08-28 09:27:20 +02:00

171
.github/workflows/pr-test-command.yml generated vendored Normal file
View File

@@ -0,0 +1,171 @@
name: PR test command
# Posts a ready-to-run test command for reviewers.
# It uses this PRs script branch with the production engine, since both resolve
# independently. Only scripts using community-scripts/core are supported.
#
# 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/**"
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}
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 });
}
if (apps.size === 0) return;
// 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. Each script pins `_CS_DEFAULT_URL` to `main`, and that',
'pin is what fills `COMMUNITY_SCRIPTS_URL` when the variable is unset — 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 (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 });
}