Compare commits

..
Author SHA1 Message Date
MickLesk d3956d1c1c Bump workflow actions to their current majors
The older majors ran on Node 20, which GitHub now forces onto Node 24
with a deprecation warning on every run.
2026-11-12 14:32:10 +01:00
Rögl-Brunner Michel 8144ce5d2a pr-script-test: no container noise in the journal note, real disk use on Incus 2026-11-12 12:31:25 +01:00
Rögl-Brunner Michel 548a6d92ec pr-script-test: upgrade test only in ProxmoxVE, and only for a changed update_script 2026-11-12 12:04:56 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 52b9a5df26 Update CHANGELOG.md (#18632)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 10:58:35 +00:00
CanbiZ (MickLesk) c870b5620d pocketbase-bot: apply website metadata requests from Accept/Decline boxes (#18631)
The website now posts each requested change as its own comment with the
command and two checkboxes. Ticking one edits the comment: Accept runs
the command as if a maintainer had typed it, Decline records the
refusal, and the issue closes once every change is decided. Only users
with write access can decide. app_vars is checked field by field before
it is saved.
2026-11-12 11:58:10 +01:00
Rögl-Brunner Michel b6123811be pr-script-test: upgrade path, second update, stability and resources 2026-11-12 11:42:26 +01:00
Rögl-Brunner Michel 3e9ce91252 pr-script-test: test the update of apps deployed from a branch too 2026-11-12 11:29:12 +01:00
Rögl-Brunner Michel 03ea5280e6 CI hosts: single-use, reset before every job 2026-11-12 11:11:19 +01:00
10 changed files with 731 additions and 52 deletions

No files matched your search

+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
timeout-minutes: 120
steps:
# The harness from the default branch, as in pr-script-test.yml.
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
sparse-checkout: .github/workflows/scripts
persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
} >>"$GITHUB_STEP_SUMMARY"
- if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: result-arm
path: ${{ runner.temp }}/result
+149 -4
View File
@@ -2,7 +2,7 @@ name: PocketBase Bot
on:
issue_comment:
types: [created]
types: [created, edited]
permissions:
issues: write
@@ -25,6 +25,9 @@ jobs:
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
EVENT_ACTION: ${{ github.event.action }}
SENDER: ${{ github.event.sender.login }}
PREVIOUS_BODY: ${{ github.event.changes.body.from }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || 'false' }}
REPO_OWNER: ${{ github.repository_owner }}
@@ -87,7 +90,7 @@ jobs:
const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
const isPrComment = process.env.IS_PR_COMMENT === 'true';
const commentId = parseInt(process.env.COMMENT_ID, 10);
const actor = process.env.ACTOR;
let actor = process.env.ACTOR;
function ghRequest(path, method, body) {
const headers = {
@@ -278,7 +281,65 @@ jobs:
// Accept /pocketbase at the start of ANY line (leading whitespace ok),
// so the command works even when preceded by other text. Mid-sentence
// mentions and blockquoted ("> ...") examples are ignored.
const commentBody = process.env.COMMENT_BODY || '';
let commentBody = process.env.COMMENT_BODY || '';
// ── Metadata requests from the website ─────────────────────────────
// One comment per requested change, each with Accept/Decline boxes.
// Ticking one edits the comment; that edit is the only event handled.
const REQUEST_MARKER = '<!-- pb-request -->';
const DONE_MARKER = '<!-- pb-request:done -->';
const isRequest = commentBody.includes(REQUEST_MARKER);
let decidedBy = null;
if (process.env.EVENT_ACTION === 'edited' && !isRequest) process.exit(0);
if (isRequest) {
if (process.env.EVENT_ACTION !== 'edited' || commentBody.includes(DONE_MARKER)) process.exit(0);
const ticked = (body, word) =>
new RegExp('^[ \\t]*[-*] \\[[xX]\\] ' + word + '[ \\t]*\\r?$', 'm').test(body);
const previous = process.env.PREVIOUS_BODY || '';
const accept = ticked(commentBody, 'Accept') && !ticked(previous, 'Accept');
const decline = ticked(commentBody, 'Decline') && !ticked(previous, 'Decline');
if (accept === decline) process.exit(0);
const sender = process.env.SENDER;
const permRes = await ghRequest(
'/repos/' + owner + '/' + repo + '/collaborators/' + encodeURIComponent(sender) + '/permission'
);
const level = permRes.ok ? JSON.parse(permRes.body).permission : 'none';
if (level !== 'admin' && level !== 'write') {
await postComment(
'❌ **PocketBase Bot**: @' + sender + ' is not authorized to decide metadata requests.\n' +
'Only maintainers with write access can accept or decline them.'
);
process.exit(0);
}
const heading = (commentBody.match(/^### (.+)$/m) || [])[1] || 'this change';
const command = commentBody.match(/<!-- pb-command -->\r?\n([\s\S]*?)\r?\n<!-- \/pb-command -->/);
const decided = commentBody
.replace(/^[ \t]*[-*] \[[ xX]\] (Accept|Decline)[ \t]*\r?\n?/gm, '')
.replace(
REQUEST_MARKER,
'**' + (accept ? '✅ Accepted' : '❌ Declined') + '** by @' + sender + '\n\n' +
REQUEST_MARKER + '\n' + DONE_MARKER
);
const markRes = await ghRequest(
'/repos/' + owner + '/' + repo + '/issues/comments/' + commentId, 'PATCH', { body: decided }
);
if (!markRes.ok) console.warn('Could not mark the request as decided:', markRes.body);
if (decline) {
await postComment('❌ **PocketBase Bot**: **' + heading + '** declined by @' + sender + '.');
process.exit(0);
}
if (!command) {
await postComment('❌ **PocketBase Bot**: no command found in the request for **' + heading + '**.');
process.exit(0);
}
commentBody = command[1];
decidedBy = sender;
actor = sender;
}
const cmdLine = commentBody
.split('\n')
.map(l => l.trim())
@@ -292,7 +353,7 @@ jobs:
// ── Permission check ───────────────────────────────────────────────
const association = process.env.ACTOR_ASSOCIATION;
if (association !== 'OWNER' && association !== 'MEMBER') {
if (!decidedBy && association !== 'OWNER' && association !== 'MEMBER') {
await addReaction('-1');
await postComment(
'❌ **PocketBase Bot**: @' + actor + ' is not authorized to use this command.\n' +
@@ -957,6 +1018,35 @@ jobs:
);
process.exit(0);
}
if (fieldName === 'app_vars') {
const TYPES = ['text', 'password', 'number', 'boolean', 'select'];
const problems = [];
if (!Array.isArray(parsedJson)) problems.push('must be a JSON array');
else parsedJson.forEach(function (v, i) {
const at = 'entry ' + (i + 1) + ': ';
if (!v || typeof v !== 'object' || Array.isArray(v)) { problems.push(at + 'must be an object'); return; }
if (typeof v.name !== 'string' || !/^var_[a-z0-9_]+$/.test(v.name)) problems.push(at + '`name` must look like `var_example`');
if (typeof v.label !== 'string' || !v.label.trim()) problems.push(at + '`label` is required');
if (TYPES.indexOf(v.type) === -1) problems.push(at + '`type` must be one of ' + TYPES.join(', '));
if (v.default !== undefined && typeof v.default !== 'string') problems.push(at + '`default` must be a string');
if (v.type === 'select') {
const opts = v.options;
if (!Array.isArray(opts) || opts.length === 0 || opts.some(function (o) { return typeof o !== 'string' || !o; })) {
problems.push(at + 'a select needs `options`, a non-empty array of strings');
} else if (v.default !== undefined && opts.indexOf(v.default) === -1) {
problems.push(at + '`default` must be one of its options');
}
}
['required', 'secret'].forEach(function (k) {
if (v[k] !== undefined && typeof v[k] !== 'boolean') problems.push(at + '`' + k + '` must be true or false');
});
});
if (problems.length > 0) {
await addReaction('-1');
await postComment('❌ **PocketBase Bot**: `app_vars` was not saved.\n\n- ' + problems.join('\n- '));
process.exit(0);
}
}
setPayload[fieldName] = parsedJson;
} else {
setPayload[fieldName] = codeBlockValue;
@@ -1236,3 +1326,58 @@ jobs:
});
ENDSCRIPT
shell: bash
- name: Close a metadata request once every change is decided
if: always() && github.event.action == 'edited' && contains(github.event.comment.body, '<!-- pb-request -->')
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node << 'ENDSCRIPT'
(async function () {
const api = 'https://api.github.com/repos/' + process.env.REPO + '/issues/' + process.env.ISSUE_NUMBER;
const headers = {
'Authorization': 'Bearer ' + process.env.GITHUB_TOKEN,
'Accept': 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': 'pocketbase-bot'
};
const https = require('https');
function call(method, url, body) {
return new Promise(function (resolve, reject) {
const req = https.request(url, { method: method, headers: headers }, function (res) {
let data = '';
res.on('data', function (d) { data += d; });
res.on('end', function () { resolve({ ok: res.statusCode < 300, status: res.statusCode, body: data }); });
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const requests = [];
for (let page = 1; page <= 10; page++) {
const res = await call('GET', api + '/comments?per_page=100&page=' + page);
if (!res.ok) throw new Error('Could not list comments: ' + res.status);
const comments = JSON.parse(res.body);
for (const c of comments) {
if ((c.body || '').includes('<!-- pb-request -->')) requests.push(c.body);
}
if (comments.length < 100) break;
}
const open = requests.filter(function (b) { return !b.includes('<!-- pb-request:done -->'); });
if (requests.length === 0 || open.length > 0) {
console.log(open.length + ' of ' + requests.length + ' changes still undecided.');
return;
}
const accepted = requests.filter(function (b) { return b.includes('✅ Accepted'); }).length;
const res = await call('PATCH', api, { state: 'closed', state_reason: accepted > 0 ? 'completed' : 'not_planned' });
if (!res.ok) throw new Error('Could not close the issue: ' + res.status);
console.log('Closed: ' + accepted + ' accepted, ' + (requests.length - accepted) + ' declined.');
})().catch(function (e) {
console.error(e.message || e);
process.exit(1);
});
ENDSCRIPT
shell: bash
+75 -15
View File
@@ -95,9 +95,10 @@ jobs:
trusted: ${{ steps.plan.outputs.trusted }}
kind: ${{ steps.plan.outputs.kind }}
platforms: ${{ steps.plan.outputs.platforms }}
base_sha: ${{ steps.plan.outputs.base_sha }}
steps:
- id: plan
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;
@@ -258,6 +259,31 @@ jobs:
}
const include = [];
// Upgrade test: install the ct/ script from the PR's base branch,
// then update with the PR, as a user who installed before the merge
// would. Only in ProxmoxVE, where users have installed it, and only
// when the PR changes update_script(): with the same update code an
// existing install updates exactly as it does today. New scripts
// have nothing to upgrade from. Proxmox only, once per script.
const UPGRADE_REPO = 'community-scripts/ProxmoxVE';
let baseSha = '';
if (!isIssue && `${owner}/${repo}` === UPGRADE_REPO) {
const { data: b } = await github.rest.repos.getBranch({ owner, repo, branch: pr.base.ref });
baseSha = b.commit.sha;
}
// From `function update_script() {` to the first `}` at the start of
// a line; null without one.
const updateCode = t => ((t || '').match(/^(function[ \t]+)?update_script[ \t]*\(\)[ \t]*\{[\s\S]*?^\}/m) || [null])[0];
const updateChanged = async (p, headText) => {
if (!baseSha) return false;
let baseText;
try {
const res = await github.rest.repos.getContent({ owner, repo, path: p, ref: baseSha });
baseText = Buffer.from(res.data.content || '', 'base64').toString('utf8');
} catch (e) { return false; }
const before = updateCode(baseText), after = updateCode(headText);
return before !== null && after !== null && before !== after;
};
const fetchScript = async p => {
try {
const res = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path: p, ref: headSha });
@@ -302,7 +328,11 @@ jobs:
platforms = platforms.filter(p => only.has(p));
}
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 os of oses) include.push({ script, os, platform, arm64: platform === 'arm' ? arm : '', mode: '', name: `${os ? `${name}-${os}` : name}-${platform}` });
}
if (!isVm && platforms.includes('pve') && await updateChanged(script, text)) {
const os = oses[0];
include.push({ script, os, platform: 'pve', arm64: '', mode: 'upgrade', name: `${os ? `${name}-${os}` : name}-pve-upgrade` });
}
}
for (const script of entries.slice(MAX)) notBuilt.push(`\`${script}\`: at most ${MAX} scripts per run, name it with \`/test ${script}\``);
@@ -327,7 +357,7 @@ jobs:
// full test, e.g. as a required check.
owner, repo, name: `Proxmox script test${onlyList.length ? ` (${onlyList.join(', ')} only)` : ''}`, 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') },
output: { title: `Building ${include.length}`, summary: include.map(i => `- \`${i.script}\` ${[i.platform, i.os, i.mode].filter(Boolean).join(' · ')}`).join('\n') },
});
checkId = String(check.id);
}
@@ -339,13 +369,14 @@ jobs:
core.setOutput('check_id', checkId);
core.setOutput('not_built', JSON.stringify(notBuilt));
core.setOutput('platforms', onlyList.join(' '));
core.setOutput('base_sha', baseSha);
// 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)' : ''}${onlyList.length ? `, only on ${onlyList.join(', ')}` : ''} — [watch it live](${runUrl}) (open the \`test\` job)`, '',
...include.map(i => `- \`${i.script}\` (${[i.platform, i.os].filter(Boolean).join(' · ')})`),
...include.map(i => `- \`${i.script}\` (${[i.platform, i.os, i.mode && 'upgrade from the base branch'].filter(Boolean).join(' · ')})`),
...(notBuilt.length ? ['', '**Not built:**', ...notBuilt.map(n => `- ${n}`)] : []),
].join('\n'));
@@ -363,7 +394,7 @@ jobs:
continue-on-error: ${{ matrix.platform == 'arm' }}
permissions:
contents: read
timeout-minutes: 90 # install (45) + update (30) + checks
timeout-minutes: 120 # install (45) + update (30) + second update + checks
strategy:
fail-fast: false
matrix:
@@ -371,7 +402,7 @@ jobs:
name: test (${{ matrix.name }})
steps:
# The default branch, never the PR: the harness itself must be trusted.
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
sparse-checkout: .github/workflows/scripts
persist-credentials: false
@@ -392,6 +423,9 @@ jobs:
# ARM is the Incus harness on another machine.
PLATFORM: ${{ matrix.platform == 'arm' && 'incus' || matrix.platform }}
VAR_ARM64: ${{ matrix.platform == 'arm' && 'yes' || '' }}
# upgrade: install from the base branch, update with the PR.
TEST_MODE: ${{ matrix.mode || '' }}
BASE_SHA: ${{ needs.gate.outputs.base_sha }}
UPSTREAM: ${{ github.repository }}
CTID_BASE: ${{ github.repository == 'community-scripts/ProxmoxVE' && '9050' || '9000' }}
# Only so release lookups are not rate limited: unauthenticated, all
@@ -402,14 +436,14 @@ jobs:
# 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' || '' }}
${{ matrix.platform == 'arm' && 'sudo --preserve-env=PR_REPO,PR_SHA,SCRIPT,OS,PLATFORM,VAR_ARM64,TEST_MODE,BASE_SHA,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
uses: actions/upload-artifact@v7
with:
name: result-${{ matrix.name }}
path: ${{ runner.temp }}/result
@@ -427,12 +461,12 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v8
with:
pattern: result-*
path: results
- uses: actions/github-script@v8
- uses: actions/github-script@v9
env:
MATRIX: ${{ needs.gate.outputs.matrix }}
PR: ${{ needs.gate.outputs.pr }}
@@ -477,6 +511,12 @@ jobs:
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 u2log = path.join(dir, 'update2.log');
r.update2_log = fs.existsSync(u2log) ? fs.readFileSync(u2log, 'utf8') : '';
if (!['upgrade', ''].includes(r.mode || '')) r.mode = '';
r.mode = r.mode || '';
r.notes = Array.isArray(r.notes) ? r.notes.filter(n => typeof n === 'string').slice(0, 8) : [];
r.resources = typeof r.resources === 'string' ? r.resources : '';
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
@@ -484,7 +524,7 @@ jobs:
// 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);
found.set(`${r.script}|${r.platform}|${r.os}|${r.mode}`, r);
} catch (err) { core.warning(`unreadable ${p}: ${err.message}`); }
}
}
@@ -492,19 +532,20 @@ jobs:
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',
const rows = matrix.map(m => ({ ...(found.get(`${m.script}|${m.platform || 'pve'}|${m.os || 'default'}|${m.mode || ''}`) || {
script: m.script, os: m.os || 'default', platform: m.platform || 'pve', mode: m.mode || '', notes: [], resources: '', update2_log: '',
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).
// Where a row ran: "pve", "incus · alpine", "arm64", "pve · upgrade", 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;
const w = r.os && r.os !== 'default' ? `${p} · ${r.os}` : p;
return r.mode === 'upgrade' ? `${w} · upgrade` : w;
};
// ARM is information only: shown, never counted. The result, the
// check, the review and the approval come from Proxmox and Incus.
@@ -647,6 +688,9 @@ jobs:
const lines = r.update_log.trimEnd().split('\n').length;
parts.push(details(`🔄 ${r.script} (${where(r)}) — update log (${lines} lines)`, r.update_log, updateShare));
}
if (r.update2_log) {
parts.push(details(`🔁 ${r.script} (${where(r)}) — second update log`, r.update2_log, Math.min(r.update2_log.length, Math.floor(budget / 8))));
}
return parts.join('\n\n');
});
@@ -661,6 +705,22 @@ jobs:
`${headline} at \`${process.env.SHA.slice(0, 7)}\``, '',
table, '',
...(armLine ? [`<sub>${armLine}</sub>`, ''] : []),
...(rows.some(r => r.mode === 'upgrade')
? ['<sub>`upgrade`: installed from the base branch as it is now, then updated with this PR, as a user who installed before the merge would.</sub>', '']
: []),
...(() => {
const noted = rows.filter(r => r.notes.length);
return noted.length
? ['**Notes** (information only, not counted):', ...noted.flatMap(r => r.notes.map(n => `- \`${r.script}\` (${where(r)}): ${clean(n)}`)), '']
: [];
})(),
...(() => {
const measured = rows.filter(r => r.resources);
return measured.length
? ['<details><summary>📊 Resources</summary>', '', '| Script | Platform | Usage |', '|---|---|---|',
...measured.map(r => `| \`${r.script}\` | ${where(r)} | ${clean(r.resources)} |`), '', '</details>', '']
: [];
})(),
...(partial ? [`<sub>Built only on ${process.env.PLATFORMS.split(' ').join(', ')}; comment \`/test\` for every platform.</sub>`, ''] : []),
...shots, '',
...(() => { try { const n = JSON.parse(process.env.NOT_BUILT || '[]'); return n.length ? ['**Not built:**', ...n.map(x => `- ${x}`), ''] : []; } catch (e) { return []; } })(),
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Installs ci-vm-controller.sh on the hypervisor (prox1): the script, the
# token reader, a systemd unit per CI VM, and each VM's config. Run as root
# on prox1 from a directory that also holds ci-vm-controller.sh:
#
# scp ci-controller-setup.sh ci-vm-controller.sh root@prox1:/root/ci-controller/
# ssh root@prox1 'cd /root/ci-controller && bash ci-controller-setup.sh'
#
# It does not start anything: a VM goes into service with
# systemctl enable --now ci-vm-controller@<vmid>
# once it has a 'clean' snapshot without any runner registered (see
# docs/guides/pr-script-testing.md, "Single-use hosts").
#
# The token (/etc/ci-controller/token) is put there by hand: a fine-grained
# token for the two repositories with Administration: read and write only.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f ci-vm-controller.sh ]] || { echo "ci-vm-controller.sh missing next to this script" >&2; exit 1; }
command -v jq >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq jq >/dev/null
install -m 755 ci-vm-controller.sh /usr/local/sbin/ci-vm-controller.sh
install -d -m 700 /etc/ci-controller
cat >/usr/local/sbin/ci-gh-token <<'EOF'
#!/bin/sh
# Prints the token ci-vm-controller.sh uses (Administration: write on the CI
# repositories). Swap this for a GitHub App token later without touching
# the controller.
exec cat /etc/ci-controller/token
EOF
chmod 755 /usr/local/sbin/ci-gh-token
cat >/etc/systemd/system/ci-vm-controller@.service <<'EOF'
[Unit]
Description=Single-use CI runner host, VM %i
After=network-online.target pve-cluster.service pvedaemon.service
Wants=network-online.target
# A missing token is a setup error, not something to retry every 30 s.
ConditionPathExists=/etc/ci-controller/token
[Service]
ExecStart=/usr/local/sbin/ci-vm-controller.sh %i
Restart=always
RestartSec=30
# Stopping the controller leaves the VM as it is; the next start resets it,
# which also ends a job that may still be running there.
KillMode=mixed
[Install]
WantedBy=multi-user.target
EOF
# One repository per host: see ci-vm-controller.sh.
conf() { # conf <vmid> <ip> <name> <label> <repo>
cat >"/etc/ci-controller/$1.conf" <<EOF
VM_IP=$2
HOST_NAME=$3
LABEL=$4
REPOS=$5
EOF
}
conf 200 10.201.146.232 pve-ci proxmox-test community-scripts/DevScripts
conf 201 10.201.146.231 pve-ci-2 proxmox-test community-scripts/ProxmoxVE
conf 204 10.201.146.235 pve-ci-3 proxmox-test community-scripts/ProxmoxVE
conf 202 10.201.146.233 incus-ci incus-test community-scripts/DevScripts
conf 203 10.201.146.234 incus-ci-2 incus-test community-scripts/ProxmoxVE
systemctl daemon-reload
echo "Installed. Token: $([[ -s /etc/ci-controller/token ]] && echo present || echo 'MISSING (/etc/ci-controller/token)')"
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Keeps one CI VM (pve-ci, incus-ci, ...) as a single-use GitHub runner host.
# Runs on the hypervisor (prox1), which PR code never touches.
#
# Every cycle:
# 1. roll the VM back to its clean snapshot and boot it;
# 2. register a just-in-time runner on it (generate-jitconfig) for the
# host's repository. Such a runner takes exactly one job and is deleted
# by GitHub afterwards, so its credentials are worthless once that job
# is over;
# 3. (with several repositories in REPOS: as soon as one runner is busy,
# delete the others that are still idle, so no second job starts on a
# host that is about to be reset);
# 4. once every runner of this cycle is gone, start over at 1.
#
# PR code runs as root on the VM, so nothing the VM says is trusted: when a
# job is over is read from GitHub's API, never from the VM.
#
# Config: /etc/ci-controller/<vmid>.conf (or the environment), see below.
# Usage: ci-vm-controller.sh <vmid> run forever (the systemd unit)
# ONE_CYCLE=1 ci-vm-controller.sh <vmid> one job, then stop (testing)
set -euo pipefail
VMID=${1:?usage: ci-vm-controller.sh <vmid>}
CONF=${CONF:-/etc/ci-controller/$VMID.conf}
# shellcheck source=/dev/null
[[ -f "$CONF" ]] && . "$CONF"
: "${VM_IP:?VM_IP}" # address of the CI VM
: "${HOST_NAME:?HOST_NAME}" # runner name prefix, e.g. pve-ci
: "${LABEL:?LABEL}" # proxmox-test or incus-test
# One repository per host. Several would each have a runner waiting on the
# VM, and a job of one could read the idle runner's JIT config of the other
# and use it elsewhere before it is withdrawn: one repository's PR code
# faking the other's results. (Several are fine within one trust domain.)
: "${REPOS:?REPOS, e.g. community-scripts/DevScripts}"
EXTRA_LABELS=${EXTRA_LABELS:-} # space-separated, e.g. for a test
SNAPSHOT=${SNAPSHOT:-clean}
TOKEN_CMD=${TOKEN_CMD:-/usr/local/sbin/ci-gh-token} # prints a token with repo Administration: write
PVE=${PVE:-} # prefix for qm, e.g. "ssh root@prox1" when not run on it
GUEST_SSH=${GUEST_SSH:-"ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new root@$VM_IP"}
POLL=${POLL:-20}
MAX_BUSY_MIN=${MAX_BUSY_MIN:-180} # the test job's own limit is 90
MAX_IDLE_MIN=${MAX_IDLE_MIN:-720} # recycle an idle host twice a day
ONE_CYCLE=${ONE_CYCLE:-}
log() { echo "[$(date -u +%FT%TZ)] vm $VMID: $*"; }
qm() { if [[ -n "$PVE" ]]; then $PVE qm "$@"; else command qm "$@"; fi; }
# Not in a subshell, so the status and the cached token survive the call:
# api METHOD PATH [JSON] -> body in $API_BODY, HTTP status in $API_STATUS.
API_FILE=$(mktemp)
trap 'rm -f "$API_FILE"' EXIT
TOKEN="" TOKEN_AT=0
api() {
local method=$1 path=$2 data=${3:-}
# Cached for 30 minutes (an App installation token lives 60).
if [[ -z "$TOKEN" || $(($(date +%s) - TOKEN_AT)) -gt 1800 ]]; then
TOKEN=$($TOKEN_CMD) || { log "no GitHub token"; API_STATUS=000 API_BODY=""; return 1; }
TOKEN_AT=$(date +%s)
fi
API_STATUS=$(curl -sS -o "$API_FILE" -w '%{http_code}' -X "$method" \
-H "Authorization: Bearer $TOKEN" -H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' ${data:+-d "$data"} "https://api.github.com$path") || API_STATUS=000
API_BODY=$(<"$API_FILE")
[[ "$API_STATUS" != 000 ]]
}
reset_vm() {
log "rolling back to '$SNAPSHOT'"
[[ "$(qm status "$VMID")" == *running* ]] && qm stop "$VMID" --timeout 60 >/dev/null
qm rollback "$VMID" "$SNAPSHOT" >/dev/null
qm start "$VMID" >/dev/null
local i
for i in $(seq 1 60); do
$GUEST_SSH true 2>/dev/null && { log "booted"; return 0; }
sleep 5
done
log "no SSH after 5 minutes"
return 1
}
# Parallel arrays: one entry per runner registered in this cycle.
R_REPO=() R_ID=() R_GONE=()
register() { # register <owner/repo>
local repo=$1 name dir labels body cfg id
name="$HOST_NAME-${repo#*/}-$(date +%s)"
dir=/opt/actions-runner/${repo#*/}
labels=$(printf '%s\n' "$LABEL" $EXTRA_LABELS | jq -R . | jq -sc .)
api POST "/repos/$repo/actions/runners/generate-jitconfig" \
"$(jq -nc --arg n "$name" --argjson l "$labels" '{name: $n, runner_group_id: 1, labels: $l, work_folder: "_work"}')" || return 1
body=$API_BODY
[[ "$API_STATUS" == 201 ]] || { log "$repo: generate-jitconfig failed ($API_STATUS): $(jq -r .message <<<"$body" 2>/dev/null)"; return 1; }
id=$(jq -r .runner.id <<<"$body")
cfg=$(jq -r .encoded_jit_config <<<"$body")
# The config goes in through stdin, not the command line.
if ! $GUEST_SSH "umask 077; cat >/run/ci-jit-${repo#*/} && systemd-run --quiet --collect --unit=ci-jit-${repo#*/} \
-p WorkingDirectory=$dir -E RUNNER_ALLOW_RUNASROOT=1 \
/bin/sh -c 'cfg=\$(cat /run/ci-jit-${repo#*/}); rm -f /run/ci-jit-${repo#*/}; exec ./run.sh --jitconfig \"\$cfg\"'" <<<"$cfg"; then
log "$repo: could not start the runner on the VM"
api DELETE "/repos/$repo/actions/runners/$id" >/dev/null || true
return 1
fi
R_REPO+=("$repo") R_ID+=("$id") R_GONE+=(0)
log "$repo: runner $name (id $id) started"
}
drop() { # drop <index>: delete an idle runner; GitHub refuses a busy one
local i=$1
api DELETE "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" >/dev/null || return 1
if [[ "$API_STATUS" == 204 || "$API_STATUS" == 404 ]]; then R_GONE[$i]=1; return 0; fi
return 1
}
watch_cycle() {
local start busy_since="" i body status busy any_busy left offline_since=""
start=$(date +%s)
while :; do
sleep "$POLL"
any_busy=0 left=0 offline=0
for i in "${!R_ID[@]}"; do
[[ "${R_GONE[$i]}" == 1 ]] && continue
api GET "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" || { left=1; continue; }
body=$API_BODY
if [[ "$API_STATUS" == 404 ]]; then
R_GONE[$i]=1
log "${R_REPO[$i]}: runner ${R_ID[$i]} is gone (job done or removed)"
continue
fi
left=1
busy=$(jq -r .busy <<<"$body"); status=$(jq -r .status <<<"$body")
[[ "$busy" == true ]] && any_busy=1
[[ "$status" == offline ]] && offline=1
done
if [[ "$any_busy" == 1 && -z "$busy_since" ]]; then
busy_since=$(date +%s)
log "a job started; withdrawing the idle runners"
fi
# A host that has started a job takes no other one: it will be reset.
if [[ -n "$busy_since" ]]; then
for i in "${!R_ID[@]}"; do
[[ "${R_GONE[$i]}" == 1 ]] && continue
api GET "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" || continue
body=$API_BODY
[[ "$API_STATUS" == 200 && "$(jq -r .busy <<<"$body")" == false ]] && drop "$i" &&
log "${R_REPO[$i]}: idle runner ${R_ID[$i]} withdrawn"
done
fi
[[ "$left" == 0 ]] && { log "cycle over"; return 0; }
local now; now=$(date +%s)
if [[ -n "$busy_since" && $((now - busy_since)) -gt $((MAX_BUSY_MIN * 60)) ]]; then
log "job still running after $MAX_BUSY_MIN minutes; resetting anyway"
break
fi
if [[ -z "$busy_since" && $((now - start)) -gt $((MAX_IDLE_MIN * 60)) ]]; then
log "idle for $MAX_IDLE_MIN minutes; recycling"
break
fi
# A runner that never comes online (or dies while idle) holds the host
# forever otherwise.
if [[ -z "$busy_since" && "$offline" == 1 ]]; then
[[ -z "$offline_since" ]] && offline_since=$now
if [[ $((now - offline_since)) -gt 300 ]]; then log "a runner is offline for 5 minutes; resetting"; break; fi
else
offline_since=""
fi
done
for i in "${!R_ID[@]}"; do [[ "${R_GONE[$i]}" == 1 ]] || drop "$i" || true; done
}
# Registrations of this host that a stopped or crashed controller left behind:
# their VM was reset, so they are offline and never take a job, but they
# clutter the runner list. Busy or online ones are left alone.
cleanup_stale() {
local repo ids id
for repo in $REPOS; do
api GET "/repos/$repo/actions/runners?per_page=100" || continue
[[ "$API_STATUS" == 200 ]] || continue
ids=$(jq -r --arg p "$HOST_NAME-${repo#*/}-" \
'.runners[] | select((.name | startswith($p)) and .status == "offline" and (.busy | not)) | .id' <<<"$API_BODY")
for id in $ids; do
api DELETE "/repos/$repo/actions/runners/$id" && log "$repo: stale runner $id removed"
done
done
}
while :; do
R_REPO=() R_ID=() R_GONE=()
if reset_vm; then
cleanup_stale
ok=0
for repo in $REPOS; do register "$repo" && ok=1 || true; done
if [[ "$ok" == 1 ]]; then watch_cycle; else log "no runner could be registered"; sleep 60; fi
else
sleep 60
fi
[[ -n "$ONE_CYCLE" ]] && { log "ONE_CYCLE: stopping; the VM is left as the job left it"; break; }
done
+9
View File
@@ -6,6 +6,8 @@
#
# incus-ci-host-setup.sh host only
# incus-ci-host-setup.sh <owner/repo> <token> host + a runner for that repo
# incus-ci-host-setup.sh <owner/repo> jit host + runner software only, for
# ci-vm-controller.sh (single-use host)
#
# The token is a runner *registration* token (valid one hour):
# gh api -X POST repos/<owner/repo>/actions/runners/registration-token --jq .token
@@ -176,6 +178,13 @@ if [[ $# -eq 2 ]]; then
curl -fsSL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" | tar -xz
./bin/installdependencies.sh >/dev/null
fi
# `jit`: a single-use host. ci-vm-controller.sh on the hypervisor registers
# a fresh runner for every job and resets the VM afterwards, so only the
# runner software goes on it, and the 'clean' snapshot is taken after this.
if [[ "$token" == jit ]]; then
echo "runner software for ${repo} installed; ci-vm-controller.sh registers a runner per job"
exit 0
fi
if [[ -f .runner ]]; then
./svc.sh stop >/dev/null 2>&1 || true
./svc.sh uninstall >/dev/null 2>&1 || true
+9
View File
@@ -5,6 +5,8 @@
#
# pve-ci-host-setup.sh host only
# pve-ci-host-setup.sh <owner/repo> <token> host + a runner for that repo
# pve-ci-host-setup.sh <owner/repo> jit host + runner software only, for
# ci-vm-controller.sh (single-use host)
#
# The token is a runner *registration* token (valid one hour):
# gh api -X POST repos/<owner/repo>/actions/runners/registration-token --jq .token
@@ -196,6 +198,13 @@ if [[ $# -eq 2 ]]; then
curl -fsSL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" | tar -xz
./bin/installdependencies.sh >/dev/null
fi
# `jit`: a single-use host. ci-vm-controller.sh on the hypervisor registers
# a fresh runner for every job and resets the VM afterwards, so only the
# runner software goes on it, and the 'clean' snapshot is taken after this.
if [[ "$token" == jit ]]; then
echo "runner software for ${repo} installed; ci-vm-controller.sh registers a runner per job"
exit 0
fi
# A registered runner is reconfigured from scratch: stop and remove the old
# registration (the token works for removal too).
if [[ -f .runner ]]; then
+210 -29
View File
@@ -48,6 +48,16 @@ URL_WAIT="${URL_WAIT:-120}"
# script that leaves var_arm64 unset; the ARM run builds those anyway, to see.
VAR_ARM64="${VAR_ARM64:-}"
[[ -z "$VAR_ARM64" || "$VAR_ARM64" == "yes" ]] || { echo "bad VAR_ARM64" >&2; exit 2; }
# upgrade: install the script as it is on the base branch (BASE_SHA of
# UPSTREAM), then update with the PR. That is what a user does who installed
# before the PR is merged and runs `update` after it. Empty: install and
# update both from the PR.
TEST_MODE="${TEST_MODE:-}"
[[ -z "$TEST_MODE" || "$TEST_MODE" == "upgrade" ]] || { echo "bad TEST_MODE" >&2; exit 2; }
if [[ -n "$TEST_MODE" ]]; then
[[ "${BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || { echo "bad BASE_SHA" >&2; exit 2; }
[[ "$SCRIPT" == ct/* ]] || { echo "upgrade tests are for ct/ scripts" >&2; exit 2; }
fi
[[ "$PLATFORM" == "pve" || "$SCRIPT" == ct/* ]] || { echo "vm/ scripts run on pve only" >&2; exit 2; }
[[ "$CTID_BASE" =~ ^[0-9]+$ ]] || { echo "bad CTID_BASE" >&2; exit 2; }
@@ -59,12 +69,17 @@ INSTALL_LOG_OUT="$OUT_DIR/install.log"
UPDATE_RAW="$OUT_DIR/update.raw.log"
UPDATE_LOG="$OUT_DIR/update.log"
UPDATE_TIMEOUT="${UPDATE_TIMEOUT:-30m}"
UPDATE2_RAW="$OUT_DIR/update2.raw.log"
UPDATE2_LOG="$OUT_DIR/update2.log"
# How long the app has to keep running without a service restarting.
STABLE_WAIT="${STABLE_WAIT:-60}"
kind="${SCRIPT%%/*}" # ct or vm
app=$(basename "$SCRIPT" .sh)
job="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${PLATFORM}-${kind}-${app}-${OS:-default}"
job="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${PLATFORM}-${kind}-${app}-${OS:-default}${TEST_MODE:+-$TEST_MODE}"
tree="$SERVE_ROOT/$job"
base="$SERVE_URL/$job"
src_tree="$tree" # what gets installed; the base branch's tree in an upgrade test
started=$(date +%s)
status="fail"
@@ -102,6 +117,16 @@ failed_units=""
# fail: the update broke; empty: not attempted (the install failed).
update_status=""
update_step=""
# install: building and checking what was installed; update: from the first
# update on. In an upgrade test a failure while installing is the base
# branch's, not the PR's.
phase="install"
# Findings that do not fail the build, shown under the table on the PR.
notes=()
# Disk, RAM and OOM kills of the container, measured at the end.
resources=""
# Second `update` right after the first: no-update, updated-again, or empty.
update2_status=""
# Spinner frames: the engine redraws "⠋ Starting VM" a few times a second
# without a tty too. An alternation, not a bracket: these are multibyte and
@@ -163,13 +188,61 @@ classify_cpu_failure() {
step="needs a newer CPU than the test host has (no ${feature}): ${line}"
}
# Disk used against var_disk, RAM in use at rest (anonymous memory, without
# the page cache that fills any limit) against the limit, and how often the
# kernel killed something for lack of memory, over the container's whole life,
# the build included. A dir-backed Incus root has no size of its own: df shows
# the host's disk. Then what the container holds is counted with du, and the
# script's var_disk is the size to compare with.
measure_resources() {
local out disk_size disk_used anon limit oom want_disk want_ram line=()
out=$(guest_exec sh -c 'df -Pm / | awk "NR==2 {print \$2, \$3}"
awk "/^anon / {print int(\$2 / 1048576)}" /sys/fs/cgroup/memory.stat 2>/dev/null || echo -
cat /sys/fs/cgroup/memory.max 2>/dev/null || echo -
awk "/^oom_kill / {print \$2}" /sys/fs/cgroup/memory.events 2>/dev/null || echo -' 2>/dev/null) || return 0
read -r disk_size disk_used <<<"$(sed -n 1p <<<"$out")"
anon=$(sed -n 2p <<<"$out"); limit=$(sed -n 3p <<<"$out"); oom=$(sed -n 4p <<<"$out")
want_disk=$(grep -m1 -oE 'var_disk="\$\{var_disk:-[0-9]+\}"' "$src_tree/$SCRIPT" | grep -oE '[0-9]+' | head -n1)
want_ram=$(grep -m1 -oE 'var_ram="\$\{var_ram:-[0-9]+\}"' "$src_tree/$SCRIPT" | grep -oE '[0-9]+' | head -n1)
[[ "$disk_size" =~ ^[0-9]+$ && "$disk_used" =~ ^[0-9]+$ ]] || return 0
if [[ -n "$want_disk" ]] && ((disk_size > want_disk * 1024 * 3 / 2)); then
disk_size=$((want_disk * 1024))
disk_used=$(guest_exec sh -c 'du -sxm / 2>/dev/null | cut -f1' 2>/dev/null)
[[ "$disk_used" =~ ^[0-9]+$ ]] || return 0
fi
line+=("disk $(awk -v u="$disk_used" 'BEGIN {printf "%.1f", u / 1024}') of $(awk -v s="$disk_size" 'BEGIN {printf "%.0f", s / 1024}') GB")
if ((disk_used * 100 >= disk_size * 90)); then
notes+=("disk ${disk_used} of ${disk_size} MB used after the install: var_disk may be too small")
fi
[[ "$limit" =~ ^[0-9]+$ ]] && limit=$((limit / 1048576)) || limit="${want_ram:-}"
if [[ "$anon" =~ ^[0-9]+$ ]]; then
line+=("RAM at rest ${anon}${limit:+ of ${limit}} MB")
if [[ "$limit" =~ ^[0-9]+$ ]] && ((anon * 100 >= limit * 80)); then
notes+=("${anon} of ${limit} MB RAM in use at rest: var_ram may be too small")
fi
fi
if [[ "$oom" =~ ^[0-9]+$ ]]; then
line+=("OOM kills ${oom}")
((oom > 0)) && notes+=("the kernel killed ${oom} process(es) for lack of memory: var_ram is too small for the build or the app")
fi
resources=$(
IFS='|'
echo "${line[*]}"
)
resources=${resources//|/ · }
}
finish() {
[[ -f "$RAW_LOG" ]] && strip_ansi <"$RAW_LOG" >"$LOG"
[[ -f "$RAW_LOG" ]] && collect_install_log
[[ -f "$UPDATE_RAW" ]] && strip_ansi <"$UPDATE_RAW" >"$UPDATE_LOG"
rm -f "$UPDATE_RAW"
[[ -f "$UPDATE2_RAW" ]] && strip_ansi <"$UPDATE2_RAW" >"$UPDATE2_LOG"
rm -f "$UPDATE_RAW" "$UPDATE2_RAW"
[[ "$kind" == ct && -n "$ctid" && -z "$resources" ]] && guest_running && measure_resources
jq -n \
--arg script "$SCRIPT" --arg kind "$kind" --arg platform "$PLATFORM" --arg os "${OS:-default}" --arg status "$status" \
--arg mode "$TEST_MODE" --arg resources "$resources" --arg update2_status "$update2_status" \
--arg notes "$(printf '%s\n' "${notes[@]}")" \
--arg step "$step" --arg exit_code "$exit_code" --arg sha "$PR_SHA" \
--arg ctid "$ctid" --arg url "$url" --arg http_code "$http_code" \
--arg failed_units "$failed_units" --arg ip "$ct_ip" --arg arch "$(uname -m)" \
@@ -179,6 +252,8 @@ finish() {
'{script: $script, kind: $kind, platform: $platform, arch: $arch, os: $os, status: $status, step: $step, exit_code: $exit_code,
sha: $sha, ctid: $ctid, ip: $ip, url: $url, http_code: $http_code,
failed_units: $failed_units, update_status: $update_status, update_step: $update_step,
mode: $mode, resources: $resources, update2_status: $update2_status,
notes: ($notes | split("\n") | map(select(. != ""))),
duration: $duration, log_tail: $tail}' >"$RESULT"
echo "::group::result"
jq 'del(.log_tail)' "$RESULT"
@@ -262,7 +337,7 @@ cleanup() {
done
fi
rm -f "$RAW_LOG.start"
rm -rf -- "$tree"
rm -rf -- "$tree" "$tree-base"
}
trap cleanup EXIT
# A cancelled job sends TERM; without this the EXIT trap would not run.
@@ -281,6 +356,10 @@ fail() {
step="$1"
status="${2:-fail}"
[[ "$status" == "unsupported" ]] || status="fail"
if [[ "$TEST_MODE" == "upgrade" && "$phase" == "install" && "$status" == "fail" ]]; then
status="error"
step="the install before this PR (${UPSTREAM}@${BASE_SHA:0:7}) failed, so the upgrade was not tested: $1"
fi
finish
echo "::warning title=${SCRIPT}::${step}"
exit 0
@@ -335,20 +414,37 @@ upstream_re="https://raw\.githubusercontent\.com/${UPSTREAM//./\\.}/(refs/heads/
grep -rlE "$upstream_re" "$tree" --include='*.sh' --include='*.func' |
xargs -r sed -i -E "s#${upstream_re}#${base}#g"
# What gets installed: the PR, or in an upgrade test the base branch.
src_tree="$tree"
src_base="$base"
if [[ "$TEST_MODE" == "upgrade" ]]; then
src_tree="$tree-base"
src_base="$base-base"
mkdir -p "$src_tree"
curl -fsSL --retry 3 "https://codeload.github.com/${UPSTREAM}/tar.gz/${BASE_SHA}" |
tar -xz -C "$src_tree" --strip-components=1 || setup_error "could not download ${UPSTREAM}@${BASE_SHA:0:7}"
[[ -f "$src_tree/$SCRIPT" ]] || fail "${SCRIPT} does not exist at ${UPSTREAM}@${BASE_SHA:0:7}"
grep -rlE "$upstream_re" "$src_tree" --include='*.sh' --include='*.func' |
xargs -r sed -i -E "s#${upstream_re}#${src_base}#g"
fi
if [[ "$kind" == "vm" ]]; then
vm_build_and_check
exit 0
fi
# --- Build ----------------------------------------------------------------------
echo "Building ${SCRIPT} (${OS:-default OS}) on ${PLATFORM} as CT ${ctid} from ${PR_REPO}@${PR_SHA:0:7}"
if [[ "$TEST_MODE" == "upgrade" ]]; then
echo "Upgrade test: installing ${SCRIPT} from ${UPSTREAM}@${BASE_SHA:0:7} (before this PR), then updating with ${PR_REPO}@${PR_SHA:0:7}"
fi
echo "Building ${SCRIPT} (${OS:-default OS}) on ${PLATFORM} as CT ${ctid} from $([[ "$TEST_MODE" == upgrade ]] && echo "${UPSTREAM}@${BASE_SHA:0:7}" || echo "${PR_REPO}@${PR_SHA:0:7}")"
hostname_safe="ci-$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}" | cut -c1-50)"
build_env=(
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root TERM=dumb LANG=C.UTF-8
mode=default MODE=default PHS_SILENT=1 DIAGNOSTICS=no
var_ctid="$ctid" var_hostname="$hostname_safe" var_brg="$BRIDGE"
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$base"
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$src_base"
)
if [[ "$PLATFORM" == "incus" ]]; then
build_env+=(var_container_storage=default)
@@ -387,7 +483,7 @@ ci_value() {
esac
}
declare -A answers=()
json="$tree/json/${app}.json"
json="$src_tree/json/${app}.json"
if [[ -f "$json" ]]; then
while IFS=$'\t' read -r name default; do
answers[$name]="$default"
@@ -419,8 +515,8 @@ expand_templates() {
# The engine names the install script after APP (lower case, no spaces), which
# need not match the ct/ file name: ct/bluesky-pds.sh -> blueskypds-install.sh.
nsapp=$(grep -m1 -oE '^APP="[^"]+"' "$tree/$SCRIPT" | cut -d'"' -f2 | tr -d ' ' | tr '[:upper:]' '[:lower:]')
for f in "$tree/install/${app}-install.sh" "$tree/install/${nsapp}-install.sh" "$tree/$SCRIPT"; do
nsapp=$(grep -m1 -oE '^APP="[^"]+"' "$src_tree/$SCRIPT" | cut -d'"' -f2 | tr -d ' ' | tr '[:upper:]' '[:lower:]')
for f in "$src_tree/install/${app}-install.sh" "$src_tree/install/${nsapp}-install.sh" "$src_tree/$SCRIPT"; do
[[ -f "$f" ]] || continue
while read -r name; do
[[ -v "answers[$name]" ]] || answers[$name]=""
@@ -443,7 +539,7 @@ done
# In the background so that a TERM from a cancelled job is handled at once:
# bash defers traps until a foreground command returns.
env -i "${build_env[@]}" timeout --kill-after=1m "$BUILD_TIMEOUT" \
bash -c 'bash -c "$(curl -fsSL "$1")"' _ "$base/$SCRIPT" </dev/null >"$RAW_LOG" 2>&1 &
bash -c 'bash -c "$(curl -fsSL "$1")"' _ "$src_base/$SCRIPT" </dev/null >"$RAW_LOG" 2>&1 &
build_pid=$!
start_streams
wait "$build_pid"
@@ -522,8 +618,12 @@ take_screenshot() {
# open, e.g. http://${IP}:1337/admin, answers 200 (after redirects, so a jump
# to /login is fine). A script that prints no URL has no web UI to check.
# Run after the install and again after the update. Sets health_problem.
pattern=$(grep -oE 'https?://\$\{IP\}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?' "$tree/$SCRIPT" | head -n1)
[[ -n "$pattern" && -n "$ct_ip" ]] && url="${pattern/\$\{IP\}/$ct_ip}"
app_url() { # the URL the script at $1 tells the user to open
local pattern
pattern=$(grep -oE 'https?://\$\{IP\}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?' "$1" | head -n1)
[[ -n "$pattern" && -n "$ct_ip" ]] && echo "${pattern/\$\{IP\}/$ct_ip}"
}
url=$(app_url "$src_tree/$SCRIPT")
health_problem=""
check_health() {
health_problem=""
@@ -555,7 +655,9 @@ check_health screenshot.png || fail "$health_problem"
# only say "No update available" and never run update_script's real path. The
# version files the install wrote (~/.<app>, one per fetch_and_deploy_gh_release)
# that update_script checks are set back to 0.0.0-ci first; check_for_gh_release then sees an update and
# runs it all. `update` is the /usr/bin/update the install generated: it pulls
# runs it all. Apps deployed from a branch (fetch_and_deploy_gh_branch, checked
# with check_for_gh_branch) store a commit hash instead of a version; the
# engine compares both as plain strings, so 0.0.0-ci works for them too. `update` is the /usr/bin/update the install generated: it pulls
# ct/<app>.sh from the URL this container was built from, i.e. the PR's tree.
UPDATE_FAKE="0.0.0-ci"
update_fail() {
@@ -568,7 +670,7 @@ version_files=$(guest_exec sh -c '
for f in /root/.*; do
[ -f "$f" ] || continue
case "$f" in */.install-* | *.pending | *.bak | */.bash* | */.profile | */.hushlogin | */.viminfo | */.lesshst | */.wget-hsts | */.selected_editor) continue ;; esac
[ "$(wc -l <"$f")" -le 1 ] && grep -qE "^v?[0-9]+(\.[0-9A-Za-z_+-]+)+$" "$f" && echo "$f"
[ "$(wc -l <"$f")" -le 1 ] && grep -qE "^(v?[0-9]+(\.[0-9A-Za-z_+-]+)+|[0-9a-f]{7,40})$" "$f" && echo "$f"
done' 2>/dev/null | grep -E '^/root/\.[A-Za-z0-9._-]+$')
# Only set back what update_script checks: the names it passes to
# check_for_*release. The install can also deploy helpers the update never
@@ -585,24 +687,41 @@ for f in $version_files; do
done
[[ -n "$version_files" ]] && echo "Update test: set back $(tr '\n' ' ' <<<"$version_files")to ${UPDATE_FAKE}"
: >"$UPDATE_RAW"
update_token=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
echo "──────── update test (live) ────────"
echo "::stop-commands::${update_token}"
# timeout runs a program, not a shell function, so spell out the exec here.
if [[ "$PLATFORM" == "incus" ]]; then guest_cmd=(incus exec "$ctid" --); else guest_cmd=(pct exec "$ctid" --); fi
timeout --kill-after=1m "$UPDATE_TIMEOUT" "${guest_cmd[@]}" env PHS_SILENT=1 mode=default MODE=default \
TERM=dumb DIAGNOSTICS=no var_ignore_breaking_changes=yes ${GH_API_TOKEN:+GITHUB_TOKEN="$GH_API_TOKEN"} update </dev/null \
> >(tee "$UPDATE_RAW" | live_filter) 2>&1 &
update_pid=$!
wait "$update_pid"
update_rc=$?
update_pid=""
sleep 1
echo "::${update_token}::"
phase="update"
# Upgrade test: the container pulls its update from where it was installed
# from (COMMUNITY_SCRIPTS_URL in /usr/bin/update); point that at the PR, as
# merging it would.
if [[ "$TEST_MODE" == "upgrade" ]]; then
guest_exec sed -i -E "s#^export COMMUNITY_SCRIPTS_URL=.*#export COMMUNITY_SCRIPTS_URL=\"${base}\"#" /usr/bin/update &&
guest_exec grep -qxF "export COMMUNITY_SCRIPTS_URL=\"${base}\"" /usr/bin/update ||
setup_error "could not point /usr/bin/update at the PR"
echo "Upgrade test: /usr/bin/update now pulls from ${PR_REPO}@${PR_SHA:0:7}"
fi
# run_update <raw log> <title>: runs `update` in the container, live in the
# job log; sets update_rc.
run_update() {
local raw=$1 token
: >"$raw"
token=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
echo "──────── $2 (live) ────────"
echo "::stop-commands::${token}"
# timeout runs a program, not a shell function, so spell out the exec here.
if [[ "$PLATFORM" == "incus" ]]; then guest_cmd=(incus exec "$ctid" --); else guest_cmd=(pct exec "$ctid" --); fi
timeout --kill-after=1m "$UPDATE_TIMEOUT" "${guest_cmd[@]}" env PHS_SILENT=1 mode=default MODE=default \
TERM=dumb DIAGNOSTICS=no var_ignore_breaking_changes=yes ${GH_API_TOKEN:+GITHUB_TOKEN="$GH_API_TOKEN"} update </dev/null \
> >(tee "$raw" | live_filter) 2>&1 &
update_pid=$!
wait "$update_pid"
update_rc=$?
update_pid=""
sleep 1
echo "::${token}::"
}
run_update "$UPDATE_RAW" "update test"
update_failure_line() {
strip_ansi <"$UPDATE_RAW" | grep -E '✖|\[ERROR\]|error:' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180
strip_ansi <"${1:-$UPDATE_RAW}" | grep -E '✖|\[ERROR\]|error:' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180
}
if ((update_rc == 124 || update_rc == 137)); then
update_fail "timed out after ${UPDATE_TIMEOUT}"
@@ -637,7 +756,69 @@ else
update_step=$(strip_ansi <"$UPDATE_RAW" | grep -iE 'no update|up to date|up-to-date|already' | tail -n 1 | sed -E 's/^\s*(✔️?|ℹ️?|\s)*//' | cut -c1-160)
fi
url=$(app_url "$tree/$SCRIPT")
check_health screenshot-update.png || update_fail "after the update: $health_problem"
# --- Second update ------------------------------------------------------------------
# Right after an update, `update` has nothing to do: it must say so and leave
# the app alone. One that breaks on a second run breaks for every user on
# their next update; one that updates again although nothing changed
# rebuilds on every run (often a version file it never writes).
if [[ "$update_status" == "updated" ]]; then
run_update "$UPDATE2_RAW" "second update (must have nothing to do)"
if ((update_rc == 124 || update_rc == 137)); then
update_fail "second run timed out after ${UPDATE_TIMEOUT}"
elif ((update_rc != 0)); then
last=$(update_failure_line "$UPDATE2_RAW")
update_fail "second run exited ${update_rc}${last:+: $last}"
fi
update_error=$(strip_ansi <"$UPDATE2_RAW" | grep -E '✖|\[ERROR\]' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180)
if [[ -n "$update_error" && "${update_error,,}" != *"rate limit"* ]]; then
update_fail "second run: $update_error"
fi
if strip_ansi <"$UPDATE2_RAW" | grep -qi 'updated successfully'; then
update2_status="updated-again"
notes+=("a second \`update\` right after the first updated again: update_script does not notice it is current (e.g. a version file it never writes)")
else
update2_status="no-update"
fi
check_health screenshot-update.png || update_fail "after the second update: $health_problem"
fi
# --- Does it stay up? -----------------------------------------------------------
# A service that crash-loops can be "active" at every single look. Count the
# restarts of every service (and Docker container) over STABLE_WAIT seconds:
# any that went up was restarted while nobody touched the container.
restart_counts() {
guest_exec sh -c '
if command -v systemctl >/dev/null; then
systemctl list-units --type=service --all --no-legend --plain | awk "{print \$1}" |
while read -r u; do echo "$u $(systemctl show -p NRestarts --value "$u" 2>/dev/null)"; done
fi
if command -v docker >/dev/null; then
docker ps -aq | xargs -r docker inspect -f "docker:{{.Name}} {{.RestartCount}}" 2>/dev/null
fi' 2>/dev/null | awk '$2 ~ /^[0-9]+$/' | sort
}
before=$(restart_counts)
sleep "$STABLE_WAIT"
after=$(restart_counts)
restarting=$(join <(echo "$before") <(echo "$after") | awk '$3 > $2 {printf "%s%s (+%d)", sep, $1, $3 - $2; sep=", "}')
[[ -z "$restarting" ]] || fail "not stable: restarted within ${STABLE_WAIT}s without anyone touching it: ${restarting}"
check_health screenshot-update.png || fail "not stable: ${STABLE_WAIT}s later, ${health_problem}"
# Error lines in the journal since boot: often harmless, sometimes the reason
# a feature is broken. Information only.
if guest_exec sh -c 'command -v journalctl' &>/dev/null; then
# Without the noise every container produces: udev may not trigger devices
# in an unprivileged container, and logind finds no VTs.
journal_errors=$(guest_exec journalctl -b -p err -q -o cat --no-pager 2>/dev/null | grep -vE '^\s*$' |
grep -vE "Failed to write 'add' to '/sys/.*/uevent'|Seat has no VTs but VT number not 0" | sort | uniq -c | sort -rn)
if [[ -n "$journal_errors" ]]; then
n=$(awk '{s += $1} END {print s}' <<<"$journal_errors")
top=$(head -n 2 <<<"$journal_errors" | sed -E 's/^\s*[0-9]+\s+//' | cut -c1-120 | paste -sd'|' - | sed 's/|/; /g')
notes+=("${n} error line(s) in the journal since boot, e.g. ${top}")
fi
fi
status="pass"
finish
+4
View File
@@ -593,6 +593,10 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
- Whisparr-eros: set the first login and save it [@MickLesk](https://github.com/MickLesk) ([#18617](https://github.com/community-scripts/ProxmoxVE/pull/18617))
### 📚 Documentation
- pocketbase-bot: apply website metadata requests from Accept/Decline boxes [@MickLesk](https://github.com/MickLesk) ([#18631](https://github.com/community-scripts/ProxmoxVE/pull/18631))
## 2026-11-11
### 🚀 Updated Scripts
+2 -2
View File
@@ -11,8 +11,8 @@ var_tags="${var_tags:-web}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-1024}"
var_disk="${var_disk:-8}"
var_os="${var_os:-Debian}"
var_version="${var_version:-13}"
var_os="${var_os:-ubuntu}"
var_version="${var_version:-24.04}"
var_arm64="${var_arm64:-yes}"
var_unprivileged="${var_unprivileged:-0}"