mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-11-12 18:25:28 +01:00
Compare commits
20
Commits
No files matched your search
Generated
+2
-2
@@ -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
|
||||
|
||||
Generated
+149
-4
@@ -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
|
||||
Generated
+75
-15
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -591,8 +591,18 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
|
||||
|
||||
- #### 🐞 Bug Fixes
|
||||
|
||||
- dotnetaspwebapi: start the service only once an app is deployed [@MickLesk](https://github.com/MickLesk) ([#18639](https://github.com/community-scripts/ProxmoxVE/pull/18639))
|
||||
- Whisparr-eros: set the first login and save it [@MickLesk](https://github.com/MickLesk) ([#18617](https://github.com/community-scripts/ProxmoxVE/pull/18617))
|
||||
|
||||
- #### 💥 Breaking Changes
|
||||
|
||||
- Updated ASP.NET Web App script to Debian and adds a .Net selection [@kris701](https://github.com/kris701) ([#18628](https://github.com/community-scripts/ProxmoxVE/pull/18628))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- github: bump workflow actions to their current majors [@MickLesk](https://github.com/MickLesk) ([#18637](https://github.com/community-scripts/ProxmoxVE/pull/18637))
|
||||
- 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
|
||||
|
||||
@@ -4,17 +4,17 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
|
||||
# Copyright (c) 2021-2026 community-scripts ORG
|
||||
# Author: Kristian Skov
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx?view=aspnetcore-9.0&tabs=linux-ubuntu
|
||||
# Source: https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian
|
||||
|
||||
APP="Dotnet ASP Web API"
|
||||
var_tags="${var_tags:-web}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-1024}"
|
||||
var_disk="${var_disk:-8}"
|
||||
var_os="${var_os:-ubuntu}"
|
||||
var_version="${var_version:-24.04}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-13}"
|
||||
var_arm64="${var_arm64:-yes}"
|
||||
var_unprivileged="${var_unprivileged:-0}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
@@ -43,4 +43,4 @@ description
|
||||
msg_ok "Completed successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW}Access it using the following IP:${CL}"
|
||||
echo -e "${GATEWAY}${BGN}${IP}:80${CL}"
|
||||
echo -e "${GATEWAY}${BGN}${IP}:80${CL}"
|
||||
+69
-204
@@ -38,12 +38,20 @@ function update_script() {
|
||||
exit
|
||||
fi
|
||||
|
||||
if ! grep -qE '(^|[[:space:]])testing([[:space:]]|$)' /etc/apt/sources.list.d/debian.sources 2>/dev/null; then
|
||||
if ! grep -qsE '^[[:space:]]*Suites:.*(^|[[:space:]])testing([[:space:]]|$)' /etc/apt/sources.list.d/*.sources; then
|
||||
msg_info "Adding Debian Testing repo"
|
||||
if grep -q "trixie-updates" /etc/apt/sources.list.d/debian.sources 2>/dev/null; then
|
||||
sed -i 's/ trixie-updates/ trixie-updates testing/g' /etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
elif grep -qsE '^[[:space:]]*Suites:.*trixie' /etc/apt/sources.list.d/debian.sources; then
|
||||
sed -i '/^[[:space:]]*Suites:.*trixie/ s/$/ testing/' /etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
cat <<EOF >/etc/apt/sources.list.d/testing.sources
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: testing
|
||||
Components: main
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
fi
|
||||
cat <<EOF >/etc/apt/preferences.d/preferences
|
||||
Package: *
|
||||
@@ -71,9 +79,6 @@ EOF
|
||||
fetch_and_deploy_gh_release "mise" "jdx/mise" "singlefile" "latest" "/usr/local/bin" "mise-v*-linux-${MISE_ARCH}"
|
||||
fi
|
||||
|
||||
STAGING_DIR=/opt/staging
|
||||
BASE_DIR=${STAGING_DIR}/base-images
|
||||
SOURCE_DIR=${STAGING_DIR}/image-source
|
||||
cd /tmp
|
||||
RELEASE="v3.4.1"
|
||||
if [[ -f ~/.intel_version ]]; then
|
||||
@@ -100,21 +105,8 @@ EOF
|
||||
msg_ok "Updated Intel OpenVINO dependencies"
|
||||
fi
|
||||
fi
|
||||
# Without the revision file the refresh was skipped and sharp failed on an old libvips (#17914).
|
||||
[[ -f ~/.immich_library_revisions ]] || msg_warn "No record of the compiled image-processing libraries, rebuilding all of them"
|
||||
for lib in imagemagick jpegli libheif libjxl libraw libvips; do
|
||||
grep -q "^${lib}:" ~/.immich_library_revisions 2>/dev/null || echo "${lib}: unknown" >>~/.immich_library_revisions
|
||||
done
|
||||
[[ -d "$BASE_DIR/.git" ]] || $STD git clone -b main https://github.com/immich-app/base-images "$BASE_DIR"
|
||||
libraries=("libjxl" "jpegli" "libheif" "libraw" "imagemagick" "libvips")
|
||||
cd "$BASE_DIR"
|
||||
msg_warn "Checking for updates to custom image-processing libraries (recompile time: 2-15min per library)"
|
||||
ensure_dependencies liblcms2-dev libjpeg62-turbo-dev libspng-dev libexif-dev libopenjp2-7-dev pigz
|
||||
$STD git pull
|
||||
for library in "${libraries[@]}"; do
|
||||
compile_"$library"
|
||||
done
|
||||
msg_ok "Image-processing libraries up to date"
|
||||
# Libraries the prebuilt image-processing libraries link against; pigz compresses database backups.
|
||||
ensure_dependencies liblcms2-dev libjpeg62-turbo-dev libspng-dev libexif-dev libopenjp2-7-dev libaom-dev pigz
|
||||
|
||||
# 3.3.0 reads geodata/countryInfo.txt; the update before #17823 saved it into app/ instead.
|
||||
if [[ -d /opt/immich/geodata && ! -s /opt/immich/geodata/countryInfo.txt ]]; then
|
||||
@@ -131,6 +123,7 @@ EOF
|
||||
|
||||
if check_for_gh_release "Immich" "immich-app/immich" "${RELEASE}" "each release is tested individually before the version is updated. Please do not open issues for this"; then
|
||||
msg_warn "The update takes 5-15 minutes, do not interrupt it"
|
||||
fetch_prebuilt_libs
|
||||
PREV_IMMICH="${CS_PREVIOUS_VERSION:-$(cat ~/.immich)}"
|
||||
INSTALL_DIR="/opt/${APP}"
|
||||
UPLOAD_DIR="$(sed -n '/^IMMICH_MEDIA_LOCATION/s/[^=]*=//p' /opt/immich/.env)"
|
||||
@@ -157,6 +150,7 @@ EOF
|
||||
systemctl stop immich-web
|
||||
systemctl stop immich-ml
|
||||
msg_ok "Stopped Services"
|
||||
install_prebuilt_libs
|
||||
VCHORD_RELEASE="1.1.1"
|
||||
PG_VERSION=$(ls /etc/postgresql/ 2>/dev/null | sort -V | tail -1)
|
||||
PG_VERSION=${PG_VERSION:-16}
|
||||
@@ -174,7 +168,6 @@ EOF
|
||||
$STD sudo -u postgres psql -d immich -c "REINDEX INDEX face_index;"
|
||||
$STD sudo -u postgres psql -d immich -c "REINDEX INDEX clip_index;"
|
||||
fi
|
||||
ensure_dependencies ccache gcc-13 g++-13
|
||||
|
||||
[[ -f "$ML_DIR"/ml_start.sh ]] && cp "$ML_DIR"/ml_start.sh "$INSTALL_DIR"
|
||||
if grep -qs "set -a" "$APP_DIR"/bin/start.sh && grep -qs "warnings" "$APP_DIR"/bin/start.sh; then
|
||||
@@ -434,193 +427,65 @@ EOF
|
||||
exit
|
||||
}
|
||||
|
||||
function compile_libjxl() {
|
||||
SOURCE=${SOURCE_DIR}/libjxl
|
||||
LIBJXL_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libjxl.json)"
|
||||
if [[ "$LIBJXL_REVISION" != "$(grep 'libjxl' ~/.immich_library_revisions | awk '{print $2}')" ]]; then
|
||||
msg_info "Recompiling libjxl"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/libjxl/libjxl.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBJXL_REVISION"
|
||||
$STD git submodule update --init --recursive --depth 1 --recommend-shallow -- third_party
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DJPEGXL_ENABLE_DOXYGEN=OFF \
|
||||
-DJPEGXL_ENABLE_MANPAGES=OFF \
|
||||
-DJPEGXL_ENABLE_BENCHMARK=OFF \
|
||||
-DJPEGXL_ENABLE_EXAMPLES=OFF \
|
||||
-DJPEGXL_FORCE_SYSTEM_BROTLI=ON \
|
||||
-DJPEGXL_FORCE_SYSTEM_HWY=ON \
|
||||
-DJPEGXL_ENABLE_HWY_AVX3=ON \
|
||||
-DJPEGXL_ENABLE_HWY_AVX3_ZEN4=ON \
|
||||
-DJPEGXL_ENABLE_HWY_SVE=OFF \
|
||||
-DJPEGXL_ENABLE_HWY_SVE2=OFF \
|
||||
-DJPEGXL_ENABLE_HWY_SVE2_128=ON \
|
||||
-DJPEGXL_ENABLE_PLUGINS=ON \
|
||||
..
|
||||
$STD cmake --build . -- -j"$(nproc)"
|
||||
$STD cmake --install .
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/{build,third_party}
|
||||
sed -i "s/libjxl: .*$/libjxl: $LIBJXL_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled libjxl"
|
||||
# Immich ships the custom photo-processing libraries (libjxl, jpegli, libheif,
|
||||
# libraw, ImageMagick, libvips) prebuilt in its own OCI base image; the release's
|
||||
# Dockerfile pins it. fetch_prebuilt_libs downloads and unpacks it while Immich
|
||||
# still runs, so a failure leaves the install as it was. install_prebuilt_libs
|
||||
# copies it into /usr/local once the services are stopped.
|
||||
function fetch_prebuilt_libs() {
|
||||
PREBUILT_BUNDLE=""
|
||||
BASE_REF="$(curl -fsSL "https://raw.githubusercontent.com/immich-app/immich/${RELEASE}/server/Dockerfile" | awk '/^FROM ghcr.io\/immich-app\/base-server-dev/ {print $2; exit}')" || true
|
||||
if [[ -z "$BASE_REF" ]]; then
|
||||
msg_error "Could not read the base image from Immich ${RELEASE}'s Dockerfile. Immich was not updated."
|
||||
exit 115
|
||||
fi
|
||||
[[ "$(cat ~/.immich_base_image 2>/dev/null)" == "$BASE_REF" ]] && return 0
|
||||
msg_info "Fetching prebuilt image-processing libraries"
|
||||
# skopeo refuses a reference with both a tag and a digest; the digest is the pin.
|
||||
local image="$BASE_REF"
|
||||
[[ "$BASE_REF" == *@sha256:* ]] && image="ghcr.io/immich-app/base-server-dev@${BASE_REF#*@}"
|
||||
if ! { command -v skopeo && command -v umoci; } >/dev/null 2>&1 &&
|
||||
! $STD apt install -y --no-install-recommends skopeo umoci; then
|
||||
msg_error "Could not install skopeo and umoci, which Immich needs for its image-processing libraries. Immich was not updated."
|
||||
exit 105
|
||||
fi
|
||||
local oci_dir
|
||||
oci_dir="$(mktemp -d)"
|
||||
PREBUILT_BUNDLE="$(mktemp -d)"
|
||||
if ! $STD skopeo copy --override-os linux --override-arch "$(arch_resolve amd64 arm64)" "docker://${image}" "oci:${oci_dir}:img"; then
|
||||
rm -rf "$oci_dir" "$PREBUILT_BUNDLE"
|
||||
msg_error "Could not download Immich's base image (${image}). Immich was not updated."
|
||||
exit 115
|
||||
fi
|
||||
if ! $STD umoci unpack --image "${oci_dir}:img" "$PREBUILT_BUNDLE"; then
|
||||
rm -rf "$oci_dir" "$PREBUILT_BUNDLE"
|
||||
msg_error "Could not unpack Immich's base image. Immich needs Proxmox VE 9.1 or newer; it was not updated."
|
||||
exit 105
|
||||
fi
|
||||
rm -rf "$oci_dir"
|
||||
msg_ok "Fetched prebuilt image-processing libraries"
|
||||
}
|
||||
|
||||
function compile_jpegli() {
|
||||
SOURCE=${SOURCE_DIR}/jpegli
|
||||
JPEGLI_LIBJPEG_LIBRARY_SOVERSION="62"
|
||||
JPEGLI_LIBJPEG_LIBRARY_VERSION="62.3.0"
|
||||
JPEGLI_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/jpegli.json)"
|
||||
if [[ "$JPEGLI_REVISION" != "$(grep 'jpegli' ~/.immich_library_revisions | awk '{print $2}')" ]]; then
|
||||
msg_info "Recompiling jpegli"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/google/jpegli.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$JPEGLI_REVISION"
|
||||
$STD git submodule update --init --depth 1 --recommend-shallow third_party/libjpeg-turbo
|
||||
$STD git apply -3 "$BASE_DIR"/server/sources/jpegli-patches/jpegli-empty-dht-marker.patch
|
||||
$STD git apply -3 "$BASE_DIR"/server/sources/jpegli-patches/jpegli-icc-warning.patch
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DJPEGLI_ENABLE_DOXYGEN=OFF \
|
||||
-DJPEGLI_ENABLE_MANPAGES=OFF \
|
||||
-DJPEGLI_ENABLE_BENCHMARK=OFF \
|
||||
-DJPEGLI_ENABLE_TOOLS=OFF \
|
||||
-DJPEGLI_ENABLE_DEVTOOLS=OFF \
|
||||
-DJPEGLI_ENABLE_FUZZERS=OFF \
|
||||
-DJPEGLI_ENABLE_JNI=OFF \
|
||||
-DJPEGLI_ENABLE_OPENEXR=OFF \
|
||||
-DJPEGLI_ENABLE_SJPEG=OFF \
|
||||
-DJPEGLI_ENABLE_SKCMS=OFF \
|
||||
-DJPEGLI_FORCE_SYSTEM_HWY=ON \
|
||||
-DJPEGLI_FORCE_SYSTEM_LCMS2=ON \
|
||||
-DJPEGLI_ENABLE_JPEGLI_LIBJPEG=ON \
|
||||
-DJPEGLI_INSTALL_JPEGLI_LIBJPEG=ON \
|
||||
-DJPEGLI_ENABLE_HWY_AVX3=ON \
|
||||
-DJPEGLI_ENABLE_HWY_AVX3_ZEN4=ON \
|
||||
-DJPEGLI_ENABLE_HWY_SVE=OFF \
|
||||
-DJPEGLI_ENABLE_HWY_SVE2=OFF \
|
||||
-DJPEGLI_ENABLE_HWY_SVE2_128=ON \
|
||||
-DJPEGLI_LIBJPEG_LIBRARY_SOVERSION="$JPEGLI_LIBJPEG_LIBRARY_SOVERSION" \
|
||||
-DJPEGLI_LIBJPEG_LIBRARY_VERSION="$JPEGLI_LIBJPEG_LIBRARY_VERSION" \
|
||||
-DLIBJPEG_TURBO_VERSION_NUMBER=2001005 \
|
||||
..
|
||||
$STD cmake --build . -- -j"$(nproc)"
|
||||
$STD cmake --install .
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/{build,third_party}
|
||||
sed -i "s/jpegli: .*$/jpegli: $JPEGLI_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled jpegli"
|
||||
fi
|
||||
}
|
||||
|
||||
function compile_libheif() {
|
||||
SOURCE=${SOURCE_DIR}/libheif
|
||||
ensure_dependencies libaom-dev
|
||||
LIBHEIF_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libheif.json)"
|
||||
if [[ "${update:-}" ]] || [[ "$LIBHEIF_REVISION" != "$(grep 'libheif' ~/.immich_library_revisions | awk '{print $2}')" ]]; then
|
||||
msg_info "Recompiling libheif"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/strukturag/libheif.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBHEIF_REVISION"
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake --preset=release-noplugins \
|
||||
-DWITH_DAV1D=ON \
|
||||
-DENABLE_PARALLEL_TILE_DECODING=ON \
|
||||
-DWITH_LIBSHARPYUV=ON \
|
||||
-DWITH_LIBDE265=ON \
|
||||
-DWITH_AOM_DECODER=OFF \
|
||||
-DWITH_AOM_ENCODER=ON \
|
||||
-DWITH_X265=OFF \
|
||||
-DWITH_EXAMPLES=OFF \
|
||||
..
|
||||
$STD make install -j"$(nproc)"
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/build
|
||||
sed -i "s/libheif: .*$/libheif: $LIBHEIF_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled libheif"
|
||||
fi
|
||||
}
|
||||
|
||||
function compile_libraw() {
|
||||
SOURCE=${SOURCE_DIR}/libraw
|
||||
LIBRAW_REVISION="e419de08001de28ae6988ecb22df47e52b9c5eaa"
|
||||
# : "${LIBRAW_REVISION:=$(jq -cr '.revision' "$BASE_DIR"/server/sources/libraw.json)}"
|
||||
if [[ "$LIBRAW_REVISION" != "$(grep 'libraw' ~/.immich_library_revisions | awk '{print $2}')" ]]; then
|
||||
msg_info "Recompiling libraw"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/LibRaw/LibRaw.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBRAW_REVISION"
|
||||
$STD autoreconf --install
|
||||
$STD ./configure --disable-examples
|
||||
$STD make -j"$(nproc)"
|
||||
$STD make install
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
sed -i "s/libraw: .*$/libraw: $LIBRAW_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled libraw"
|
||||
fi
|
||||
}
|
||||
|
||||
function compile_imagemagick() {
|
||||
SOURCE=$SOURCE_DIR/imagemagick
|
||||
: "${IMAGEMAGICK_REVISION:=$(jq -cr '.revision' "$BASE_DIR"/server/sources/imagemagick.json)}"
|
||||
if [[ "$IMAGEMAGICK_REVISION" != "$(grep 'imagemagick' ~/.immich_library_revisions | awk '{print $2}')" ]] ||
|
||||
! grep -q 'DMAGICK_LIBRAW' /usr/local/lib/ImageMagick-7*/config-Q16HDRI/configure.xml; then
|
||||
msg_info "Recompiling ImageMagick"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/ImageMagick/ImageMagick.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$IMAGEMAGICK_REVISION"
|
||||
$STD ./configure --with-modules CPPFLAGS="-DMAGICK_LIBRAW_VERSION_TAIL=202502"
|
||||
$STD make -j"$(nproc)"
|
||||
$STD make install
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
sed -i "s/imagemagick: .*$/imagemagick: $IMAGEMAGICK_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled ImageMagick"
|
||||
fi
|
||||
}
|
||||
|
||||
function compile_libvips() {
|
||||
SOURCE=$SOURCE_DIR/libvips
|
||||
LIBVIPS_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libvips.json)"
|
||||
if [[ "$LIBVIPS_REVISION" != "$(grep 'libvips' ~/.immich_library_revisions | awk '{print $2}')" ]] ||
|
||||
[[ "$(vips -l foreign 2>/dev/null)" != *jp2kload* ]]; then
|
||||
msg_info "Recompiling libvips"
|
||||
[[ -d "$SOURCE" ]] && rm -rf "$SOURCE"
|
||||
$STD git clone https://github.com/libvips/libvips.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBVIPS_REVISION"
|
||||
$STD git apply "$BASE_DIR"/server/sources/libvips-patches/0001-put-other-loaders-ahead-of-dcrawload.patch
|
||||
$STD meson setup build --buildtype=release --libdir=lib -Dintrospection=disabled -Dtiff=disabled
|
||||
cd build
|
||||
$STD ninja install
|
||||
ldconfig /usr/local/lib
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/build
|
||||
sed -i "s/libvips: .*$/libvips: $LIBVIPS_REVISION/" ~/.immich_library_revisions
|
||||
msg_ok "Recompiled libvips"
|
||||
function install_prebuilt_libs() {
|
||||
[[ -n "${PREBUILT_BUNDLE:-}" ]] || return 0
|
||||
msg_info "Installing prebuilt image-processing libraries"
|
||||
# The libraries, headers and ImageMagick data, without the node runtime, global
|
||||
# node modules and ffmpeg of the node base image: this script provides those.
|
||||
tar -C "${PREBUILT_BUNDLE}/rootfs/usr/local" \
|
||||
--exclude=./bin/node --exclude=./bin/npm --exclude=./bin/npx --exclude=./bin/corepack \
|
||||
--exclude=./bin/ffmpeg --exclude=./bin/ffprobe \
|
||||
--exclude=./lib/node_modules --exclude=./include/node \
|
||||
-cf - . | tar -C /usr/local -xf -
|
||||
rm -rf "$PREBUILT_BUNDLE"
|
||||
ldconfig /usr/local/lib
|
||||
if ! vips --version >/dev/null 2>&1 || ! pkg-config --exists vips; then
|
||||
msg_error "The prebuilt libvips from ${BASE_REF} does not work in this container."
|
||||
exit 1
|
||||
fi
|
||||
echo "$BASE_REF" >~/.immich_base_image
|
||||
# Leftovers of the libraries this script used to compile.
|
||||
rm -rf /opt/staging/base-images /opt/staging/image-source ~/.immich_library_revisions
|
||||
msg_ok "Installed prebuilt image-processing libraries"
|
||||
}
|
||||
|
||||
start
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Copyright (c) 2021-2026 community-scripts ORG
|
||||
# Author: Kristian Skov
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx?view=aspnetcore-9.0&tabs=linux-ubuntu
|
||||
# Source: https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
@@ -14,21 +14,36 @@ network_check
|
||||
update_os
|
||||
|
||||
msg_info "Installing Dependencies"
|
||||
apt_update_safe
|
||||
$STD apt install -y \
|
||||
ssh \
|
||||
software-properties-common
|
||||
|
||||
$STD add-apt-repository -y ppa:dotnet/backports
|
||||
$STD apt install -y \
|
||||
dotnet-sdk-9.0 \
|
||||
vsftpd \
|
||||
nginx
|
||||
msg_ok "Installed Dependencies"
|
||||
|
||||
var_project_name="${var_project_name:-}"
|
||||
[[ -n "$var_project_name" ]] || read -r -p "${TAB3}Type the assembly name of the project: " var_project_name || true
|
||||
[[ -n "$var_project_name" ]] || { msg_error "No assembly name given; set var_project_name or answer the prompt"; exit 64; }
|
||||
setup_deb822_repo \
|
||||
"microsoft" \
|
||||
"https://packages.microsoft.com/keys/microsoft-2025.asc" \
|
||||
"https://packages.microsoft.com/debian/13/prod/" \
|
||||
"trixie" \
|
||||
"main"
|
||||
|
||||
if [[ -z "${var_dotnet_version:-}" && -t 0 ]]; then
|
||||
read -r -t 60 -p "${TAB3}.NET SDK version, 10 (LTS) or 11 [10]: " var_dotnet_version || echo
|
||||
fi
|
||||
var_dotnet_version="${var_dotnet_version:-10}"
|
||||
var_dotnet_version="${var_dotnet_version%%.*}"
|
||||
if ! apt-cache show "dotnet-sdk-${var_dotnet_version}.0" &>/dev/null; then
|
||||
msg_warn ".NET SDK ${var_dotnet_version} is not available for this system, using 10"
|
||||
var_dotnet_version="10"
|
||||
fi
|
||||
|
||||
if [[ -z "${var_project_name:-}" && -t 0 ]]; then
|
||||
read -r -t 60 -p "${TAB3}Assembly name of the project [testproject]: " var_project_name || echo
|
||||
fi
|
||||
var_project_name="${var_project_name:-testproject}"
|
||||
|
||||
msg_info "Installing .NET SDK ${var_dotnet_version}"
|
||||
$STD apt install -y "dotnet-sdk-${var_dotnet_version}.0"
|
||||
msg_ok "Installed .NET SDK ${var_dotnet_version}"
|
||||
|
||||
msg_info "Setting up FTP Server"
|
||||
useradd ftpuser
|
||||
@@ -55,7 +70,7 @@ msg_ok "FTP server setup completed"
|
||||
msg_info "Setting up Nginx Server"
|
||||
rm -f /var/www/html/index.nginx-debian.html
|
||||
|
||||
sed "s/\$var_project_name/$var_project_name/g" >myfile <<'EOF' >/etc/nginx/sites-available/default
|
||||
sed "s/\$var_project_name/$var_project_name/g" <<'EOF' >/etc/nginx/sites-available/default
|
||||
map $http_connection $connection_upgrade {
|
||||
"~*Upgrade" $http_connection;
|
||||
default keep-alive;
|
||||
@@ -82,12 +97,12 @@ msg_info "Creating Service"
|
||||
cat <<EOF >/etc/systemd/system/kestrel-aspnetapi.service
|
||||
[Unit]
|
||||
Description=.NET Web API App running on Linux
|
||||
ConditionPathExists=/var/www/html/$var_project_name.dll
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/var/www/html
|
||||
ExecStart=/usr/bin/dotnet /var/www/html/$var_project_name.dll
|
||||
Restart=always
|
||||
# Restart service after 10 seconds if the dotnet service crashes:
|
||||
RestartSec=10
|
||||
KillSignal=SIGINT
|
||||
SyslogIdentifier=dotnet-${var_project_name}
|
||||
@@ -98,7 +113,18 @@ Environment=DOTNET_NOLOGO=true
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl enable -q --now kestrel-aspnetapi
|
||||
cat <<EOF >/etc/systemd/system/kestrel-aspnetapi.path
|
||||
[Unit]
|
||||
Description=Start the .NET Web API App once it is deployed
|
||||
|
||||
[Path]
|
||||
PathExists=/var/www/html/$var_project_name.dll
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl enable -q kestrel-aspnetapi
|
||||
systemctl enable -q --now kestrel-aspnetapi.path
|
||||
msg_ok "Created Service"
|
||||
|
||||
motd_ssh
|
||||
|
||||
+45
-177
@@ -69,13 +69,9 @@ $STD apt install --no-install-recommends -y \
|
||||
git \
|
||||
redis \
|
||||
pigz \
|
||||
autoconf \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
automake \
|
||||
cmake \
|
||||
jq \
|
||||
libtool \
|
||||
libltdl-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libbrotli-dev \
|
||||
@@ -96,8 +92,6 @@ $STD apt install --no-install-recommends -y \
|
||||
libltdl7 \
|
||||
libopenjp2-7 \
|
||||
libopenjp2-7-dev \
|
||||
meson \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
mesa-utils \
|
||||
mesa-va-drivers \
|
||||
@@ -114,8 +108,7 @@ $STD apt install --no-install-recommends -y \
|
||||
libhwy-dev \
|
||||
libwebp-dev \
|
||||
libaom-dev \
|
||||
unzip \
|
||||
ccache
|
||||
unzip
|
||||
|
||||
setup_deb822_repo \
|
||||
"jellyfin" \
|
||||
@@ -202,179 +195,54 @@ sed -i "s/^#shared_preload.*/shared_preload_libraries = 'vchord.so'/" /etc/postg
|
||||
systemctl restart postgresql.service
|
||||
PG_DB_NAME="immich" PG_DB_USER="immich" PG_DB_GRANT_SUPERUSER="true" PG_DB_SKIP_ALTER_ROLE="true" setup_postgresql_db
|
||||
|
||||
msg_info "Installing GCC-13 (available as fallback compiler)"
|
||||
$STD apt install -y gcc-13 g++-13
|
||||
msg_ok "Installed GCC-13"
|
||||
|
||||
msg_warn "Compiling Custom Photo-processing Libraries (can take anywhere from 15min to 2h)"
|
||||
LD_LIBRARY_PATH=/usr/local/lib
|
||||
export LD_RUN_PATH=/usr/local/lib
|
||||
STAGING_DIR=/opt/staging
|
||||
BASE_REPO="https://github.com/immich-app/base-images"
|
||||
BASE_DIR=${STAGING_DIR}/base-images
|
||||
SOURCE_DIR=${STAGING_DIR}/image-source
|
||||
$STD git clone -b main "$BASE_REPO" "$BASE_DIR"
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
|
||||
msg_info "(1/6) Compiling libjxl"
|
||||
cd "$STAGING_DIR"
|
||||
SOURCE=${SOURCE_DIR}/libjxl
|
||||
LIBJXL_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libjxl.json)"
|
||||
$STD git clone https://github.com/libjxl/libjxl.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBJXL_REVISION"
|
||||
$STD git submodule update --init --recursive --depth 1 --recommend-shallow -- third_party
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DJPEGXL_ENABLE_DOXYGEN=OFF \
|
||||
-DJPEGXL_ENABLE_MANPAGES=OFF \
|
||||
-DJPEGXL_ENABLE_BENCHMARK=OFF \
|
||||
-DJPEGXL_ENABLE_EXAMPLES=OFF \
|
||||
-DJPEGXL_FORCE_SYSTEM_BROTLI=ON \
|
||||
-DJPEGXL_FORCE_SYSTEM_HWY=ON \
|
||||
-DJPEGXL_ENABLE_HWY_AVX3=ON \
|
||||
-DJPEGXL_ENABLE_HWY_AVX3_ZEN4=ON \
|
||||
-DJPEGXL_ENABLE_HWY_SVE=OFF \
|
||||
-DJPEGXL_ENABLE_HWY_SVE2=OFF \
|
||||
-DJPEGXL_ENABLE_HWY_SVE2_128=ON \
|
||||
-DJPEGXL_ENABLE_PLUGINS=ON \
|
||||
..
|
||||
$STD cmake --build . -- -j"$(nproc)"
|
||||
$STD cmake --install .
|
||||
# Immich ships the custom photo-processing libraries (libjxl, jpegli, libheif,
|
||||
# libraw, ImageMagick, libvips) prebuilt in its own OCI base image. Pull the one
|
||||
# the release's Dockerfile pins (skopeo + umoci, no container runtime) and use its
|
||||
# /usr/local. There is no compile fallback: this needs Proxmox VE 9.1 or newer.
|
||||
msg_info "Deploying prebuilt image-processing libraries"
|
||||
BASE_REF="$(curl -fsSL "https://raw.githubusercontent.com/immich-app/immich/${RELEASE}/server/Dockerfile" | awk '/^FROM ghcr.io\/immich-app\/base-server-dev/ {print $2; exit}')" || true
|
||||
if [[ -z "$BASE_REF" ]]; then
|
||||
msg_error "Could not read the base image from Immich ${RELEASE}'s Dockerfile."
|
||||
exit 115
|
||||
fi
|
||||
# skopeo refuses a reference with both a tag and a digest; the digest is the pin.
|
||||
BASE_IMAGE="$BASE_REF"
|
||||
[[ "$BASE_REF" == *@sha256:* ]] && BASE_IMAGE="ghcr.io/immich-app/base-server-dev@${BASE_REF#*@}"
|
||||
if ! $STD apt install -y --no-install-recommends skopeo umoci; then
|
||||
msg_error "Could not install skopeo and umoci, which Immich needs for its image-processing libraries."
|
||||
exit 105
|
||||
fi
|
||||
OCI_DIR="$(mktemp -d)"
|
||||
OCI_BUNDLE="$(mktemp -d)"
|
||||
if ! $STD skopeo copy --override-os linux --override-arch "$(arch_resolve amd64 arm64)" "docker://${BASE_IMAGE}" "oci:${OCI_DIR}:img"; then
|
||||
rm -rf "$OCI_DIR" "$OCI_BUNDLE"
|
||||
msg_error "Could not download Immich's base image (${BASE_IMAGE})."
|
||||
exit 115
|
||||
fi
|
||||
if ! $STD umoci unpack --image "${OCI_DIR}:img" "$OCI_BUNDLE"; then
|
||||
rm -rf "$OCI_DIR" "$OCI_BUNDLE"
|
||||
msg_error "Could not unpack Immich's base image. Immich needs Proxmox VE 9.1 or newer."
|
||||
exit 105
|
||||
fi
|
||||
# The libraries, headers and ImageMagick data, without the node runtime, global
|
||||
# node modules and ffmpeg of the node base image: this script provides those.
|
||||
tar -C "${OCI_BUNDLE}/rootfs/usr/local" \
|
||||
--exclude=./bin/node --exclude=./bin/npm --exclude=./bin/npx --exclude=./bin/corepack \
|
||||
--exclude=./bin/ffmpeg --exclude=./bin/ffprobe \
|
||||
--exclude=./lib/node_modules --exclude=./include/node \
|
||||
-cf - . | tar -C /usr/local -xf -
|
||||
rm -rf "$OCI_DIR" "$OCI_BUNDLE"
|
||||
export LD_LIBRARY_PATH=/usr/local/lib
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/{build,third_party}
|
||||
msg_ok "(1/6) Compiled libjxl"
|
||||
|
||||
msg_info "(2/6) Compiling jpegli"
|
||||
SOURCE=${SOURCE_DIR}/jpegli
|
||||
JPEGLI_LIBJPEG_LIBRARY_SOVERSION="62"
|
||||
JPEGLI_LIBJPEG_LIBRARY_VERSION="62.3.0"
|
||||
JPEGLI_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/jpegli.json)"
|
||||
$STD git clone https://github.com/google/jpegli.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$JPEGLI_REVISION"
|
||||
$STD git submodule update --init --depth 1 --recommend-shallow third_party/libjpeg-turbo
|
||||
$STD git apply -3 "$BASE_DIR"/server/sources/jpegli-patches/jpegli-empty-dht-marker.patch
|
||||
$STD git apply -3 "$BASE_DIR"/server/sources/jpegli-patches/jpegli-icc-warning.patch
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DJPEGLI_ENABLE_DOXYGEN=OFF \
|
||||
-DJPEGLI_ENABLE_MANPAGES=OFF \
|
||||
-DJPEGLI_ENABLE_BENCHMARK=OFF \
|
||||
-DJPEGLI_ENABLE_TOOLS=OFF \
|
||||
-DJPEGLI_ENABLE_DEVTOOLS=OFF \
|
||||
-DJPEGLI_ENABLE_FUZZERS=OFF \
|
||||
-DJPEGLI_ENABLE_JNI=OFF \
|
||||
-DJPEGLI_ENABLE_OPENEXR=OFF \
|
||||
-DJPEGLI_ENABLE_SJPEG=OFF \
|
||||
-DJPEGLI_ENABLE_SKCMS=OFF \
|
||||
-DJPEGLI_FORCE_SYSTEM_HWY=ON \
|
||||
-DJPEGLI_FORCE_SYSTEM_LCMS2=ON \
|
||||
-DJPEGLI_ENABLE_JPEGLI_LIBJPEG=ON \
|
||||
-DJPEGLI_INSTALL_JPEGLI_LIBJPEG=ON \
|
||||
-DJPEGLI_ENABLE_HWY_AVX3=ON \
|
||||
-DJPEGLI_ENABLE_HWY_AVX3_ZEN4=ON \
|
||||
-DJPEGLI_ENABLE_HWY_SVE=OFF \
|
||||
-DJPEGLI_ENABLE_HWY_SVE2=OFF \
|
||||
-DJPEGLI_ENABLE_HWY_SVE2_128=ON \
|
||||
-DJPEGLI_LIBJPEG_LIBRARY_SOVERSION="$JPEGLI_LIBJPEG_LIBRARY_SOVERSION" \
|
||||
-DJPEGLI_LIBJPEG_LIBRARY_VERSION="$JPEGLI_LIBJPEG_LIBRARY_VERSION" \
|
||||
-DLIBJPEG_TURBO_VERSION_NUMBER=2001005 \
|
||||
..
|
||||
$STD cmake --build . -- -j"$(nproc)"
|
||||
$STD cmake --install .
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/{build,third_party}
|
||||
msg_ok "(2/6) Compiled jpegli"
|
||||
|
||||
msg_info "(3/6) Compiling libheif"
|
||||
SOURCE=${SOURCE_DIR}/libheif
|
||||
LIBHEIF_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libheif.json)"
|
||||
$STD git clone https://github.com/strukturag/libheif.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBHEIF_REVISION"
|
||||
mkdir build
|
||||
cd build
|
||||
$STD cmake --preset=release-noplugins \
|
||||
-DWITH_DAV1D=ON \
|
||||
-DENABLE_PARALLEL_TILE_DECODING=ON \
|
||||
-DWITH_LIBSHARPYUV=ON \
|
||||
-DWITH_LIBDE265=ON \
|
||||
-DWITH_AOM_DECODER=OFF \
|
||||
-DWITH_AOM_ENCODER=ON \
|
||||
-DWITH_X265=OFF \
|
||||
-DWITH_EXAMPLES=OFF \
|
||||
..
|
||||
$STD make install -j"$(nproc)"
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/build
|
||||
msg_ok "(3/6) Compiled libheif"
|
||||
|
||||
msg_info "(4/6) Compiling libraw"
|
||||
SOURCE=${SOURCE_DIR}/libraw
|
||||
LIBRAW_REVISION="e419de08001de28ae6988ecb22df47e52b9c5eaa"
|
||||
# : "${LIBRAW_REVISION:=$(jq -cr '.revision' $BASE_DIR/server/sources/libraw.json)}"
|
||||
$STD git clone https://github.com/LibRaw/LibRaw.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBRAW_REVISION"
|
||||
$STD autoreconf --install
|
||||
$STD ./configure --disable-examples
|
||||
$STD make -j"$(nproc)"
|
||||
$STD make install
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
msg_ok "(4/6) Compiled libraw"
|
||||
|
||||
msg_info "(5/6) Compiling imagemagick"
|
||||
SOURCE=$SOURCE_DIR/imagemagick
|
||||
: "${IMAGEMAGICK_REVISION:=$(jq -cr '.revision' $BASE_DIR/server/sources/imagemagick.json)}"
|
||||
$STD git clone https://github.com/ImageMagick/ImageMagick.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$IMAGEMAGICK_REVISION"
|
||||
$STD ./configure --with-modules CPPFLAGS="-DMAGICK_LIBRAW_VERSION_TAIL=202502"
|
||||
$STD make -j"$(nproc)"
|
||||
$STD make install
|
||||
ldconfig /usr/local/lib
|
||||
$STD make clean
|
||||
cd "$STAGING_DIR"
|
||||
msg_ok "(5/6) Compiled imagemagick"
|
||||
|
||||
msg_info "(6/6) Compiling libvips"
|
||||
SOURCE=$SOURCE_DIR/libvips
|
||||
LIBVIPS_REVISION="$(jq -cr '.revision' "$BASE_DIR"/server/sources/libvips.json)"
|
||||
$STD git clone https://github.com/libvips/libvips.git "$SOURCE"
|
||||
cd "$SOURCE"
|
||||
$STD git reset --hard "$LIBVIPS_REVISION"
|
||||
$STD git apply "$BASE_DIR"/server/sources/libvips-patches/0001-put-other-loaders-ahead-of-dcrawload.patch
|
||||
$STD meson setup build --buildtype=release --libdir=lib -Dintrospection=disabled -Dtiff=disabled
|
||||
cd build
|
||||
$STD ninja install
|
||||
ldconfig /usr/local/lib
|
||||
cd "$STAGING_DIR"
|
||||
rm -rf "$SOURCE"/build
|
||||
msg_ok "(6/6) Compiled libvips"
|
||||
cat <<EOF >~/.immich_library_revisions
|
||||
imagemagick: $IMAGEMAGICK_REVISION
|
||||
jpegli: $JPEGLI_REVISION
|
||||
libheif: $LIBHEIF_REVISION
|
||||
libjxl: $LIBJXL_REVISION
|
||||
libraw: $LIBRAW_REVISION
|
||||
libvips: $LIBVIPS_REVISION
|
||||
EOF
|
||||
msg_ok "Custom Photo-processing Libraries Compiled Successfully"
|
||||
if ! vips --version >/dev/null 2>&1 || ! pkg-config --exists vips; then
|
||||
msg_error "The prebuilt libvips from ${BASE_IMAGE} does not work in this container."
|
||||
exit 1
|
||||
fi
|
||||
echo "$BASE_REF" >~/.immich_base_image
|
||||
msg_ok "Deployed prebuilt image-processing libraries"
|
||||
|
||||
fetch_and_deploy_gh_release "Immich" "immich-app/immich" "tarball" "${RELEASE}" "$SRC_DIR"
|
||||
PNPM_VERSION="$(jq -r '.packageManager | split("@")[1] | split("+")[0]' ${SRC_DIR}/package.json)"
|
||||
|
||||
Reference in new issue
Block a user