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. 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 = ''; 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.', '', '
Useful while testing', '', '`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")"`, '```', '
', ); } 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 }); }