Compare commits

...
Author SHA1 Message Date
Rögl-Brunner Michel afef3ff79f immich: use only the prebuilt image-processing libraries
Drop compiling libjxl, jpegli, libheif, libraw, ImageMagick and libvips from
source. The install pulls them from the base image the release's Dockerfile
pins and stops with a clear error when it can't (skopeo/umoci need Proxmox VE
9.1+). The update fetches and unpacks the new image before it stops or changes
anything, so a failure leaves the running install as it is, and installs it
only when the Immich release changes. ~/.immich_base_image records the image;
the old compile leftovers and ~/.immich_library_revisions are removed.
2026-11-12 15:53:41 +01:00
Rögl-Brunner Michel e96fa5bd50 Merge remote-tracking branch 'origin/main' into immich-prebuilt-libs 2026-11-12 15:51:01 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] c4493b2671 Update CHANGELOG.md (#18641)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 14:48:41 +00:00
CanbiZ (MickLesk) 445ecd6da7 dotnetaspwebapi: start the service only once an app is deployed (#18639) 2026-11-12 15:47:59 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 1b21c6a585 Update CHANGELOG.md (#18638)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 13:53:20 +00:00
CanbiZ (MickLesk) 1d119abb23 Bump workflow actions to their current majors (#18637) 2026-11-12 14:52:54 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 0f00ec055f Update CHANGELOG.md (#18636)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 13:36:36 +00:00
Kristian SkovandCanbiZ 005f52a2ac Updated ASP.NET Web App script to Debian and adds a .Net selection (#18628)
* Updated scripts to Debian and adds a .Net selection

* Uses `fetch_and_deploy_from_url` instead of wget

* Cleanup

* Sets defaults for variables

* Removed unused error message for missing project name

* Unbound variable fix

* Improve .NET SDK installation script

Refactor .NET SDK installation process and update prompts.

* Update Microsoft repository keys and Nginx config

---------

Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-11-12 14:35:38 +01:00
Rögl-Brunner Michel 8144ce5d2a pr-script-test: no container noise in the journal note, real disk use on Incus 2026-11-12 12:31:25 +01:00
Rögl-Brunner Michel 548a6d92ec pr-script-test: upgrade test only in ProxmoxVE, and only for a changed update_script 2026-11-12 12:04:56 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 52b9a5df26 Update CHANGELOG.md (#18632)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 10:58:35 +00:00
CanbiZ (MickLesk) c870b5620d pocketbase-bot: apply website metadata requests from Accept/Decline boxes (#18631)
The website now posts each requested change as its own comment with the
command and two checkboxes. Ticking one edits the comment: Accept runs
the command as if a maintainer had typed it, Decline records the
refusal, and the issue closes once every change is decided. Only users
with write access can decide. app_vars is checked field by field before
it is saved.
2026-11-12 11:58:10 +01:00
Rögl-Brunner Michel b6123811be pr-script-test: upgrade path, second update, stability and resources 2026-11-12 11:42:26 +01:00
Rögl-Brunner Michel 3e9ce91252 pr-script-test: test the update of apps deployed from a branch too 2026-11-12 11:29:12 +01:00
Rögl-Brunner Michel 03ea5280e6 CI hosts: single-use, reset before every job 2026-11-12 11:11:19 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 9b6c24f8ff Update CHANGELOG.md (#18627)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 08:54:34 +00:00
push-app-to-main[bot]andCanbiZ cbb347cc61 Pixelfed (#18523)
* Add pixelfed (ct)

* arm64 unknown

* pixelfed: use nginx_enable_site, enable FFI for vips, align .env with upstream

---------

Co-authored-by: push-app-to-main[bot] <203845782+push-app-to-main[bot]@users.noreply.github.com>
Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-11-12 09:54:10 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 0ad290c81f Update CHANGELOG.md (#18626)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 08:53:15 +00:00
CanbiZ (MickLesk) 0b25f9c6a5 Whisparr-eros: set the first login and save it (#18617)
* fix(whisparr-eros): set the first login and save it

Eros 3.6 turns on forms login and creates the user whisparr with a password that only appears in its log, so a fresh install showed a login nobody knew the password for (#18616). The install now sets a random password through WHISPARR__AUTH__INITIALPASSWORD for the first start only, removes it again, and saves the login in ~/whisparr-eros.creds; the password stays out of the logs.

* whisparr-eros: set the first password in the unit, write creds with cat
2026-11-12 09:52:47 +01:00
Rögl-Brunner Michel 1ac6100001 pr-script-test: build every PR automatically, forks included 2026-11-12 09:22:03 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 46f54a9071 Update CHANGELOG.md (#18625)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-12 08:13:14 +00:00
Rögl-Brunner Michel 46f0678031 immich: fall back to compiling when copying the prebuilt libraries fails
The tar copy out of the unpacked base image ran outside the if, so a failed or
partial unpack hit the ERR trap and aborted the install instead of compiling
from source.
2026-11-12 09:12:55 +01:00
Rögl-Brunner Michel d67f53176e pr-script-test: /test can pick the platforms, and takes app names in PRs 2026-11-12 09:12:45 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 1edcadd90b Update CHANGELOG.md (#18623)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 22:53:34 +00:00
CanbiZ (MickLesk) b1597ac3d1 Paperless-AI: install and update to 4.0 (#18620)
* Paperless-AI: install and update to 4.0

4.0 is a rewrite as a single Node.js process. requirements.txt is gone,
so every install since its release on 2026-11-10 failed at pip, and an
update would have failed the same way.

Install and update now build it the way upstream's image does. An
update from 3.x removes the Python RAG service and rewrites the unit
without data/.env as its environment: 4.0 migrates that file on first
start and treats any setting passed as a variable as locked.

* Paperless-AI: migrate pre-RAG 3.x units too, drop comments
2026-11-11 23:53:08 +01:00
CanbiZ (MickLesk) 0f2e4022c0 Immich: bump to 3.4.1 (#18619) 2026-11-11 21:39:28 +01:00
juronja bf5fa77f0b TrueNas: Fixing isolookup and dialog (#18578)
* fix(truenas-vm): improve ISO fetching logic and exclusions

* fix(truenas-vm): enhance ISO selection and error handling

* fix(truenas-vm): improve ISO selection and error handling

* fix(truenas-vm): enhance ISO lookup and selection process to filter by stable releases only and improve user dialog
2026-11-11 21:33:09 +01:00
Rögl-Brunner Michel fb58720888 arm-runner-setup: get past the runner's DNS failing for the arm64 mirror
The ARM runner's resolver cannot resolve ports.ubuntu.com since this
afternoon; apt then hangs. The name is now looked up over DNS-over-HTTPS
when needed, and a failing apt prints the runner's network state.
2026-11-11 16:24:38 +01:00
Rögl-Brunner Michel 7258873339 arm-runner-setup: time limits, so a hung setup can't hold the report 2026-11-11 16:03:49 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 243c3b1047 Update CHANGELOG.md (#18612)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 14:24:52 +00:00
CanbiZ (MickLesk) 5a4f599f4f Fill platforms when a script moves from DevScripts (#18611)
Setting is_dev to false left platforms as DevScripts had them, usually empty or pve only, although a container in ProxmoxVE is synced to Incus from then on. 36 production records had no platforms because of it. The migration now sets pve and incus for containers, pve where nothing was set, and amd64 for an empty architectures.
2026-11-11 15:24:20 +01:00
Rögl-Brunner Michel 6163037afd pr-script-test: every ct/ script on ARM64 too, as information only
- ARM runs on GitHub's ubuntu-24.04-arm runner (free for public repos), set
  up by arm-runner-setup.sh: Incus 6.0 LTS from Zabbly (key pinned, APT
  pin), screenshots through Playwright. ARM rows are shown but never count:
  not in the headline, the check, the review or the approval, and the job
  runs with continue-on-error.
- var_arm64=no is not built on ARM; unset is built with var_arm64=yes.
- arm-script-test.yml builds one script on ARM by hand, posting nothing.
- Update test: only the versions update_script checks are set back.
- The screenshot browser gets a writable HOME.
2026-11-11 13:31:33 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 95abb0a2d6 Update CHANGELOG.md (#18610)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 12:26:01 +00:00
CanbiZ (MickLesk) 76996d2d82 Pocketbase: install and update from the new host (removed in github) (#18609)
* fix(pocketbase): install and update from the new host

PocketBase moved from GitHub to ganigeorgiev.com in October 2026; the GitHub repo is archived at v0.40.6 and gets no new releases. Install and update now read the latest stable tag from the new release API and download the zip from its fixed download path. The update no longer runs pocketbase update, which older binaries point at GitHub.

* fix(pocketbase): query the release API with curl_with_retry
2026-11-11 13:25:36 +01:00
Rögl-Brunner Michel 519e279d89 Merge remote-tracking branch 'origin/main' into immich-prebuilt-libs
# Conflicts:
#	install/immich-install.sh
2026-11-11 12:56:22 +01:00
Rögl-Brunner Michel 7735372244 immich: update adds the Testing repo without debian.sources
Containers without /etc/apt/sources.list.d/debian.sources (Incus images) got
testing.sources from the install, but the update only looked in debian.sources
and ran sed on the missing file. Check every *.sources file, and write
testing.sources like the install does when debian.sources can't take it.
2026-11-11 12:53:54 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 009c7b0c0e Update CHANGELOG.md (#18608)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 11:30:22 +00:00
Rögl-Brunner Michel 01f37f501f pr-script-test: build vm/ scripts, and every ct/ script on Incus too
- vm/ scripts run unattended (VM_UNATTENDED=1) on the Proxmox hosts, IDs
  9100-9199, one VM per host at a time. Checks: success line, the VM keeps
  running, a DHCP lease, and a ${VM_IP} URL answering 200. Console
  screenshot from QEMU when there is no web UI. ARM64-only scripts are
  listed as not built.
- Every ct/ script now builds on pve and incus (new incus-test runners,
  incus-ci-host-setup.sh); the comment gets a Platform column.
- Spinner frames are dropped from logs and live output.
- The sweeper ignores and removes locks of killed jobs (older than 3h).
2026-11-11 11:48:57 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] b60ff8540a Update CHANGELOG.md (#18606)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 10:30:42 +00:00
CanbiZ (MickLesk) 3aca471c7b Immich: bump to 3.4.0 (#18600)
3.4.0 adds FUTO Backups, which run restic from PATH and use restic-proxy when it is there. Both are built from the refs the server Dockerfile pins, and an update only rebuilds them when those change. pigz is installed, which database backups now use for compression when present. mise skips its own restic, and the extism-js fallback follows the js-pdk version in mise.toml (v1.7.0) instead of a fixed v1.6.0.
2026-11-11 11:30:11 +01:00
Michel Roegl-Brunner 609bfd09d7 immich: keep build toolchain only in the source-compile fallback
Move the build-only toolchain (autoconf, automake, libtool, cmake, meson,
ninja-build, gcc-13) into the fallback branch and drop the unused ccache,
so the prebuilt-library path no longer installs them. The runtime library
-dev packages stay in the common path because they pull the shared
libraries the prebuilt libvips loads at runtime.

Also restore the base-images clone and source-dir creation in the fallback,
which the previous commit dropped.
2026-11-11 11:28:40 +01:00
Michel Roegl-Brunner 80b315a0bc immich: reuse prebuilt image-processing libraries from the base image
The installer compiles libjxl, jpegli, libheif, libraw, ImageMagick and
libvips from source, which takes 15min-2h. These are the same artifacts
Immich already ships in its ghcr.io/immich-app/base-server-dev base image.

Pull /usr/local out of that image (pinned by digest, parsed from the
release's server/Dockerfile) with daemonless skopeo + umoci and reuse it.
Verify vips is usable before committing to this path; on any failure, or
with IMMICH_LIBS_FROM_SOURCE=1, fall back to the unchanged source compile.
A version-matched ~/.immich_library_revisions is written so the update
script does not needlessly recompile the libraries.
2026-11-11 10:58:13 +01:00
Rögl-Brunner Michel f63d1bd425 pr-script-test: test the update too, and /test <app> in issues
Update test: after a passing install, every version file the install
wrote (~/.<app>) is set back to 0.0.0-ci and the container's own
`update` runs unattended, pulling the PR's ct/ script, so
update_script's real path is exercised. It must report "Updated
successfully" without errors, and services and URL are checked again.
Apps without a version file get "nothing to update", a GitHub rate
limit counts as not tested. Separate Install and Update columns in the
report; a failed update requests changes like a failed install.

The job's read-only token is passed to the build so release lookups are
not rate limited (all test containers share one public IP).

/test <app> in an issue builds that app from main, install and update,
and answers in the issue; no check run, review or approval.
2026-11-11 10:46:01 +01:00
Rögl-Brunner Michel d859b2f764 pr-script-test: runners named after their host
A second test host (pve-ci-2) now serves both repositories. The setup
script registered runners as pve-ci-<repo> with --replace, which would
have made it take over the first host's registration; they are now
named <host>-<repo>.
2026-11-11 10:30:43 +01:00
Michel Roegl-Brunner 81e8e0495b Increase timeout from 60 to 90 minutes 2026-11-11 09:41:57 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] ebd69e822d Update CHANGELOG.md (#18604)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-11 08:18:37 +00:00
25 changed files with 2616 additions and 718 deletions

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
name: ARM script test (manual)
# Builds one ct/ script on GitHub's free arm64 runner (ubuntu-24.04-arm),
# inside Incus, with the same harness as pr-script-test.yml. Started by hand
# from the Actions tab; it posts nothing anywhere: the result is in the job
# summary and the artifact (with the screenshots).
#
# pr-script-test.yml runs the same ARM build for every /test, as information
# only. This one is for trying a script or branch on ARM without a PR.
#
# Public repositories get the standard hosted runners, arm64 included, at no
# cost. Each job is a fresh VM that is thrown away afterwards, so the script
# under test never touches the lab.
#
# The engine refuses to guess on arm64: a script that leaves var_arm64 unset
# stops with "refusing to guess". Here var_arm64=no means "not built", yes is
# built as is, and unset is built with var_arm64=yes to see whether it would
# work.
on:
workflow_dispatch:
inputs:
script:
description: "ct/ script, e.g. ct/bark-server.sh"
required: true
type: string
ref:
description: "Branch or full SHA of this repository"
required: false
default: main
type: string
permissions:
contents: read
jobs:
arm:
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
# The harness from the default branch, as in pr-script-test.yml.
- uses: actions/checkout@v7
with:
sparse-checkout: .github/workflows/scripts
persist-credentials: false
- name: Decide var_arm64
id: plan
env:
SCRIPT: ${{ inputs.script }}
REF: ${{ inputs.ref }}
GH_TOKEN: ${{ github.token }}
run: |
[[ "$SCRIPT" =~ ^ct/[A-Za-z0-9._-]+\.sh$ ]] || { echo "::error::script must be ct/<name>.sh"; exit 1; }
[[ "$REF" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::bad ref"; exit 1; }
sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${REF}" --jq .sha)
text=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${SCRIPT}?ref=${sha}" --jq .content | base64 -d)
# A commented-out line ("#var_arm64=...") counts as unset.
arm=$(grep -m1 -oE '^[[:space:]]*var_arm64="[$][{]var_arm64:-(yes|no)[}]"' <<<"$text" | grep -oE ':-(yes|no)' | cut -c3- || true)
echo "sha=$sha" >>"$GITHUB_OUTPUT"
echo "arm64=${arm:-unset}" >>"$GITHUB_OUTPUT"
case "$arm" in
no) echo "::notice::${SCRIPT} sets var_arm64=no: not built on ARM" ;;
yes) echo "${SCRIPT} sets var_arm64=yes" ;;
*) echo "::notice::${SCRIPT} leaves var_arm64 unset: building with var_arm64=yes to see whether it works" ;;
esac
echo "Building ${SCRIPT} at ${sha:0:7} on $(uname -m), $(nproc) cores, $(free -g | awk '/^Mem/ {print $2}') GB RAM, $(df -h / | awk 'NR==2 {print $4}') free"
- name: Set up Incus
if: steps.plan.outputs.arm64 != 'no'
timeout-minutes: 20 # about 2 when healthy; a hang must not hold the report
run: sudo bash .github/workflows/scripts/arm-runner-setup.sh
- name: Build and check ${{ inputs.script }}
if: steps.plan.outputs.arm64 != 'no'
env:
PLATFORM: incus
VAR_ARM64: "yes"
PR_REPO: ${{ github.repository }}
PR_SHA: ${{ steps.plan.outputs.sha }}
SCRIPT: ${{ inputs.script }}
UPSTREAM: ${{ github.repository }}
CTID_BASE: "9000"
GH_API_TOKEN: ${{ github.token }}
OUT_DIR: ${{ runner.temp }}/result
BUILD_TIMEOUT: 90m
run: sudo --preserve-env=PLATFORM,VAR_ARM64,PR_REPO,PR_SHA,SCRIPT,UPSTREAM,CTID_BASE,GH_API_TOKEN,OUT_DIR,BUILD_TIMEOUT,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT bash .github/workflows/scripts/pve-run-script.sh
- name: Summary
if: always() && steps.plan.outputs.arm64 != 'no'
env:
OUT_DIR: ${{ runner.temp }}/result
ARM64: ${{ steps.plan.outputs.arm64 }}
run: |
sudo chmod -R a+rX "$OUT_DIR" 2>/dev/null || true
r="$OUT_DIR/result.json"
[[ -f "$r" ]] || { echo "No result.json" >>"$GITHUB_STEP_SUMMARY"; exit 0; }
{
echo "### ARM (arm64, Incus): $(jq -r .script "$r")"
echo
echo "| Status | Install | Update | Duration | var_arm64 | Detail |"
echo "|---|---|---|---|---|---|"
jq -r --arg arm "$ARM64" '"| \(.status) | \(if .update_status != "" then "pass" else .status end) | \(if .update_status == "" then "-" else .update_status end) | \(.duration)s | \($arm) | \(if .status == "pass" then (.url // "" | if . == "" then "no URL" else . + " → " + $ARGS.named.c end) else .step end) |"' --arg c "$(jq -r .http_code "$r")" "$r"
echo
ls "$OUT_DIR"/screenshot*.png &>/dev/null && echo "Screenshots: in the \`result-arm\` artifact." || echo "No screenshot."
} >>"$GITHUB_STEP_SUMMARY"
- if: always()
uses: actions/upload-artifact@v7
with:
name: result-arm
path: ${{ runner.temp }}/result
if-no-files-found: ignore
retention-days: 14
+13 -6
View File
@@ -230,7 +230,7 @@ jobs:
for (const slug of slugs) {
const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1&expand=type', {
headers: { 'Authorization': token }
});
const list = JSON.parse(listRes.body);
@@ -239,14 +239,21 @@ jobs:
console.log('Slug not in DB, skipping: ' + slug);
continue;
}
const patch = {
name: record.name || record.slug,
last_update_commit: prUrl,
is_dev: false
};
// Containers in ProxmoxVE are synced to Incus from here on.
const recordType = record.expand && record.expand.type && record.expand.type.type;
const platforms = Array.isArray(record.platforms) ? record.platforms : [];
if (recordType === 'lxc' && !platforms.includes('incus')) patch.platforms = ['pve', 'incus'];
else if (!platforms.length) patch.platforms = ['pve'];
if (!Array.isArray(record.architectures) || !record.architectures.length) patch.architectures = ['amd64'];
const patchRes = await request(recordsUrl + '/' + record.id, {
method: 'PATCH',
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: record.name || record.slug,
last_update_commit: prUrl,
is_dev: false
})
body: JSON.stringify(patch)
});
if (!patchRes.ok) {
console.warn('PATCH failed for slug ' + slug + ': ' + patchRes.body);
+149 -4
View File
@@ -2,7 +2,7 @@ name: PocketBase Bot
on:
issue_comment:
types: [created]
types: [created, edited]
permissions:
issues: write
@@ -25,6 +25,9 @@ jobs:
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
EVENT_ACTION: ${{ github.event.action }}
SENDER: ${{ github.event.sender.login }}
PREVIOUS_BODY: ${{ github.event.changes.body.from }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || 'false' }}
REPO_OWNER: ${{ github.repository_owner }}
@@ -87,7 +90,7 @@ jobs:
const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
const isPrComment = process.env.IS_PR_COMMENT === 'true';
const commentId = parseInt(process.env.COMMENT_ID, 10);
const actor = process.env.ACTOR;
let actor = process.env.ACTOR;
function ghRequest(path, method, body) {
const headers = {
@@ -278,7 +281,65 @@ jobs:
// Accept /pocketbase at the start of ANY line (leading whitespace ok),
// so the command works even when preceded by other text. Mid-sentence
// mentions and blockquoted ("> ...") examples are ignored.
const commentBody = process.env.COMMENT_BODY || '';
let commentBody = process.env.COMMENT_BODY || '';
// ── Metadata requests from the website ─────────────────────────────
// One comment per requested change, each with Accept/Decline boxes.
// Ticking one edits the comment; that edit is the only event handled.
const REQUEST_MARKER = '<!-- pb-request -->';
const DONE_MARKER = '<!-- pb-request:done -->';
const isRequest = commentBody.includes(REQUEST_MARKER);
let decidedBy = null;
if (process.env.EVENT_ACTION === 'edited' && !isRequest) process.exit(0);
if (isRequest) {
if (process.env.EVENT_ACTION !== 'edited' || commentBody.includes(DONE_MARKER)) process.exit(0);
const ticked = (body, word) =>
new RegExp('^[ \\t]*[-*] \\[[xX]\\] ' + word + '[ \\t]*\\r?$', 'm').test(body);
const previous = process.env.PREVIOUS_BODY || '';
const accept = ticked(commentBody, 'Accept') && !ticked(previous, 'Accept');
const decline = ticked(commentBody, 'Decline') && !ticked(previous, 'Decline');
if (accept === decline) process.exit(0);
const sender = process.env.SENDER;
const permRes = await ghRequest(
'/repos/' + owner + '/' + repo + '/collaborators/' + encodeURIComponent(sender) + '/permission'
);
const level = permRes.ok ? JSON.parse(permRes.body).permission : 'none';
if (level !== 'admin' && level !== 'write') {
await postComment(
'❌ **PocketBase Bot**: @' + sender + ' is not authorized to decide metadata requests.\n' +
'Only maintainers with write access can accept or decline them.'
);
process.exit(0);
}
const heading = (commentBody.match(/^### (.+)$/m) || [])[1] || 'this change';
const command = commentBody.match(/<!-- pb-command -->\r?\n([\s\S]*?)\r?\n<!-- \/pb-command -->/);
const decided = commentBody
.replace(/^[ \t]*[-*] \[[ xX]\] (Accept|Decline)[ \t]*\r?\n?/gm, '')
.replace(
REQUEST_MARKER,
'**' + (accept ? '✅ Accepted' : '❌ Declined') + '** by @' + sender + '\n\n' +
REQUEST_MARKER + '\n' + DONE_MARKER
);
const markRes = await ghRequest(
'/repos/' + owner + '/' + repo + '/issues/comments/' + commentId, 'PATCH', { body: decided }
);
if (!markRes.ok) console.warn('Could not mark the request as decided:', markRes.body);
if (decline) {
await postComment('❌ **PocketBase Bot**: **' + heading + '** declined by @' + sender + '.');
process.exit(0);
}
if (!command) {
await postComment('❌ **PocketBase Bot**: no command found in the request for **' + heading + '**.');
process.exit(0);
}
commentBody = command[1];
decidedBy = sender;
actor = sender;
}
const cmdLine = commentBody
.split('\n')
.map(l => l.trim())
@@ -292,7 +353,7 @@ jobs:
// ── Permission check ───────────────────────────────────────────────
const association = process.env.ACTOR_ASSOCIATION;
if (association !== 'OWNER' && association !== 'MEMBER') {
if (!decidedBy && association !== 'OWNER' && association !== 'MEMBER') {
await addReaction('-1');
await postComment(
'❌ **PocketBase Bot**: @' + actor + ' is not authorized to use this command.\n' +
@@ -957,6 +1018,35 @@ jobs:
);
process.exit(0);
}
if (fieldName === 'app_vars') {
const TYPES = ['text', 'password', 'number', 'boolean', 'select'];
const problems = [];
if (!Array.isArray(parsedJson)) problems.push('must be a JSON array');
else parsedJson.forEach(function (v, i) {
const at = 'entry ' + (i + 1) + ': ';
if (!v || typeof v !== 'object' || Array.isArray(v)) { problems.push(at + 'must be an object'); return; }
if (typeof v.name !== 'string' || !/^var_[a-z0-9_]+$/.test(v.name)) problems.push(at + '`name` must look like `var_example`');
if (typeof v.label !== 'string' || !v.label.trim()) problems.push(at + '`label` is required');
if (TYPES.indexOf(v.type) === -1) problems.push(at + '`type` must be one of ' + TYPES.join(', '));
if (v.default !== undefined && typeof v.default !== 'string') problems.push(at + '`default` must be a string');
if (v.type === 'select') {
const opts = v.options;
if (!Array.isArray(opts) || opts.length === 0 || opts.some(function (o) { return typeof o !== 'string' || !o; })) {
problems.push(at + 'a select needs `options`, a non-empty array of strings');
} else if (v.default !== undefined && opts.indexOf(v.default) === -1) {
problems.push(at + '`default` must be one of its options');
}
}
['required', 'secret'].forEach(function (k) {
if (v[k] !== undefined && typeof v[k] !== 'boolean') problems.push(at + '`' + k + '` must be true or false');
});
});
if (problems.length > 0) {
await addReaction('-1');
await postComment('❌ **PocketBase Bot**: `app_vars` was not saved.\n\n- ' + problems.join('\n- '));
process.exit(0);
}
}
setPayload[fieldName] = parsedJson;
} else {
setPayload[fieldName] = codeBlockValue;
@@ -1236,3 +1326,58 @@ jobs:
});
ENDSCRIPT
shell: bash
- name: Close a metadata request once every change is decided
if: always() && github.event.action == 'edited' && contains(github.event.comment.body, '<!-- pb-request -->')
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node << 'ENDSCRIPT'
(async function () {
const api = 'https://api.github.com/repos/' + process.env.REPO + '/issues/' + process.env.ISSUE_NUMBER;
const headers = {
'Authorization': 'Bearer ' + process.env.GITHUB_TOKEN,
'Accept': 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': 'pocketbase-bot'
};
const https = require('https');
function call(method, url, body) {
return new Promise(function (resolve, reject) {
const req = https.request(url, { method: method, headers: headers }, function (res) {
let data = '';
res.on('data', function (d) { data += d; });
res.on('end', function () { resolve({ ok: res.statusCode < 300, status: res.statusCode, body: data }); });
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const requests = [];
for (let page = 1; page <= 10; page++) {
const res = await call('GET', api + '/comments?per_page=100&page=' + page);
if (!res.ok) throw new Error('Could not list comments: ' + res.status);
const comments = JSON.parse(res.body);
for (const c of comments) {
if ((c.body || '').includes('<!-- pb-request -->')) requests.push(c.body);
}
if (comments.length < 100) break;
}
const open = requests.filter(function (b) { return !b.includes('<!-- pb-request:done -->'); });
if (requests.length === 0 || open.length > 0) {
console.log(open.length + ' of ' + requests.length + ' changes still undecided.');
return;
}
const accepted = requests.filter(function (b) { return b.includes('✅ Accepted'); }).length;
const res = await call('PATCH', api, { state: 'closed', state_reason: accepted > 0 ? 'completed' : 'not_planned' });
if (!res.ok) throw new Error('Could not close the issue: ' + res.status);
console.log('Closed: ' + accepted + ' accepted, ' + (requests.length - accepted) + ' declined.');
})().catch(function (e) {
console.error(e.message || e);
process.exit(1);
});
ENDSCRIPT
shell: bash
+378 -109
View File
@@ -1,22 +1,28 @@
name: PR script test
# Builds the ct/ scripts a pull request touches on a real Proxmox host and
# Builds the ct/ and vm/ scripts a pull request touches on real Proxmox and Incus hosts and
# reports the outcome on the PR, as a check run and as one comment kept up to
# date.
#
# A self-hosted runner that executes pull request code as root is only
# acceptable if a human decides what runs on it. So it starts on a push only
# for trusted PRs: those the push-app-to-main app opens on a branch of this
# repository when a script moves over from DevScripts (already reviewed and
# tested there; only writers can push to such a branch). Those are built
# automatically and approved when every script passes. Everything else waits
# for someone with write access to comment `/test` (optionally followed by a
# full SHA to pin, and the ct/ paths to build). Only a commit that existed when
# the comment was written gets built: a push after it is never covered, not
# even one that lands in the seconds before this job runs.
# Every push to a non-draft PR is built automatically, forks included: the
# maintainers decided (2026-11-12) that the lab hosts run contributors' code
# unattended. They are isolated, hold no secret, and are treated as
# compromised anyway; the sweeper and fresh guests per build keep them usable.
# Someone with write access can also comment `/test` (optionally followed by
# a full SHA to pin, the scripts to build, and the platforms to build on) to
# build again or pick what to build. Only a commit that existed when the
# comment was written gets built: a push after it is never covered, not even
# one that lands in the seconds before this job runs.
#
# Approval stays narrow: only PRs the push-app-to-main app opens on a branch
# of this repository (a script moving over from DevScripts, reviewed and
# tested there) are approved when every script passes. Every other PR gets
# the report, the check, and "changes requested" on a real failure.
#
# gate ubuntu-latest who asked, which scripts, which commit
# test proxmox-test build + checks, one matrix job per script/OS
# test proxmox-test / build + checks, one matrix job per script, platform
# incus-test / (pve, incus, arm) and OS; arm runs on GitHub's
# ubuntu-24.04-arm arm64 runner and is information only
# report ubuntu-latest check run + PR comment
#
# The test job gets a read-only token and never sees a secret: the host it runs
@@ -28,13 +34,14 @@ name: PR script test
on:
issue_comment:
types: [created]
# Trusted PRs only, see above. pull_request_target runs this file from main,
# never the PR's copy of it.
# Every PR, see above. pull_request_target runs this file from main, never
# the PR's copy of it, and does not wait for "approve and run" on forks.
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- "ct/**"
- "install/**"
- "vm/**"
workflow_dispatch:
inputs:
pr:
@@ -46,20 +53,30 @@ on:
required: false
type: string
scripts:
description: "Space-separated ct/ paths (empty = everything the PR touches)"
description: "Space-separated app names, ct/ or vm/ paths, and platforms (pve, incus, arm); empty = everything the PR touches, everywhere"
required: false
type: string
permissions: {}
# One queue per PR. A newer push replaces a run that is still waiting (its
# gate would skip anyway: the head moved), but never kills one that is
# building: a cancelled job leaves its guest behind until the sweeper. Any
# comment starts this workflow; one that is not a /test gets a group of its
# own, so it cannot push a waiting test out of the queue.
concurrency:
group: >-
pr-script-test-${{ github.event_name == 'issue_comment' && !startsWith(github.event.comment.body, '/test')
&& format('noop-{0}', github.run_id)
|| github.event.pull_request.number || github.event.issue.number || inputs.pr }}
cancel-in-progress: false
jobs:
gate:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/test')) ||
(github.event_name == 'pull_request_target' && !github.event.pull_request.draft &&
github.event.pull_request.user.login == 'push-app-to-main[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository)
(github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/test')) ||
(github.event_name == 'pull_request_target' && !github.event.pull_request.draft)
runs-on: ubuntu-latest
permissions:
actions: read # when the run was dispatched, for a /test without SHA
@@ -76,9 +93,12 @@ jobs:
actor: ${{ steps.plan.outputs.actor }}
not_built: ${{ steps.plan.outputs.not_built }}
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;
@@ -94,7 +114,6 @@ jobs:
let prNumber, requested = '';
if (context.eventName === 'pull_request_target') {
const p = context.payload.pull_request;
if (!isTrusted(p)) return;
prNumber = p.number;
// Exactly the pushed commit; a later push starts its own run.
requested = p.head.sha;
@@ -118,14 +137,28 @@ jobs:
requested = body.split('\n')[0].slice('/test'.length);
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
if (pr.state !== 'open' || !pr.head.repo) { core.notice('PR is closed or its fork is gone'); return; }
const head = pr.head.repo;
if (context.eventName === 'pull_request_target' && pr.head.sha !== requested) {
core.notice('Pushed to again since; the newer push has its own run');
return;
// `/test <app>` in an issue (not a PR) builds that app from the
// default branch as it is right now, e.g. to see whether a reported
// error reproduces. No PR, so no check run, no review, no approval.
const isIssue = context.eventName === 'issue_comment' && !context.payload.issue.pull_request;
let pr = null, head, headSha;
if (isIssue) {
const { data: info } = await github.rest.repos.get({ owner, repo });
const { data: branch } = await github.rest.repos.getBranch({ owner, repo, branch: info.default_branch });
headSha = branch.commit.sha;
head = { owner: { login: owner }, name: repo, full_name: `${owner}/${repo}` };
} else {
({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }));
if (pr.state !== 'open' || !pr.head.repo) { core.notice('PR is closed or its fork is gone'); return; }
head = pr.head.repo;
headSha = pr.head.sha;
if (context.eventName === 'pull_request_target' && headSha !== requested) {
core.notice('Pushed to again since; the newer push has its own run');
return;
}
}
core.setOutput('trusted', isTrusted(pr) ? 'true' : 'false');
core.setOutput('kind', isIssue ? 'issue' : 'pr');
core.setOutput('trusted', !isIssue && isTrusted(pr) ? 'true' : 'false');
const MARK = '<!-- pr-script-test -->';
const sticky = async body => {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber });
@@ -147,27 +180,50 @@ jobs:
// and read after the PR, so a push in between shows up.
// Branches of this repository belong to people with
// write access, who could build anything anyway.
const tokens = requested.split(/\s+/).filter(Boolean);
const words = requested.split(/\s+/).filter(Boolean);
// Platform words pick where to build: `/test arm`, `/test pve incus
// bark-server`. None means every platform. A run on only some of
// them is reported under its own check name and never approves.
const PLATFORM = { pve: 'pve', proxmox: 'pve', incus: 'incus', arm: 'arm', arm64: 'arm', aarch64: 'arm' };
const platformOf = t => PLATFORM[t.toLowerCase()];
const only = new Set(words.map(platformOf).filter(Boolean));
const onlyList = ['pve', 'incus', 'arm'].filter(p => only.has(p));
const tokens = words.filter(t => !platformOf(t));
const isSha = t => /^[0-9a-f]{7,40}$/.test(t);
const approved = tokens.filter(isSha);
let entries = tokens.filter(t => !isSha(t));
// App names (bark-server) become ct/ paths; such a name without a
// ct/ script falls back to vm/ below.
const bare = new Set();
const toPath = t => {
if (/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(t) || !/^[A-Za-z0-9._-]+$/.test(t)) return t;
const p = `ct/${t.replace(/\.sh$/, '')}.sh`;
bare.add(p);
return p;
};
const approved = isIssue ? [] : tokens.filter(isSha);
let entries = (isIssue ? tokens : tokens.filter(t => !isSha(t))).map(toPath);
const refuse = async (why, retry) => {
await sticky([MARK, '### Proxmox script test', '', `⚠️ ${why} Nothing was built.`, '', retry].join('\n'));
core.notice(why);
};
if (approved.length > 1) {
if (isIssue) {
// The default branch is trusted; only which app is needed.
if (entries.length === 0) {
await refuse('In an issue, `/test` needs to know which app.', 'Comment `/test <app>`, for example `/test bark-server` (the name of its `ct/` script).');
return;
}
} else if (approved.length > 1) {
await refuse('`/test` takes at most one commit.', 'Comment `/test` to build the latest commit.');
return;
}
if (approved.length === 1 && approved[0] !== pr.head.sha) {
if (approved.length === 1 && approved[0] !== headSha) {
await refuse(
approved[0].length !== 40 && pr.head.sha.startsWith(approved[0])
approved[0].length !== 40 && headSha.startsWith(approved[0])
? 'A short SHA is not enough, it has to be all 40 characters.'
: `\`${approved[0]}\` is not the head of this PR (any more).`,
'Comment `/test` to build the latest commit.');
return;
}
if (approved.length === 0 && head.full_name !== `${owner}/${repo}`) {
if (!isIssue && approved.length === 0 && head.full_name !== `${owner}/${repo}`) {
const askedAt = context.eventName === 'workflow_dispatch'
? (await github.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId })).data.created_at
: context.payload.comment.created_at;
@@ -179,8 +235,10 @@ jobs:
return;
}
}
if (entries.some(e => !/^ct\/[A-Za-z0-9._-]+\.sh$/.test(e))) {
core.setFailed(`Only ct/<name>.sh paths can be requested, got: ${requested}`);
if (entries.some(e => !/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(e))) {
await refuse(
isIssue ? 'Only app names (as in `ct/<app>.sh` or `vm/<name>.sh`) and platforms (`pve`, `incus`, `arm`) can be requested.' : 'Only app names, `ct/<name>.sh` and `vm/<name>.sh` paths, platforms (`pve`, `incus`, `arm`) and the commit can be requested.',
isIssue ? 'For example: `/test bark-server`, `/test arm bark-server` or `/test debian-vm`' : 'For example: `/test`, `/test arm`, or `/test pve incus bark-server`.');
return;
}
// Everything the PR changes that this test cannot build is named in
@@ -194,71 +252,149 @@ jobs:
if (f.status === 'removed') continue;
let m;
if ((m = f.filename.match(/^install\/([A-Za-z0-9._-]+)-install\.sh$/))) set.add(`ct/${m[1]}.sh`);
else if (/^ct\/[A-Za-z0-9._-]+\.sh$/.test(f.filename)) set.add(f.filename);
else if (/^vm\/.*\.sh$/.test(f.filename)) notBuilt.push(`${code(f.filename)}: \`vm/\` scripts are not covered`);
else if (/^(tools|turnkey)\/.*\.sh$/.test(f.filename)) notBuilt.push(`${code(f.filename)}: only \`ct/\` scripts are built`);
else if (/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(f.filename)) set.add(f.filename);
else if (/^(tools|turnkey)\/.*\.sh$/.test(f.filename)) notBuilt.push(`${code(f.filename)}: only \`ct/\` and \`vm/\` scripts are built`);
}
entries = [...set].sort();
}
const include = [];
for (const script of entries.slice(0, MAX)) {
let text;
// 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: head.owner.login, repo: head.name, path: script, ref: pr.head.sha });
text = Buffer.from(res.data.content || '', 'base64').toString('utf8');
} catch (e) {
notBuilt.push(`\`${script}\` does not exist in this PR (an \`install/\` script is built through the \`ct/\` script of the same name)`);
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 });
return Buffer.from(res.data.content || '', 'base64').toString('utf8');
} catch (e) { return null; }
};
for (let script of entries.slice(0, MAX)) {
let text = await fetchScript(script);
// /test debian-vm in an issue: there is no ct/debian-vm.sh, but a vm/ one.
const wasBare = isIssue || bare.has(script);
if (text === null && wasBare && script.startsWith('ct/')) {
const vm = `vm/${script.slice(3)}`;
if ((text = await fetchScript(vm)) !== null) script = vm;
}
if (text === null) {
const where = isIssue ? 'on the default branch' : 'in this PR';
notBuilt.push(wasBare
? (script.startsWith('ct/') ? `${code(script.slice(3, -3))}: neither \`ct/\` nor \`vm/\` has it ${where}` : `${code(script)} does not exist ${where}`)
: `\`${script}\` does not exist in this PR (an \`install/\` script is built through the \`ct/\` script of the same name)`);
continue;
}
const isVm = script.startsWith('vm/');
// The test hosts are amd64; an ARM-only VM script refuses to run there.
if (isVm && /^\s*vm_require_arch\s+arm64\b/m.test(text)) {
notBuilt.push(`${code(script)}: ARM64 only, the test hosts are amd64`);
continue;
}
// Scripts that ask for the OS get built once per OS.
const oses = /msg_menu\s+"Choose the container OS"/.test(text) ? ['debian', 'alpine'] : [''];
const name = script.slice(3, -3);
for (const os of oses) include.push({ script, os, name: os ? `${name}-${os}` : name });
const oses = !isVm && /msg_menu\s+"Choose the container OS"/.test(text) ? ['debian', 'alpine'] : [''];
const name = isVm ? `vm-${script.slice(3, -3)}` : script.slice(3, -3);
// Every ct/ script on every platform: the same files run on Proxmox
// and, through community-scripts/core's Incus backend, on Incus.
// VMs are Proxmox only. ARM (Incus on GitHub's arm64 runner) is
// information only and never decides the result; a script that
// says var_arm64=no is not built there. Unset is built with
// var_arm64=yes, to see whether it would work.
const arm = (text.match(/^[ \t]*var_arm64="\$\{var_arm64:-(yes|no)\}"/m) || [])[1] || 'unset';
let platforms = isVm ? ['pve'] : arm === 'no' ? ['pve', 'incus'] : ['pve', 'incus', 'arm'];
if (!isVm && arm === 'no' && (!only.size || only.has('arm'))) notBuilt.push(`${code(script)} on ARM64: the script sets \`var_arm64=no\``);
if (only.size) {
if (isVm && !only.has('pve')) notBuilt.push(`${code(script)}: VM scripts are built on \`pve\` only`);
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 : '', 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}\``);
if (include.length === 0) {
await sticky([
MARK, '### Proxmox script test', '',
'ℹ️ Nothing to build: this test builds `ct/` scripts (and changed `install/` scripts through their `ct/` script).', '',
...(notBuilt.length ? notBuilt.map(n => `- ${n}`) : ['- this PR changes no `ct/` or `install/` script']),
isIssue || onlyList.length
? `ℹ️ Nothing to build${onlyList.length ? ` on ${onlyList.join(', ')}` : ''}:`
: 'ℹ️ Nothing to build: this test builds `ct/` and `vm/` scripts (and changed `install/` scripts through their `ct/` script).', '',
...(notBuilt.length ? notBuilt.map(n => `- ${n}`) : ['- this PR changes no `ct/`, `vm/` or `install/` script']),
].join('\n'));
core.notice('No ct/ script to build');
core.notice('No ct/ or vm/ script to build');
return;
}
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const { data: check } = await github.rest.checks.create({
owner, repo, name: 'Proxmox script test', head_sha: pr.head.sha, status: 'in_progress',
details_url: runUrl,
output: { title: `Building ${include.length} on Proxmox`, summary: include.map(i => `- \`${i.script}\` ${i.os}`).join('\n') },
});
// A check run belongs to a PR's commit; an issue has none.
let checkId = '';
if (!isIssue) {
const { data: check } = await github.rest.checks.create({
// A partial run gets its own name: it must not stand in for the
// 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, i.mode].filter(Boolean).join(' · ')}`).join('\n') },
});
checkId = String(check.id);
}
core.setOutput('matrix', JSON.stringify(include));
core.setOutput('pr', String(prNumber));
core.setOutput('repo', head.full_name);
core.setOutput('sha', pr.head.sha);
core.setOutput('check_id', String(check.id));
core.setOutput('sha', headSha);
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 ${pr.head.sha.slice(0, 7)} — [watch it live](${runUrl}) (open the \`test\` job)`, '',
...include.map(i => `- \`${i.script}\`${i.os ? ` (${i.os})` : ''}`),
`⏳ 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, i.mode && 'upgrade from the base branch'].filter(Boolean).join(' · ')})`),
...(notBuilt.length ? ['', '**Not built:**', ...notBuilt.map(n => `- ${n}`)] : []),
].join('\n'));
test:
needs: gate
if: needs.gate.outputs.matrix != '[]' && needs.gate.outputs.matrix != ''
# Only `proxmox-test`, never `self-hosted`: the runner is registered without
# default labels so that no other workflow can be scheduled onto it.
runs-on: proxmox-test
# Only `proxmox-test` / `incus-test`, never `self-hosted`: the runners are
# registered without default labels so that no other workflow can be
# scheduled onto them. ARM runs on GitHub's own arm64 runner, a fresh VM
# per job (free for public repositories).
runs-on: ${{ matrix.platform == 'arm' && 'ubuntu-24.04-arm' || matrix.platform == 'incus' && 'incus-test' || 'proxmox-test' }}
# ARM is information only: a failed or missing ARM job (no runner, no
# minutes left, ...) never turns the run red. The report leaves it out of
# the result as well.
continue-on-error: ${{ matrix.platform == 'arm' }}
permissions:
contents: read
timeout-minutes: 60
timeout-minutes: 120 # install (45) + update (30) + second update + checks
strategy:
fail-fast: false
matrix:
@@ -266,24 +402,48 @@ 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
# The arm64 runner is a plain Ubuntu VM: Incus and the screenshot
# browser are set up on it for this one job.
- name: Set up the ARM runner
if: matrix.platform == 'arm'
timeout-minutes: 20 # about 2 when healthy; a hang must not hold the report
run: sudo bash .github/workflows/scripts/arm-runner-setup.sh
- name: Build and check ${{ matrix.script }}
env:
PR_REPO: ${{ needs.gate.outputs.repo }}
PR_SHA: ${{ needs.gate.outputs.sha }}
SCRIPT: ${{ matrix.script }}
OS: ${{ matrix.os }}
# 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
# test containers share the lab's one public IP and 60 calls an hour.
# This job's token can read public contents and expires with the job.
GH_API_TOKEN: ${{ github.token }}
OUT_DIR: ${{ runner.temp }}/result
run: bash .github/workflows/scripts/pve-run-script.sh
# 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,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
@@ -301,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 }}
@@ -316,6 +476,8 @@ jobs:
ACTOR: ${{ needs.gate.outputs.actor }}
NOT_BUILT: ${{ needs.gate.outputs.not_built }}
TRUSTED: ${{ needs.gate.outputs.trusted }}
KIND: ${{ needs.gate.outputs.kind }}
PLATFORMS: ${{ needs.gate.outputs.platforms }}
with:
script: |
const fs = require('fs');
@@ -343,7 +505,26 @@ jobs:
r.install_log = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
const shot = path.join(dir, 'screenshot.png');
r.shot_file = fs.existsSync(shot) ? shot : '';
found.set(`${r.script}|${r.os}`, r);
if (!['updated', 'no-update', 'fail', 'error', ''].includes(r.update_status || '')) r.update_status = '';
// Installed fine, but the update could not be tested (e.g. a
// GitHub rate limit): not a pass, and not the PR's fault.
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
// says so in arch. Only the platforms this workflow knows: an
// 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.mode}`, r);
} catch (err) { core.warning(`unreadable ${p}: ${err.message}`); }
}
}
@@ -351,11 +532,25 @@ 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.os || 'default'}`) || {
script: m.script, os: m.os || 'default',
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", "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;
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.
const coreRows = rows.filter(r => !isArm(r));
const armRows = rows.filter(isArm);
// Screenshots go to an orphan branch, because a comment can only show
// an image that has a URL. One file per PR and script, overwritten on
@@ -374,11 +569,11 @@ jobs:
const { data: commit } = await github.rest.git.createCommit({ owner, repo, message: 'Start ci-screenshots', tree: tree.sha, parents: [] });
await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${BRANCH}`, sha: commit.sha });
};
for (const r of rows.filter(r => r.shot_file)) {
const buf = fs.readFileSync(r.shot_file);
if (!isPng(buf)) { core.warning(`${r.script}: screenshot is not a plausible PNG, skipped`); continue; }
const file = `pr-${prNum}/${r.name}.png`;
for (let attempt = 1; attempt <= 3 && !r.shot_url; attempt++) {
const uploadShot = async (r, local, suffix) => {
const buf = fs.readFileSync(local);
if (!isPng(buf)) { core.warning(`${r.script}: screenshot is not a plausible PNG, skipped`); return ''; }
const file = `${process.env.KIND === 'issue' ? 'issue' : 'pr'}-${prNum}/${r.name}${suffix}.png`;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await ensureBranch();
let sha;
@@ -386,15 +581,20 @@ jobs:
catch (e) { if (e.status !== 404) throw e; }
const { data } = await github.rest.repos.createOrUpdateFileContents({
owner, repo, branch: BRANCH, path: file, sha, content: buf.toString('base64'),
message: `Screenshot: ${r.script} (${r.os}) for #${prNum} at ${process.env.SHA.slice(0, 7)}`,
message: `Screenshot: ${r.script} (${where(r)})${suffix} for #${prNum} at ${process.env.SHA.slice(0, 7)}`,
});
r.shot_url = `https://raw.githubusercontent.com/${owner}/${repo}/${data.commit.sha}/${file}`;
return `https://raw.githubusercontent.com/${owner}/${repo}/${data.commit.sha}/${file}`;
} catch (e) {
// Two reports at once race on the branch head; try again.
core.warning(`${r.script}: screenshot upload attempt ${attempt} failed: ${e.message}`);
await new Promise(res => setTimeout(res, 2000 * attempt));
}
}
return '';
};
for (const r of rows) {
if (r.shot_file) r.shot_url = await uploadShot(r, r.shot_file, '');
if (r.shot_update_file) r.shot_update_url = await uploadShot(r, r.shot_update_file, '-after-update');
}
// unsupported: the script needs a CPU feature the test host lacks
@@ -408,15 +608,28 @@ jobs:
// fail: the app broke, the PR's business. error/cancelled: our test
// setup did, not the PR's. unsupported: the host's CPU is too old.
const count = s => rows.filter(r => r.status === s).length;
const count = s => coreRows.filter(r => r.status === s).length;
const passed = count('pass');
const untestable = count('unsupported');
const broken = count('fail');
const setupTrouble = count('error') + count('cancelled');
const allPass = passed === rows.length;
const cancelled = rows.every(r => r.status === 'cancelled');
const of = `**${passed}/${rows.length} passed**`;
const headline = broken
const allPass = passed === coreRows.length;
// `/test arm`: nothing that counts was built, so the result is
// information only as a whole.
const armOnly = coreRows.length === 0;
// Built on only some platforms (`/test pve`): a pass there says
// nothing about the others, so it neither approves nor lifts an
// earlier "changes requested". A failure is still a failure.
const partial = !!process.env.PLATFORMS;
const cancelled = (armOnly ? rows : coreRows).every(r => r.status === 'cancelled');
const of = `**${passed}/${coreRows.length} passed**`;
const armPassed = armRows.filter(r => r.status === 'pass').length;
const armLine = armRows.length && !armOnly
? `ARM64 (information only, not counted): ${armPassed}/${armRows.length} passed${armPassed < armRows.length ? `; failures there do not block this PR` : ''}.`
: '';
const headline = armOnly
? `ℹ️ ARM64 only, information only: **${armPassed}/${armRows.length} passed**`
: broken
? `❌ ${of}`
: setupTrouble
? `⚠️ ${of}, ${setupTrouble} could not be tested because of a problem with the test setup (not the PR)`
@@ -424,10 +637,20 @@ jobs:
? `🖥️ ${of}, ${untestable} need${untestable === 1 ? 's' : ''} a newer CPU than the test host has (nothing failed)`
: `✅ ${of}`;
// Update: the installed app is set back a version and updated with
// the PR's update_script. ➖ = it ran but had nothing to update.
const updateIcon = { updated: '✅', 'no-update': '➖', fail: '❌', error: '⚠️' };
const detail = r => {
if (r.status !== 'pass') return clean(r.step);
const ip = /^[0-9.]{7,15}$/.test(r.ip || '') ? r.ip : '';
if (isVm(r) && !r.url) return ip ? `booted, got ${ip}` : 'booted, no DHCP lease (an installer ISO, or no network)';
const reach = r.url ? `${clean(r.url)} → ${r.http_code}` : 'no URL to check';
return r.update_status === 'no-update' ? `${reach}; update: nothing to update${r.update_step ? ` (${clean(r.update_step)})` : ''}` : reach;
};
const table = [
'| Script | OS | Result | Duration | Detail |',
'|---|---|---|---|---|',
...rows.map(r => `| \`${r.script}\` | ${r.os} | ${icon[r.status] || '❔'} | ${dur(r.duration || 0)} | ${r.status === 'pass' ? (r.url ? `${clean(r.url)} → ${r.http_code}` : 'no URL to check') : clean(r.step)} |`),
'| Script | Platform | Install | Update | Duration | Detail |',
'|---|---|---|---|---|---|',
...rows.map(r => `| \`${r.script}\` | ${where(r)} | ${r.update_status ? '✅' : icon[r.status] || '❔'} | ${updateIcon[r.update_status] || '–'} | ${dur(r.duration || 0)} | ${detail(r)} |`),
].join('\n');
// A comment holds 65536 characters. Each script gets an equal share
@@ -449,38 +672,77 @@ jobs:
const logs = rows.map(r => {
const parts = [];
const failed = r.status !== 'pass';
const buildShare = failed && r.log_tail ? Math.min(r.log_tail.length, Math.floor(budget / 3)) : 0;
if (buildShare) parts.push(details(`${icon[r.status] || '❔'} ${r.script} (${r.os}) — build log`, r.log_tail, buildShare));
// A VM script's own output is all there is: no install log inside.
const buildShare = (failed || isVm(r)) && r.log_tail ? Math.min(r.log_tail.length, Math.floor(budget / 3)) : 0;
if (buildShare) parts.push(details(`${icon[r.status] || '❔'} ${r.script} (${where(r)}) — build log`, r.log_tail, buildShare));
if (isVm(r)) return parts.join('\n\n');
// The update log gets up to a third of what is left, the install log the rest.
const updateShare = r.update_log ? Math.min(r.update_log.length, Math.floor((budget - buildShare) / 3)) : 0;
if (r.install_log) {
const lines = r.install_log.trimEnd().split('\n').length;
parts.push(details(`📄 ${r.script} (${r.os}) — install log (${lines} lines)`, r.install_log, budget - buildShare - 300));
parts.push(details(`📄 ${r.script} (${where(r)}) — install log (${lines} lines)`, r.install_log, budget - buildShare - updateShare - 400));
} else if (!buildShare) {
parts.push(`<sub>${r.script} (${r.os}): no install log was captured</sub>`);
parts.push(`<sub>${r.script} (${where(r)}): no install log was captured</sub>`);
}
if (updateShare) {
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');
});
const shots = rows.filter(r => r.shot_url).map(r => [
`<details open><summary>📸 ${r.script} (${r.os}) — ${clean(r.url)}</summary>`, '',
`<img src="${r.shot_url}" width="640" alt="Screenshot of ${r.script}">`, '', '</details>',
const shots = rows.filter(r => r.shot_url || r.shot_update_url).map(r => [
`<details open><summary>📸 ${r.script} (${where(r)}) — ${r.url ? clean(r.url) : 'console'}${r.shot_url && r.shot_update_url ? ': after install / after update' : r.shot_update_url ? ': after update' : ''}</summary>`, '',
[r.shot_url, r.shot_update_url].filter(Boolean)
.map((u, i, all) => `<img src="${u}" width="${all.length > 1 ? 420 : 640}" alt="Screenshot of ${r.script}">`).join(' '),
'', '</details>',
].join('\n'));
const summary = [
`${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 []; } })(),
...logs, '',
`Full logs: [workflow run](${runUrl}) (artifacts \`result-*\`). Comment \`/test\` to build again.`,
`Full logs: [workflow run](${runUrl}) (artifacts \`result-*\`). Comment \`/test${partial ? ` ${process.env.PLATFORMS}` : ''}${process.env.KIND === 'issue' ? ' ' + [...new Set(rows.map(r => r.script.slice(3, -3)))].join(' ') : ''}\` to build again.`,
].join('\n');
await github.rest.checks.update({
owner, repo, check_run_id: Number(process.env.CHECK_ID), status: 'completed',
// Untestable here is "test it by hand", not a pass: action_required,
// because a required check would count neutral as passed.
conclusion: cancelled ? 'cancelled' : allPass ? 'success' : broken ? 'failure' : 'action_required',
output: { title: `${passed}/${rows.length} passed`, summary: summary.slice(0, 65000) },
});
// Issues have no check run (there is no PR commit to attach it to).
if (process.env.CHECK_ID) {
await github.rest.checks.update({
owner, repo, check_run_id: Number(process.env.CHECK_ID), status: 'completed',
// Untestable here is "test it by hand", not a pass: action_required,
// because a required check would count neutral as passed.
conclusion: cancelled ? 'cancelled' : armOnly ? 'neutral' : allPass ? 'success' : broken ? 'failure' : 'action_required',
output: {
title: armOnly
? `ARM64 ${armPassed}/${armRows.length} passed (information only)`
: `${passed}/${coreRows.length} passed${armRows.length ? ` (+ ARM ${armPassed}/${armRows.length}, not counted)` : ''}`,
summary: summary.slice(0, 65000),
},
});
}
// The result pings whoever started the test. A mention added by
// editing a comment does not reliably notify, so the old comment
@@ -488,7 +750,10 @@ jobs:
// thread where it is looked for.
// Whoever started the test and the PR's author, once each; no bots.
const pr = Number(process.env.PR);
const { data: prInfo } = await github.rest.pulls.get({ owner, repo, pull_number: pr });
const isIssue = process.env.KIND === 'issue';
const { data: prInfo } = isIssue
? await github.rest.issues.get({ owner, repo, issue_number: pr })
: await github.rest.pulls.get({ owner, repo, pull_number: pr });
const author = prInfo.user && prInfo.user.type === 'User' ? prInfo.user.login : '';
const login = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
const ping = [...new Set([process.env.ACTOR || '', author])].filter(l => login.test(l)).map(l => `@${l} `).join('');
@@ -499,13 +764,15 @@ jobs:
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id });
}
const { data: posted } = await github.rest.issues.createComment({ owner, repo, issue_number: pr, body });
// An issue has nothing to review or approve: the comment is all.
if (isIssue) return;
// A real failure requests changes on the tested commit. Untestable
// (CPU), runner errors and cancelled runs are not the PR's fault and
// leave the review state alone. Our previous "changes requested" is
// always dismissed first: a pass lifts it, a new failure replaces it.
const REVIEW_MARK = '<!-- pr-script-test-review -->';
const failedRows = cancelled ? [] : rows.filter(r => r.status === 'fail');
const failedRows = cancelled ? [] : coreRows.filter(r => r.status === 'fail');
const sha7 = process.env.SHA.slice(0, 7);
// Trusted PRs (scripts moving over from DevScripts) are approved when
// every script passes -- not on untestable or setup trouble, which
@@ -517,7 +784,9 @@ jobs:
// what a migration contains: ct/ and install/ scripts. A PR that
// also changes anything else (workflows, misc/, ...) is tested but
// never approved.
let approve = process.env.TRUSTED === 'true' && allPass;
let approve = process.env.TRUSTED === 'true' && allPass && !partial;
// A partial run that passed leaves every review as it is.
const leaveReviews = partial && !failedRows.length;
let noApproveWhy = '';
if (approve) {
const changed = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr });
@@ -533,13 +802,13 @@ jobs:
try {
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr });
const ours = state => reviews.filter(v => v.user && v.user.type === 'Bot' && v.state === state && (v.body || '').includes(REVIEW_MARK));
for (const v of ours('CHANGES_REQUESTED')) {
for (const v of leaveReviews ? [] : ours('CHANGES_REQUESTED')) {
await github.rest.pulls.dismissReview({
owner, repo, pull_number: pr, review_id: v.id,
message: failedRows.length ? 'Replaced by a newer Proxmox script test.' : `Proxmox script test passed at ${sha7}.`,
});
}
if (!approve && !cancelled) {
if (!approve && !cancelled && !leaveReviews) {
for (const v of ours('APPROVED')) {
await github.rest.pulls.dismissReview({
owner, repo, pull_number: pr, review_id: v.id,
@@ -561,7 +830,7 @@ jobs:
owner, repo, pull_number: pr, commit_id: process.env.SHA, event: 'REQUEST_CHANGES',
body: [
REVIEW_MARK, `**Proxmox script test failed** at \`${sha7}\`:`, '',
...failedRows.map(r => `- \`${r.script}\` (${r.os}): ${clean(r.step)}`), '',
...failedRows.map(r => `- \`${r.script}\` (${where(r)}): ${clean(r.step)}`), '',
`Logs and details: [test result](${posted.html_url}). Push a fix, then ask for \`/test\` again.`,
].join('\n'),
});
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Turns a GitHub-hosted arm64 runner (ubuntu-24.04-arm) into a throwaway Incus
# test host for pve-run-script.sh (PLATFORM=incus): the same layout as
# incus-ci-host-setup.sh, minus what a VM that lives for one job does not need
# (services, sweeper, runner registration). Run with sudo.
#
# The runner is a fresh VM per job and is discarded afterwards; nothing here
# has to be undone.
set -euo pipefail
NET_ADDR=10.98.0.1
NET_CIDR=10.98.0.0/24
SERVE_ROOT=/srv/ci
SERVE_PORT=18080
start=$(date +%s)
# --- Incus ----------------------------------------------------------------------
# Ubuntu 24.04's own incus is 6.0.0, which lacks `-f csv,noheader`; the engine
# then finds no storage pool. Zabbly (the Incus maintainers' packages) has the
# current 6.0 LTS, as Debian 13 does.
mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
# The key Zabbly publishes in its install instructions, not whatever the
# server returns today.
gpg --show-keys --with-colons /etc/apt/keyrings/zabbly.asc 2>/dev/null |
awk -F: '/^fpr/ {print $10; exit}' | grep -qx 4EFC590696CB15B87C73A3AD82CC8797C838DCFD ||
{ echo "::error::unexpected Zabbly signing key"; exit 1; }
# Only the Incus packages may come from there; everything else stays Ubuntu's.
printf '%s\n' 'Package: *' 'Pin: origin pkgs.zabbly.com' 'Pin-Priority: 1' '' \
'Package: incus* liblxc* lxcfs* qemu* edk2*' 'Pin: origin pkgs.zabbly.com' 'Pin-Priority: 600' \
>/etc/apt/preferences.d/zabbly
printf '%s\n' 'Enabled: yes' 'Types: deb' 'URIs: https://pkgs.zabbly.com/incus/lts-6.0' \
"Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")" 'Components: main' \
"Architectures: $(dpkg --print-architecture)" 'Signed-By: /etc/apt/keyrings/zabbly.asc' \
>/etc/apt/sources.list.d/zabbly-incus-lts-6.0.sources
# Each slow step has its own limit, so a hang names itself in the log instead
# of holding the PR report until the job times out.
step() { local t=$1; shift; timeout "$t" "$@" || { echo "::error::'$*' failed or hung (limit ${t})"; return 1; }; }
# What the runner's network looks like when apt can't get through.
netdiag() {
echo "::group::Network on this runner"
resolvectl status 2>&1 | grep -E 'DNS Servers|Current DNS|resolv.conf mode' || cat /etc/resolv.conf
local h
for h in ports.ubuntu.com pkgs.zabbly.com github.com; do
echo "$h -> $(timeout 10 getent ahostsv4 "$h" | awk '{print $1; exit}')"
timeout 15 curl -s -o /dev/null -w " http %{http_code} in %{time_total}s\n" "http://$h/" || echo " no answer"
done
echo "::endgroup::"
}
# The runner's resolver (Azure's 168.63.129.16) stopped resolving the arm64
# mirror on 2026-11-11 while every other name worked. Then the name is
# looked up over DNS-over-HTTPS and pinned in /etc/hosts for this job.
for h in ports.ubuntu.com; do
timeout 10 getent ahostsv4 "$h" >/dev/null && continue
ip=$(curl -fsS --max-time 10 -H 'accept: application/dns-json' "https://1.1.1.1/dns-query?name=$h&type=A" |
jq -r '[.Answer[]? | select(.type == 1) | .data][0] // empty')
if [[ "$ip" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
echo "$ip $h" >>/etc/hosts
echo "::warning::the runner's DNS can't resolve $h; using $ip from 1.1.1.1"
fi
done
step 5m apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 update -qq || { netdiag; exit 1; }
DEBIAN_FRONTEND=noninteractive step 10m apt-get install -y -qq incus jq >/dev/null
# Docker on the runner sets the FORWARD policy to DROP, which cuts the Incus
# containers off from the internet.
iptables -P FORWARD ACCEPT
step 2m incus admin init --preseed <<EOF
networks:
- name: incusbr0
type: bridge
config:
ipv4.address: ${NET_ADDR}/24
ipv4.nat: "true"
ipv6.address: none
storage_pools:
- name: default
driver: dir
profiles:
- name: default
devices:
root:
path: /
pool: default
type: disk
eth0:
name: eth0
network: incusbr0
type: nic
EOF
# The PR tree server the harness expects (a service on the lab hosts).
mkdir -p "$SERVE_ROOT"
nohup python3 -m http.server "$SERVE_PORT" --bind "$NET_ADDR" --directory "$SERVE_ROOT" >/tmp/ci-serve.log 2>&1 &
echo "Incus $(incus --version) ready after $(($(date +%s) - start))s"
# --- Screenshots ----------------------------------------------------------------
# Optional: without them the build is still tested. The harness takes one only
# when `chromium` exists, the user ci-shot exists and the CI_SHOT chain keeps
# that user off private addresses (here: the cloud metadata service at
# 169.254.169.254 and the runner itself).
shots() {
useradd --system --create-home --shell /usr/sbin/nologin ci-shot
# Ubuntu's apt chromium is a snap wrapper; Playwright ships a plain arm64
# build. Started directly with --screenshot, that build crashes on this
# runner (sandbox or not), so `chromium` here is a stand-in that takes the
# harness's arguments and drives the browser through Playwright instead.
mkdir -p /opt/pw
(cd /opt/pw && step 5m npm install --silent --no-audit --no-fund playwright-core@1.49.1 >/dev/null) || return 1
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers DEBIAN_FRONTEND=noninteractive \
step 10m /opt/pw/node_modules/.bin/playwright-core install --with-deps chromium >/dev/null || return 1
chmod -R a+rX /opt/pw /opt/pw-browsers
cat >/opt/pw/shot.js <<'EOF'
// node shot.js <url> <out.png> <width> <height>
const { chromium } = require('/opt/pw/node_modules/playwright-core');
(async () => {
const [url, out, w, h] = process.argv.slice(2);
const browser = await chromium.launch({ chromiumSandbox: true });
try {
const page = await browser.newPage({ viewport: { width: Number(w), height: Number(h) }, ignoreHTTPSErrors: true });
await page.goto(url, { waitUntil: 'load', timeout: 30000 }).catch(() => {});
await page.waitForTimeout(5000);
await page.screenshot({ path: out });
} finally {
await browser.close();
}
})().catch(e => { console.error(e.message); process.exit(1); });
EOF
cat >/usr/local/bin/chromium <<'EOF'
#!/usr/bin/env bash
# Stand-in for `chromium --headless ... --screenshot=<file> --window-size=W,H <url>`
# on the ARM runner (see arm-runner-setup.sh). Other flags are ignored.
url="" out="" w=1280 h=800
for a in "$@"; do
case "$a" in
--version) echo "Chromium (Playwright $(node -p "require('/opt/pw/node_modules/playwright-core/package.json').version"))"; exit 0 ;;
--screenshot=*) out="${a#*=}" ;;
--window-size=*) s="${a#*=}"; w="${s%,*}"; h="${s#*,}" ;;
-*) ;;
*) url="$a" ;;
esac
done
[[ -n "$url" && -n "$out" ]] || { echo "usage: chromium --screenshot=<file> <url>" >&2; exit 2; }
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers exec node /opt/pw/shot.js "$url" "$out" "$w" "$h"
EOF
chmod 755 /usr/local/bin/chromium
# Ubuntu 24.04 forbids unprivileged user namespaces through AppArmor, and
# Chromium's sandbox needs them. The sandbox stays on; the restriction goes,
# on a VM that exists for this one job.
sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
local uid fw net ns
uid=$(id -u ci-shot)
for fw in iptables ip6tables; do
$fw -w -N CI_SHOT
$fw -w -I OUTPUT -m owner --uid-owner "$uid" -j CI_SHOT
done
iptables -w -A CI_SHOT -d "$NET_ADDR" -j REJECT
iptables -w -A CI_SHOT -d "$NET_CIDR" -j ACCEPT
for ns in 127.0.0.53 $(awk '/^nameserver/ && $2 !~ /:/ {print $2}' /run/systemd/resolve/resolv.conf 2>/dev/null); do
iptables -w -A CI_SHOT -d "$ns" -p udp --dport 53 -j ACCEPT
iptables -w -A CI_SHOT -d "$ns" -p tcp --dport 53 -j ACCEPT
done
for net in 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/3; do
iptables -w -A CI_SHOT -d "$net" -j REJECT
done
for net in ::1/128 fc00::/7 fe80::/10 ff00::/8; do
ip6tables -w -A CI_SHOT -d "$net" -j REJECT
done
# The same call the harness makes, on a page that needs no network.
local dir
dir=$(mktemp -d)
chown ci-shot "$dir"
# HOME: runuser keeps root's, which ci-shot cannot write (the harness does the same).
runuser -u ci-shot -- env HOME="$dir" timeout 60 chromium --headless=new --disable-gpu --hide-scrollbars \
--no-first-run --window-size=320,200 --user-data-dir="$dir/profile" \
--screenshot="$dir/shot.png" 'data:text/html,<h1>ci</h1>' &>"$dir/chromium.log" || true
if [[ ! -s "$dir/shot.png" ]]; then
echo "Chromium could not take a test screenshot:"
head -n 15 "$dir/chromium.log"
echo "userns: $(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>&1)"
rm -rf -- "$dir"
return 1
fi
rm -rf -- "$dir"
echo "$(chromium --version) ready for screenshots"
}
shots || echo "::warning::no screenshots on this ARM run (the build is still tested)"
echo "Runner ready after $(($(date +%s) - start))s"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Installs ci-vm-controller.sh on the hypervisor (prox1): the script, the
# token reader, a systemd unit per CI VM, and each VM's config. Run as root
# on prox1 from a directory that also holds ci-vm-controller.sh:
#
# scp ci-controller-setup.sh ci-vm-controller.sh root@prox1:/root/ci-controller/
# ssh root@prox1 'cd /root/ci-controller && bash ci-controller-setup.sh'
#
# It does not start anything: a VM goes into service with
# systemctl enable --now ci-vm-controller@<vmid>
# once it has a 'clean' snapshot without any runner registered (see
# docs/guides/pr-script-testing.md, "Single-use hosts").
#
# The token (/etc/ci-controller/token) is put there by hand: a fine-grained
# token for the two repositories with Administration: read and write only.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f ci-vm-controller.sh ]] || { echo "ci-vm-controller.sh missing next to this script" >&2; exit 1; }
command -v jq >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq jq >/dev/null
install -m 755 ci-vm-controller.sh /usr/local/sbin/ci-vm-controller.sh
install -d -m 700 /etc/ci-controller
cat >/usr/local/sbin/ci-gh-token <<'EOF'
#!/bin/sh
# Prints the token ci-vm-controller.sh uses (Administration: write on the CI
# repositories). Swap this for a GitHub App token later without touching
# the controller.
exec cat /etc/ci-controller/token
EOF
chmod 755 /usr/local/sbin/ci-gh-token
cat >/etc/systemd/system/ci-vm-controller@.service <<'EOF'
[Unit]
Description=Single-use CI runner host, VM %i
After=network-online.target pve-cluster.service pvedaemon.service
Wants=network-online.target
# A missing token is a setup error, not something to retry every 30 s.
ConditionPathExists=/etc/ci-controller/token
[Service]
ExecStart=/usr/local/sbin/ci-vm-controller.sh %i
Restart=always
RestartSec=30
# Stopping the controller leaves the VM as it is; the next start resets it,
# which also ends a job that may still be running there.
KillMode=mixed
[Install]
WantedBy=multi-user.target
EOF
# One repository per host: see ci-vm-controller.sh.
conf() { # conf <vmid> <ip> <name> <label> <repo>
cat >"/etc/ci-controller/$1.conf" <<EOF
VM_IP=$2
HOST_NAME=$3
LABEL=$4
REPOS=$5
EOF
}
conf 200 10.201.146.232 pve-ci proxmox-test community-scripts/DevScripts
conf 201 10.201.146.231 pve-ci-2 proxmox-test community-scripts/ProxmoxVE
conf 204 10.201.146.235 pve-ci-3 proxmox-test community-scripts/ProxmoxVE
conf 202 10.201.146.233 incus-ci incus-test community-scripts/DevScripts
conf 203 10.201.146.234 incus-ci-2 incus-test community-scripts/ProxmoxVE
systemctl daemon-reload
echo "Installed. Token: $([[ -s /etc/ci-controller/token ]] && echo present || echo 'MISSING (/etc/ci-controller/token)')"
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Keeps one CI VM (pve-ci, incus-ci, ...) as a single-use GitHub runner host.
# Runs on the hypervisor (prox1), which PR code never touches.
#
# Every cycle:
# 1. roll the VM back to its clean snapshot and boot it;
# 2. register a just-in-time runner on it (generate-jitconfig) for the
# host's repository. Such a runner takes exactly one job and is deleted
# by GitHub afterwards, so its credentials are worthless once that job
# is over;
# 3. (with several repositories in REPOS: as soon as one runner is busy,
# delete the others that are still idle, so no second job starts on a
# host that is about to be reset);
# 4. once every runner of this cycle is gone, start over at 1.
#
# PR code runs as root on the VM, so nothing the VM says is trusted: when a
# job is over is read from GitHub's API, never from the VM.
#
# Config: /etc/ci-controller/<vmid>.conf (or the environment), see below.
# Usage: ci-vm-controller.sh <vmid> run forever (the systemd unit)
# ONE_CYCLE=1 ci-vm-controller.sh <vmid> one job, then stop (testing)
set -euo pipefail
VMID=${1:?usage: ci-vm-controller.sh <vmid>}
CONF=${CONF:-/etc/ci-controller/$VMID.conf}
# shellcheck source=/dev/null
[[ -f "$CONF" ]] && . "$CONF"
: "${VM_IP:?VM_IP}" # address of the CI VM
: "${HOST_NAME:?HOST_NAME}" # runner name prefix, e.g. pve-ci
: "${LABEL:?LABEL}" # proxmox-test or incus-test
# One repository per host. Several would each have a runner waiting on the
# VM, and a job of one could read the idle runner's JIT config of the other
# and use it elsewhere before it is withdrawn: one repository's PR code
# faking the other's results. (Several are fine within one trust domain.)
: "${REPOS:?REPOS, e.g. community-scripts/DevScripts}"
EXTRA_LABELS=${EXTRA_LABELS:-} # space-separated, e.g. for a test
SNAPSHOT=${SNAPSHOT:-clean}
TOKEN_CMD=${TOKEN_CMD:-/usr/local/sbin/ci-gh-token} # prints a token with repo Administration: write
PVE=${PVE:-} # prefix for qm, e.g. "ssh root@prox1" when not run on it
GUEST_SSH=${GUEST_SSH:-"ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new root@$VM_IP"}
POLL=${POLL:-20}
MAX_BUSY_MIN=${MAX_BUSY_MIN:-180} # the test job's own limit is 90
MAX_IDLE_MIN=${MAX_IDLE_MIN:-720} # recycle an idle host twice a day
ONE_CYCLE=${ONE_CYCLE:-}
log() { echo "[$(date -u +%FT%TZ)] vm $VMID: $*"; }
qm() { if [[ -n "$PVE" ]]; then $PVE qm "$@"; else command qm "$@"; fi; }
# Not in a subshell, so the status and the cached token survive the call:
# api METHOD PATH [JSON] -> body in $API_BODY, HTTP status in $API_STATUS.
API_FILE=$(mktemp)
trap 'rm -f "$API_FILE"' EXIT
TOKEN="" TOKEN_AT=0
api() {
local method=$1 path=$2 data=${3:-}
# Cached for 30 minutes (an App installation token lives 60).
if [[ -z "$TOKEN" || $(($(date +%s) - TOKEN_AT)) -gt 1800 ]]; then
TOKEN=$($TOKEN_CMD) || { log "no GitHub token"; API_STATUS=000 API_BODY=""; return 1; }
TOKEN_AT=$(date +%s)
fi
API_STATUS=$(curl -sS -o "$API_FILE" -w '%{http_code}' -X "$method" \
-H "Authorization: Bearer $TOKEN" -H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' ${data:+-d "$data"} "https://api.github.com$path") || API_STATUS=000
API_BODY=$(<"$API_FILE")
[[ "$API_STATUS" != 000 ]]
}
reset_vm() {
log "rolling back to '$SNAPSHOT'"
[[ "$(qm status "$VMID")" == *running* ]] && qm stop "$VMID" --timeout 60 >/dev/null
qm rollback "$VMID" "$SNAPSHOT" >/dev/null
qm start "$VMID" >/dev/null
local i
for i in $(seq 1 60); do
$GUEST_SSH true 2>/dev/null && { log "booted"; return 0; }
sleep 5
done
log "no SSH after 5 minutes"
return 1
}
# Parallel arrays: one entry per runner registered in this cycle.
R_REPO=() R_ID=() R_GONE=()
register() { # register <owner/repo>
local repo=$1 name dir labels body cfg id
name="$HOST_NAME-${repo#*/}-$(date +%s)"
dir=/opt/actions-runner/${repo#*/}
labels=$(printf '%s\n' "$LABEL" $EXTRA_LABELS | jq -R . | jq -sc .)
api POST "/repos/$repo/actions/runners/generate-jitconfig" \
"$(jq -nc --arg n "$name" --argjson l "$labels" '{name: $n, runner_group_id: 1, labels: $l, work_folder: "_work"}')" || return 1
body=$API_BODY
[[ "$API_STATUS" == 201 ]] || { log "$repo: generate-jitconfig failed ($API_STATUS): $(jq -r .message <<<"$body" 2>/dev/null)"; return 1; }
id=$(jq -r .runner.id <<<"$body")
cfg=$(jq -r .encoded_jit_config <<<"$body")
# The config goes in through stdin, not the command line.
if ! $GUEST_SSH "umask 077; cat >/run/ci-jit-${repo#*/} && systemd-run --quiet --collect --unit=ci-jit-${repo#*/} \
-p WorkingDirectory=$dir -E RUNNER_ALLOW_RUNASROOT=1 \
/bin/sh -c 'cfg=\$(cat /run/ci-jit-${repo#*/}); rm -f /run/ci-jit-${repo#*/}; exec ./run.sh --jitconfig \"\$cfg\"'" <<<"$cfg"; then
log "$repo: could not start the runner on the VM"
api DELETE "/repos/$repo/actions/runners/$id" >/dev/null || true
return 1
fi
R_REPO+=("$repo") R_ID+=("$id") R_GONE+=(0)
log "$repo: runner $name (id $id) started"
}
drop() { # drop <index>: delete an idle runner; GitHub refuses a busy one
local i=$1
api DELETE "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" >/dev/null || return 1
if [[ "$API_STATUS" == 204 || "$API_STATUS" == 404 ]]; then R_GONE[$i]=1; return 0; fi
return 1
}
watch_cycle() {
local start busy_since="" i body status busy any_busy left offline_since=""
start=$(date +%s)
while :; do
sleep "$POLL"
any_busy=0 left=0 offline=0
for i in "${!R_ID[@]}"; do
[[ "${R_GONE[$i]}" == 1 ]] && continue
api GET "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" || { left=1; continue; }
body=$API_BODY
if [[ "$API_STATUS" == 404 ]]; then
R_GONE[$i]=1
log "${R_REPO[$i]}: runner ${R_ID[$i]} is gone (job done or removed)"
continue
fi
left=1
busy=$(jq -r .busy <<<"$body"); status=$(jq -r .status <<<"$body")
[[ "$busy" == true ]] && any_busy=1
[[ "$status" == offline ]] && offline=1
done
if [[ "$any_busy" == 1 && -z "$busy_since" ]]; then
busy_since=$(date +%s)
log "a job started; withdrawing the idle runners"
fi
# A host that has started a job takes no other one: it will be reset.
if [[ -n "$busy_since" ]]; then
for i in "${!R_ID[@]}"; do
[[ "${R_GONE[$i]}" == 1 ]] && continue
api GET "/repos/${R_REPO[$i]}/actions/runners/${R_ID[$i]}" || continue
body=$API_BODY
[[ "$API_STATUS" == 200 && "$(jq -r .busy <<<"$body")" == false ]] && drop "$i" &&
log "${R_REPO[$i]}: idle runner ${R_ID[$i]} withdrawn"
done
fi
[[ "$left" == 0 ]] && { log "cycle over"; return 0; }
local now; now=$(date +%s)
if [[ -n "$busy_since" && $((now - busy_since)) -gt $((MAX_BUSY_MIN * 60)) ]]; then
log "job still running after $MAX_BUSY_MIN minutes; resetting anyway"
break
fi
if [[ -z "$busy_since" && $((now - start)) -gt $((MAX_IDLE_MIN * 60)) ]]; then
log "idle for $MAX_IDLE_MIN minutes; recycling"
break
fi
# A runner that never comes online (or dies while idle) holds the host
# forever otherwise.
if [[ -z "$busy_since" && "$offline" == 1 ]]; then
[[ -z "$offline_since" ]] && offline_since=$now
if [[ $((now - offline_since)) -gt 300 ]]; then log "a runner is offline for 5 minutes; resetting"; break; fi
else
offline_since=""
fi
done
for i in "${!R_ID[@]}"; do [[ "${R_GONE[$i]}" == 1 ]] || drop "$i" || true; done
}
# Registrations of this host that a stopped or crashed controller left behind:
# their VM was reset, so they are offline and never take a job, but they
# clutter the runner list. Busy or online ones are left alone.
cleanup_stale() {
local repo ids id
for repo in $REPOS; do
api GET "/repos/$repo/actions/runners?per_page=100" || continue
[[ "$API_STATUS" == 200 ]] || continue
ids=$(jq -r --arg p "$HOST_NAME-${repo#*/}-" \
'.runners[] | select((.name | startswith($p)) and .status == "offline" and (.busy | not)) | .id' <<<"$API_BODY")
for id in $ids; do
api DELETE "/repos/$repo/actions/runners/$id" && log "$repo: stale runner $id removed"
done
done
}
while :; do
R_REPO=() R_ID=() R_GONE=()
if reset_vm; then
cleanup_stale
ok=0
for repo in $REPOS; do register "$repo" && ok=1 || true; done
if [[ "$ok" == 1 ]]; then watch_cycle; else log "no runner could be registered"; sleep 60; fi
else
sleep 60
fi
[[ -n "$ONE_CYCLE" ]] && { log "ONE_CYCLE: stopping; the VM is left as the job left it"; break; }
done
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# One-time setup of an Incus test host behind pr-script-test.yml: the same
# ct/ scripts as on Proxmox, built through community-scripts/core's Incus
# backend. Safe to re-run. Run as root on a disposable Debian 13 machine (it
# builds pull request code as root).
#
# 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
set -euo pipefail
RUNNER_VERSION="${RUNNER_VERSION:-2.338.2}"
SERVE_ROOT=/srv/ci
SERVE_PORT=18080
NET_ADDR=10.98.0.1
NET_CIDR=10.98.0.0/24
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq incus jq curl python3 iptables chromium fonts-dejavu fonts-noto-color-emoji >/dev/null
# Containers get a NATed bridge of their own (Incus runs DHCP and DNS on it)
# and a plain directory pool on the root disk.
if ! incus storage show default &>/dev/null; then
incus admin init --preseed <<EOF
networks:
- name: incusbr0
type: bridge
config:
ipv4.address: ${NET_ADDR}/24
ipv4.nat: "true"
ipv6.address: none
storage_pools:
- name: default
driver: dir
profiles:
- name: default
devices:
root:
path: /
pool: default
type: disk
eth0:
name: eth0
network: incusbr0
type: nic
EOF
fi
# Screenshots of the app under test are taken as this user, never as root.
id ci-shot &>/dev/null || useradd --system --create-home --shell /usr/sbin/nologin ci-shot
# Same rules as on the Proxmox hosts: the screenshot browser may reach the
# test containers, DNS and the public internet, nothing private, and not this
# host's own address on the container network (the PR tree server).
cat >/usr/local/sbin/ci-shot-firewall <<EOF
#!/usr/bin/env bash
set -euo pipefail
uid=\$(id -u ci-shot)
for fw in iptables ip6tables; do
\$fw -w -N CI_SHOT 2>/dev/null || \$fw -w -F CI_SHOT
\$fw -w -C OUTPUT -m owner --uid-owner "\$uid" -j CI_SHOT 2>/dev/null ||
\$fw -w -I OUTPUT -m owner --uid-owner "\$uid" -j CI_SHOT
done
iptables -w -A CI_SHOT -d ${NET_ADDR} -j REJECT
iptables -w -A CI_SHOT -d ${NET_CIDR} -j ACCEPT
# systemd-resolved's stub; it forwards to the lab resolver itself.
iptables -w -A CI_SHOT -d 127.0.0.53 -p udp --dport 53 -j ACCEPT
iptables -w -A CI_SHOT -d 127.0.0.53 -p tcp --dport 53 -j ACCEPT
for ns in \$(awk '/^nameserver/ && \$2 !~ /:/ {print \$2}' /etc/resolv.conf /run/systemd/resolve/resolv.conf 2>/dev/null); do
iptables -w -A CI_SHOT -d "\$ns" -p udp --dport 53 -j ACCEPT
iptables -w -A CI_SHOT -d "\$ns" -p tcp --dport 53 -j ACCEPT
done
for net in 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/3; do
iptables -w -A CI_SHOT -d "\$net" -j REJECT
done
for net in ::1/128 fc00::/7 fe80::/10 ff00::/8; do
ip6tables -w -A CI_SHOT -d "\$net" -j REJECT
done
EOF
chmod +x /usr/local/sbin/ci-shot-firewall
cat >/etc/systemd/system/ci-shot-firewall.service <<'EOF'
[Unit]
Description=Keep the screenshot browser away from this host and the lab network
After=network-online.target incus.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/ci-shot-firewall
[Install]
WantedBy=multi-user.target
EOF
[[ -s /etc/timezone ]] || {
timedatectl set-timezone Europe/Vienna
echo Europe/Vienna >/etc/timezone
}
# The rewritten PR trees, served to the engine and to the containers. Waits
# for incusbr0, which only exists once Incus is up.
mkdir -p "$SERVE_ROOT"
cat >/etc/systemd/system/ci-serve.service <<EOF
[Unit]
Description=Serve pull request trees to script builds
After=incus.service network-online.target
Wants=incus.service
[Service]
ExecStartPre=/bin/sh -c 'for i in \$(seq 1 60); do ip -4 addr show dev incusbr0 | grep -q ${NET_ADDR} && exit 0; incus network list >/dev/null 2>&1; sleep 1; done; exit 1'
ExecStart=/usr/bin/python3 -m http.server ${SERVE_PORT} --bind ${NET_ADDR} --directory ${SERVE_ROOT}
DynamicUser=yes
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# Leftovers of a job that was killed before its cleanup trap ran.
cat >/usr/local/sbin/ci-sweep <<'EOF'
#!/usr/bin/env bash
# Delete CI containers (ci-9000 .. ci-9099) that no running job holds, and stale trees.
# A job holds its guest with /run/ci-jobs/<id>. A job killed outright (runner
# restart, timeout) never removes it, so a lock older than any job can run
# (90 minutes) holds nothing.
held() { [[ -e "/run/ci-jobs/$1" && -z "$(find "/run/ci-jobs/$1" -mmin +180)" ]]; }
for name in $(incus list -c n -f csv 2>/dev/null | grep -E '^ci-9[0-9]{3}$'); do
held "$name" && continue
incus delete "$name" --force &>/dev/null && echo "swept $name"
done
find /run/ci-jobs -maxdepth 1 -type f ! -name ".*" -mmin +180 -delete 2>/dev/null
find /srv/ci -mindepth 1 -maxdepth 1 -type d -mmin +180 -exec rm -rf {} +
find /usr/local/community-scripts/logs /var/log/community-scripts -type f -mtime +2 -delete 2>/dev/null
find /opt/actions-runner/*/_diag -type f -mtime +14 -delete 2>/dev/null
true
EOF
chmod +x /usr/local/sbin/ci-sweep
cat >/etc/systemd/system/ci-sweep.service <<'EOF'
[Unit]
Description=Remove leftover CI containers
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ci-sweep
EOF
cat >/etc/systemd/system/ci-sweep.timer <<'EOF'
[Unit]
Description=Remove leftover CI containers every 3 hours
[Timer]
OnCalendar=0/3:30
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now ci-serve.service ci-sweep.timer ci-shot-firewall.service >/dev/null
systemctl restart ci-serve.service ci-shot-firewall.service
# --- Runner (one per repository) ----------------------------------------------
if [[ $# -eq 2 ]]; then
repo="$1"
token="$2"
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad repo" >&2; exit 2; }
dir="/opt/actions-runner/${repo#*/}"
mkdir -p "$dir"
cd "$dir"
if [[ ! -x ./config.sh ]]; 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
RUNNER_ALLOW_RUNASROOT=1 ./config.sh remove --token "$token" >/dev/null 2>&1 || true
fi
# Only `incus-test`: see pve-ci-host-setup.sh for why there are no default labels.
RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --replace --no-default-labels \
--url "https://github.com/${repo}" --token "$token" \
--name "$(hostname -s)-${repo#*/}" --labels incus-test --work _work
./svc.sh install root >/dev/null
./svc.sh start >/dev/null
echo "runner for ${repo} registered"
fi
echo "incus test host ready: ${SERVE_ROOT} on ${NET_ADDR}:${SERVE_PORT}, sweeper every 3h"
+31 -5
View File
@@ -5,6 +5,8 @@
#
# pve-ci-host-setup.sh host only
# pve-ci-host-setup.sh <owner/repo> <token> host + a runner for that repo
# pve-ci-host-setup.sh <owner/repo> jit host + runner software only, for
# ci-vm-controller.sh (single-use host)
#
# The token is a runner *registration* token (valid one hour):
# gh api -X POST repos/<owner/repo>/actions/runners/registration-token --jq .token
@@ -102,8 +104,9 @@ systemctl restart dnsmasq
echo Europe/Vienna >/etc/timezone
}
# Containers live on the root filesystem; there is no LVM in a cloud-image PVE.
pvesm set local --content iso,vztmpl,backup,rootdir,images
# Containers and VM disks live on the root filesystem; there is no LVM in a
# cloud-image PVE. Snippets hold the Cloud-Init configs some vm/ scripts write.
pvesm set local --content iso,vztmpl,backup,rootdir,images,snippets
# Templates are fetched once here rather than on every first build.
pveam update >/dev/null
@@ -130,12 +133,26 @@ EOF
# Leftovers of a job that was killed before its cleanup trap ran.
cat >/usr/local/sbin/ci-sweep <<'EOF'
#!/usr/bin/env bash
# Destroy CI containers (9000-9099) that no running job holds, and stale trees.
# Destroy CI containers (9000-9099) and VMs (9100-9199) that no running job
# holds, and stale trees.
# A job holds its guest with /run/ci-jobs/<id>. A job killed outright (runner
# restart, timeout) never removes it, so a lock older than any job can run
# (90 minutes) holds nothing.
held() { [[ -e "/run/ci-jobs/$1" && -z "$(find "/run/ci-jobs/$1" -mmin +180)" ]]; }
for id in $(pct list | awk 'NR>1 && $1>=9000 && $1<=9099 {print $1}'); do
[[ -e "/run/ci-jobs/$id" ]] && continue
held "$id" && continue
pct stop "$id" --skiplock 1 &>/dev/null || true
pct destroy "$id" --purge 1 --force 1 &>/dev/null && echo "swept CT $id"
done
for id in $(qm list | awk 'NR>1 && $1>=9100 && $1<=9199 {print $1}'); do
held "$id" && continue
qm stop "$id" --skiplock 1 --timeout 30 &>/dev/null || true
qm destroy "$id" --purge 1 --destroy-unreferenced-disks 1 --skiplock 1 &>/dev/null && echo "swept VM $id"
done
# VM images and ISOs are cached between builds; drop the ones no build used
# for a week. Container templates (*.tar.*) stay.
find /var/lib/vz/template/cache /var/lib/vz/template/iso -maxdepth 1 -type f ! -name '*.tar.*' -atime +7 -delete 2>/dev/null
find /run/ci-jobs -maxdepth 1 -type f ! -name ".*" -mmin +180 -delete 2>/dev/null
find /srv/ci -mindepth 1 -maxdepth 1 -type d -mmin +180 -exec rm -rf {} +
# Engine logs that a killed job never got to remove.
find /tmp -maxdepth 1 -type f \( -name 'create-lxc-*.log' -o -name 'pct_create_*.log' \
@@ -181,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
@@ -192,9 +216,11 @@ if [[ $# -eq 2 ]]; then
# --no-default-labels: without it the runner also answers to `self-hosted`,
# and any other workflow with `runs-on: self-hosted` (and its secrets) could
# land on this host.
# Named after this host: with --replace, a fixed name would make a second
# test host take over the first one's registration instead of adding to it.
RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --replace --no-default-labels \
--url "https://github.com/${repo}" --token "$token" \
--name "pve-ci-${repo#*/}" --labels proxmox-test --work _work
--name "$(hostname -s)-${repo#*/}" --labels proxmox-test --work _work
./svc.sh install root >/dev/null
./svc.sh start >/dev/null
echo "runner for ${repo} registered"
+437 -64
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env bash
# Build one ct/ script from a pull request on this Proxmox host, check that the
# container really works, destroy it, and leave a result.json behind.
# Build one ct/ or vm/ script from a pull request on this test host, check
# that the container or VM really works, destroy it, and leave a result.json
# behind. The vm/ part lives in pve-run-vm.sh, sourced from here.
#
# PLATFORM says what this host is: pve (Proxmox VE, pct) or incus. The ct/
# scripts are the same for both; community-scripts/core picks its Incus
# backend by itself when it finds incus instead of pveversion. Only the way
# this harness reaches into the container differs (guest_* below).
#
# Runs on the self-hosted runner, which *is* the disposable test host. The
# harness comes from the default branch; only the script under test comes from
@@ -17,10 +23,17 @@ set -uo pipefail
: "${PR_REPO:?}" "${PR_SHA:?}" "${SCRIPT:?}" "${UPSTREAM:?}" "${CTID_BASE:?}" "${OUT_DIR:?}"
OS="${OS:-}"
PLATFORM="${PLATFORM:-pve}"
CTID_COUNT="${CTID_COUNT:-50}"
SERVE_ROOT="${SERVE_ROOT:-/srv/ci}"
SERVE_URL="${SERVE_URL:-http://10.99.0.1:18080}"
BRIDGE="${BRIDGE:-vmbr1}"
# The containers' own network: vmbr1 + dnsmasq on Proxmox, incusbr0 on Incus.
if [[ "$PLATFORM" == "incus" ]]; then
SERVE_URL="${SERVE_URL:-http://10.98.0.1:18080}"
BRIDGE="${BRIDGE:-incusbr0}"
else
SERVE_URL="${SERVE_URL:-http://10.99.0.1:18080}"
BRIDGE="${BRIDGE:-vmbr1}"
fi
BUILD_TIMEOUT="${BUILD_TIMEOUT:-45m}"
URL_WAIT="${URL_WAIT:-120}"
@@ -28,8 +41,24 @@ URL_WAIT="${URL_WAIT:-120}"
[[ "$PR_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad PR_REPO" >&2; exit 2; }
[[ "$UPSTREAM" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad UPSTREAM" >&2; exit 2; }
[[ "$PR_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "bad PR_SHA" >&2; exit 2; }
[[ "$SCRIPT" =~ ^ct/[A-Za-z0-9._-]+\.sh$ ]] || { echo "bad SCRIPT" >&2; exit 2; }
[[ "$SCRIPT" =~ ^(ct|vm)/[A-Za-z0-9._-]+\.sh$ ]] || { echo "bad SCRIPT" >&2; exit 2; }
[[ -z "$OS" || "$OS" =~ ^(debian|ubuntu|alpine)$ ]] || { echo "bad OS" >&2; exit 2; }
[[ "$PLATFORM" =~ ^(pve|incus)$ ]] || { echo "bad PLATFORM" >&2; exit 2; }
# yes: build with var_arm64=yes. On arm64 the engine refuses to guess for a
# 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; }
mkdir -p "$OUT_DIR"
@@ -37,24 +66,75 @@ RAW_LOG="$OUT_DIR/build.raw.log"
LOG="$OUT_DIR/build.log"
RESULT="$OUT_DIR/result.json"
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}-${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"
step=""
exit_code=""
ctid=""
ctid="" # the guest's ID (pve: 9000, incus: ci-9000), container or VM
# The container, whatever the platform. ctid is set once a slot is reserved.
guest_exec() {
if [[ "$PLATFORM" == "incus" ]]; then incus exec "$ctid" -- "$@"; else pct exec "$ctid" -- "$@"; fi
}
guest_exists() {
if [[ "$PLATFORM" == "incus" ]]; then incus info "$ctid" &>/dev/null; else pct status "$ctid" &>/dev/null; fi
}
guest_running() {
if [[ "$PLATFORM" == "incus" ]]; then
[[ "$(incus list "^${ctid}\$" -c s -f csv 2>/dev/null)" == "RUNNING" ]]
else
pct status "$ctid" 2>/dev/null | grep -q running
fi
}
guest_destroy() {
if [[ "$PLATFORM" == "incus" ]]; then
incus delete "$ctid" --force &>/dev/null || true
else
pct stop "$ctid" --skiplock 1 &>/dev/null || true
pct destroy "$ctid" --purge 1 --force 1 &>/dev/null || true
fi
}
ct_ip=""
url=""
http_code=""
failed_units=""
# updated: a real update ran; no-update: update ran, nothing to update;
# 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
# the runner's locale may be C.
SPINNER_RE='^\s*(⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)'
strip_ansi() {
sed -E 's/\x1B\[[0-9;?]*[A-Za-z]//g; s/\x1B[()][A-Z0-9]//g; s/\r/\n/g' | grep -vE '^\s*$'
sed -E 's/\x1B\[[0-9;?]*[A-Za-z]//g; s/\x1B[()][A-Z0-9]//g; s/\r/\n/g' | grep -vE '^\s*$' | grep -vE "$SPINNER_RE"
}
# The install script's own output: everything $STD hides from the build log.
@@ -64,11 +144,12 @@ strip_ansi() {
collect_install_log() {
[[ -n "$ctid" ]] || return 0
# Exactly one build ran in this container, so the newest install log is ours.
if pct status "$ctid" &>/dev/null &&
pct exec "$ctid" -- sh -c 'cat "$(ls -t /root/.install-*.log 2>/dev/null | head -n1)"' 2>/dev/null |
if guest_exists &&
guest_exec sh -c 'cat "$(ls -t /root/.install-*.log 2>/dev/null | head -n1)"' 2>/dev/null |
strip_ansi >"$INSTALL_LOG_OUT" && [[ -s "$INSTALL_LOG_OUT" ]]; then
return 0
fi
[[ "$PLATFORM" == "pve" ]] || { rm -f "$INSTALL_LOG_OUT"; return 0; }
# The engine names its host-side files after the session: pct_create_<ctid>_<date>_<session>.log
local sid f
sid=$(find /tmp -maxdepth 1 -name "pct_create_${ctid}_*.log" -newer "$RAW_LOG.start" -printf '%f\n' 2>/dev/null |
@@ -107,19 +188,73 @@ 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"
[[ -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 os "${OS:-default}" --arg status "$status" \
--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 failed_units "$failed_units" --arg ip "$ct_ip" --arg arch "$(uname -m)" \
--arg update_status "$update_status" --arg update_step "$update_step" \
--argjson duration "$(($(date +%s) - started))" \
--arg tail "$([[ -f "$LOG" ]] && tail -n 60 "$LOG")" \
'{script: $script, os: $os, status: $status, step: $step, exit_code: $exit_code,
sha: $sha, ctid: $ctid, url: $url, http_code: $http_code,
failed_units: $failed_units, duration: $duration, log_tail: $tail}' >"$RESULT"
'{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"
echo "::endgroup::"
@@ -139,7 +274,7 @@ install_pid=""
# Colours stay, cursor movement and spinner redraws go.
live_filter() {
sed -u -E 's/\r/\n/g; s/\x1B\[[0-9;?]*[ABCDEFGHJKSTfhilnsu]//g' | sed -u -E '/^\s*$/d'
sed -u -E 's/\r/\n/g; s/\x1B\[[0-9;?]*[ABCDEFGHJKSTfhilnsu]//g' | sed -u -E "/^\s*\$/d; /${SPINNER_RE}/d"
}
start_streams() {
@@ -147,17 +282,21 @@ start_streams() {
echo "::stop-commands::${stream_token}"
tail -n +1 -F --pid="$build_pid" "$RAW_LOG" 2>/dev/null > >(live_filter) &
log_pid=$!
# A VM has no install log to follow: its script runs on this host only.
[[ "$kind" == "ct" ]] || return 0
# setsid: its own process group, so stop_streams can take down the
# pct exec / tail pair inside it in one go.
# pct/incus exec + tail pair inside it in one go.
setsid bash -c '
gx() { if [ "$2" = incus ]; then incus exec "$1" -- "${@:3}"; else pct exec "$1" -- "${@:3}"; fi; }
for _ in $(seq 1 900); do
f=$(pct exec "$1" -- sh -c "ls -t /root/.install-*.log 2>/dev/null | head -n1" 2>/dev/null)
f=$(gx "$1" "$2" sh -c "ls -t /root/.install-*.log 2>/dev/null | head -n1" 2>/dev/null)
if [ -n "$f" ]; then
echo "──────── install log of CT $1 (live) ────────"
if [ "$2" = incus ]; then exec incus exec "$1" -- tail -n +1 -F "$f"; fi
exec pct exec "$1" -- tail -n +1 -F "$f"
fi
sleep 2
done' _ "$ctid" 2>/dev/null > >(live_filter | sed -u 's/^/ │ /') &
done' _ "$ctid" "$PLATFORM" 2>/dev/null > >(live_filter | sed -u 's/^/ │ /') &
install_pid=$!
}
@@ -174,29 +313,31 @@ stop_streams() {
}
build_pid=""
update_pid=""
cleanup() {
stop_streams
if [[ -n "$build_pid" ]]; then
pkill -TERM -P "$build_pid" &>/dev/null || true
kill -TERM "$build_pid" &>/dev/null || true
local pid
for pid in "$build_pid" "$update_pid"; do
[[ -n "$pid" ]] || continue
pkill -TERM -P "$pid" &>/dev/null || true
kill -TERM "$pid" &>/dev/null || true
sleep 2
fi
if [[ -n "$ctid" ]]; then
if pct status "$ctid" &>/dev/null; then
pct stop "$ctid" --skiplock 1 &>/dev/null || true
pct destroy "$ctid" --purge 1 --force 1 &>/dev/null || true
fi
done
if [[ -n "$ctid" && "$kind" == "vm" ]]; then
vm_cleanup
elif [[ -n "$ctid" ]]; then
guest_exists && guest_destroy
rm -f "/run/ci-jobs/$ctid"
# What the engine leaves on the host for this container and its session.
local f sid
for f in /tmp/pct_create_"${ctid}"_*.log; do
[[ "$PLATFORM" == "pve" ]] && for f in /tmp/pct_create_"${ctid}"_*.log; do
[[ -f "$f" && "$f" -nt "$RAW_LOG.start" ]] || continue
sid=$(sed -E 's/.*_([0-9a-f]+)\.log$/\1/' <<<"$f")
rm -f -- "$f" /tmp/create-lxc-"${sid}".log /tmp/*-"${ctid}"-"${sid}".log
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.
@@ -215,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
@@ -231,19 +376,33 @@ setup_error() {
# --- Reserve a container ID ---------------------------------------------------
# A lock file per ID, not just "first free one", because the engine only creates
# the container minutes later and two jobs would otherwise pick the same ID.
# VMs take the range 100 above the containers' (DevScripts 9100-9149,
# ProxmoxVE 9150-9199).
mkdir -p /run/ci-jobs
if [[ "$kind" == "vm" ]]; then
source "$(dirname "$0")/pve-run-vm.sh"
CTID_BASE=$((CTID_BASE + 100))
vm_wait_turn
fi
exec 9>/run/ci-jobs/.lock
flock 9
# On Incus the ID is an instance name: var_ctid=ci-9000 names the container.
for ((id = CTID_BASE; id < CTID_BASE + CTID_COUNT; id++)); do
[[ -e "/run/ci-jobs/$id" ]] && continue
pvesh get /cluster/nextid --vmid "$id" &>/dev/null || continue
echo "$job" >"/run/ci-jobs/$id"
ctid=$id
name=$id
[[ "$PLATFORM" == "incus" ]] && name="ci-$id"
[[ -e "/run/ci-jobs/$name" ]] && continue
if [[ "$PLATFORM" == "incus" ]]; then
incus info "$name" &>/dev/null && continue
else
pvesh get /cluster/nextid --vmid "$id" &>/dev/null || continue
fi
echo "$job" >"/run/ci-jobs/$name"
ctid=$name
break
done
flock -u 9
exec 9>&-
[[ -n "$ctid" ]] || setup_error "no free container ID in ${CTID_BASE}-$((CTID_BASE + CTID_COUNT - 1))"
[[ -n "$ctid" ]] || setup_error "no free ${kind} ID in ${CTID_BASE}-$((CTID_BASE + CTID_COUNT - 1))"
# --- Fetch the approved commit and point it at ourselves ------------------------
mkdir -p "$tree"
@@ -255,19 +414,48 @@ 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}) 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_container_storage=local var_template_storage=local var_brg="$BRIDGE"
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$base"
var_ctid="$ctid" var_hostname="$hostname_safe" var_brg="$BRIDGE"
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$src_base"
)
if [[ "$PLATFORM" == "incus" ]]; then
build_env+=(var_container_storage=default)
else
build_env+=(var_container_storage=local var_template_storage=local)
fi
touch "$RAW_LOG.start"
[[ -n "$OS" ]] && build_env+=(var_os="$OS")
[[ -n "$VAR_ARM64" ]] && build_env+=(var_arm64=yes)
# Both engines turn var_github_token into GITHUB_TOKEN inside the container.
[[ -n "${GH_API_TOKEN:-}" ]] && build_env+=(var_github_token="$GH_API_TOKEN")
# --- Answers for the script's questions ------------------------------------------
# Nobody types into CI, so anything the install asks has to be answered up front.
@@ -276,7 +464,7 @@ touch "$RAW_LOG.start"
# CI value here. The names come from json/<app>.json app_vars (DevScripts) and
# from the read/prompt_ lines of the install script (both repos). A bare `read`
# that ignores its variable still fails, and the report says why.
ENGINE_VARS=" var_ctid var_hostname var_os var_version var_cpu var_ram var_disk var_brg var_net var_container_storage var_template_storage var_unprivileged var_tags var_arm64 var_testurl var_verbose var_timezone var_http_proxy var_http_no_proxy var_appversion "
ENGINE_VARS=" var_ctid var_hostname var_os var_version var_cpu var_ram var_disk var_brg var_net var_container_storage var_template_storage var_unprivileged var_tags var_arm64 var_testurl var_verbose var_timezone var_http_proxy var_http_no_proxy var_appversion var_github_token "
host_app=$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}")
ci_value() {
case "${1,,}" in
@@ -295,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"
@@ -327,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]=""
@@ -351,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"
@@ -398,53 +586,238 @@ fi
grep -qi 'completed successfully' "$RAW_LOG" || fail "build exited 0 but never reported success"
# --- Checks ---------------------------------------------------------------------
pct status "$ctid" 2>/dev/null | grep -q running || fail "container ${ctid} is not running"
guest_running || fail "container ${ctid} is not running"
ct_ip=$(pct exec "$ctid" -- sh -c "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null | head -n1)
if pct exec "$ctid" -- sh -c 'command -v systemctl' &>/dev/null; then
sleep 10
failed_units=$(pct exec "$ctid" -- systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}' | paste -sd, -)
elif pct exec "$ctid" -- sh -c 'command -v rc-status' &>/dev/null; then
failed_units=$(pct exec "$ctid" -- rc-status --crashed 2>/dev/null | awk '{print $1}' | paste -sd, -)
fi
[[ -z "$failed_units" ]] || fail "failed services: ${failed_units}"
ct_ip=$(guest_exec sh -c "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null | head -n1)
# What the app's page looks like, for the PR comment. Chromium runs as its own
# unprivileged user with its sandbox on: the page is served by code under test.
# ci-shot-firewall (pve-ci-host-setup.sh) keeps that user off this host and the
# lab network; without it there is no screenshot rather than an unguarded one.
# $1: file name in OUT_DIR (screenshot.png after install, -update after update).
take_screenshot() {
command -v chromium >/dev/null && id ci-shot &>/dev/null || return 0
iptables -w -S CI_SHOT 2>/dev/null | grep -q -- '-d 10.0.0.0/8 .*REJECT' || return 0
local dir
dir=$(mktemp -d)
chown ci-shot "$dir"
runuser -u ci-shot -- timeout 60 chromium --headless=new --disable-gpu --hide-scrollbars \
# HOME: runuser keeps root's, which ci-shot cannot write; Playwright's
# Chromium (the ARM runner) then crashes in its crash handler.
runuser -u ci-shot -- env HOME="$dir" timeout 60 chromium --headless=new --disable-gpu --hide-scrollbars \
--ignore-certificate-errors --no-first-run --window-size=1280,800 --virtual-time-budget=10000 \
--user-data-dir="$dir/profile" --screenshot="$dir/shot.png" "$url" &>/dev/null
# Only a plausible PNG leaves this function.
if [[ -s "$dir/shot.png" && $(stat -c %s "$dir/shot.png") -lt 3000000 ]] &&
[[ "$(head -c 8 "$dir/shot.png" | od -An -tx1 | tr -d ' \n')" == "89504e470d0a1a0a" ]]; then
cp "$dir/shot.png" "$OUT_DIR/screenshot.png"
cp "$dir/shot.png" "$OUT_DIR/${1:-screenshot.png}"
fi
rm -rf -- "$dir"
}
# The URL the script tells the user to open, e.g. http://${IP}:1337/admin, has
# to answer 200 (after redirects, so a jump to /login is fine). A script that
# prints no URL has no web UI to check.
pattern=$(grep -oE 'https?://\$\{IP\}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?' "$tree/$SCRIPT" | head -n1)
if [[ -n "$pattern" && -n "$ct_ip" ]]; then
url="${pattern/\$\{IP\}/$ct_ip}"
# Is the app up? No failed services, and the URL the script tells the user to
# 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.
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=""
failed_units=""
if guest_exec sh -c 'command -v systemctl' &>/dev/null; then
sleep 10
failed_units=$(guest_exec systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}' | paste -sd, -)
elif guest_exec sh -c 'command -v rc-status' &>/dev/null; then
failed_units=$(guest_exec rc-status --crashed 2>/dev/null | awk '{print $1}' | paste -sd, -)
fi
[[ -z "$failed_units" ]] || { health_problem="failed services: ${failed_units}"; return 1; }
[[ -n "$url" ]] || return 0
local waited
for ((waited = 0; waited < URL_WAIT; waited += 5)); do
http_code=$(curl -sk -L --max-redirs 5 -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && break
sleep 5
done
# Also when the check fails: an error page explains more than a status code.
[[ "$http_code" =~ ^[1-5][0-9][0-9]$ ]] && take_screenshot
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] || fail "${url} answered ${http_code/#000/nothing} instead of 200 within ${URL_WAIT}s"
[[ "$http_code" =~ ^[1-5][0-9][0-9]$ ]] && take_screenshot "$1"
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] ||
{ health_problem="${url} answered ${http_code/#000/nothing} instead of 200 within ${URL_WAIT}s"; return 1; }
}
check_health screenshot.png || fail "$health_problem"
# --- Update -------------------------------------------------------------------------
# A fresh install is already on the latest release, so a plain `update` would
# 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. 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() {
update_status="fail"
update_step="$1"
fail "update: $1"
}
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_+-]+)+|[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
# touches (immich: ~/.mise); those would stay 0.0.0-ci and fail the run. A
# name that is a variable can't be read here, so then all of them are set back.
tracked=$(grep -oE 'check_for_[a-z]*_?release[[:space:]]+("[^"]*"|[^[:space:]]+)' "$tree/$SCRIPT" |
sed -E 's/^check_for_[a-z]*_?release[[:space:]]+//; s/^"(.*)"$/\1/' | tr -d ' ' | tr '[:upper:]' '[:lower:]' | sort -u)
if [[ -n "$tracked" && "$tracked" != *'$'* ]]; then
version_files=$(grep -xF -f <(sed 's|^|/root/.|' <<<"$tracked") <<<"$version_files" || true)
fi
for f in $version_files; do
guest_exec sh -c "echo '$UPDATE_FAKE' > '$f'; rm -f '$f.pending'" ||
update_fail "could not reset $f"
done
[[ -n "$version_files" ]] && echo "Update test: set back $(tr '\n' ' ' <<<"$version_files")to ${UPDATE_FAKE}"
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 <"${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}"
elif ((update_rc != 0)); then
last=$(update_failure_line)
update_fail "exited ${update_rc}${last:+: $last}"
fi
# The engine can report an error and still exit 0 (bark-server: "github: rate
# limit reached", then "Cleaned"). A rate limit is ours, not the PR's: the
# update could not be tested. Any other error line is a failed update.
update_error=$(strip_ansi <"$UPDATE_RAW" | grep -E '✖|\[ERROR\]' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180)
if [[ -n "$update_error" ]]; then
if [[ "${update_error,,}" == *"rate limit"* ]]; then
update_status="error"
update_step="could not be tested: ${update_error}"
status="pass"
finish
exit 0
fi
update_fail "$update_error"
fi
if strip_ansi <"$UPDATE_RAW" | grep -qi 'updated successfully'; then
update_status="updated"
for f in $version_files; do
[[ "$(guest_exec cat "$f" 2>/dev/null)" != "$UPDATE_FAKE" ]] ||
update_fail "reported success, but $f is still ${UPDATE_FAKE}"
done
else
# Ran cleanly without updating anything: no version file to fake (apt/npm
# based), or an update_script that decides differently. Not a failure.
update_status="no-update"
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"
+140
View File
@@ -0,0 +1,140 @@
# shellcheck shell=bash
# The vm/ half of pve-run-script.sh, sourced from it: same inputs, same
# result.json, same live output. Only building and checking differ.
#
# A VM script runs entirely on this host (there is no install script inside
# the guest to follow), unattended through the core engine's VM_UNATTENDED=1:
# the script's defaults, with ID, name, bridge and storage set from here. The
# guest then boots on vmbr1 like the containers do, so its address is whatever
# this host's dnsmasq leased to its MAC; no guest agent needed.
#
# What passes: the script exits 0 and says "Completed successfully", the VM is
# running and keeps running through the boot wait, and where the script prints
# a URL built from the VM's address (http://${VM_IP}:9000) that URL answers 200.
# An installer ISO or an image without DHCP gets no address; that is reported,
# not failed. There is no update test: a VM has no `update`.
VM_BOOT_WAIT="${VM_BOOT_WAIT:-300}"
VM_URL_WAIT="${VM_URL_WAIT:-600}"
LEASES="${LEASES:-/var/lib/misc/dnsmasq.leases}"
# One VM build per host at a time: a VM takes 2-8 GB of RAM and a disk image,
# where a container takes a fraction. Held until the job exits; the build is
# started with this descriptor closed, so a QEMU process can never inherit it.
vm_wait_turn() {
exec 7>/run/ci-jobs/.vm-turn
flock -n 7 && return 0
echo "Another VM build is running on this host; waiting for it (one VM at a time)"
flock -w 3600 7 || setup_error "waited an hour for this host's VM slot"
}
vm_cleanup() {
if qm status "$ctid" &>/dev/null; then
qm stop "$ctid" --skiplock 1 --timeout 30 &>/dev/null || true
qm destroy "$ctid" --purge 1 --destroy-unreferenced-disks 1 --skiplock 1 &>/dev/null || true
fi
rm -f "/run/ci-jobs/$ctid" "/run/ci-jobs/console-${ctid}.png"
# Cloud-Init network snippets the script wrote for this VM.
[[ -f "$RAW_LOG.start" ]] &&
find /var/lib/vz/snippets -maxdepth 1 -type f -name "*${ctid}*" -newer "$RAW_LOG.start" -delete 2>/dev/null
true
}
# The VM's screen, straight from QEMU. Pixels only, but they come from the
# guest, so the same checks as for a browser screenshot apply.
vm_console_shot() {
local f="/run/ci-jobs/console-${ctid}.png"
rm -f "$f"
printf 'screendump %s -f png\n' "$f" | timeout 30 qm monitor "$ctid" &>/dev/null
if [[ -s "$f" && $(stat -c %s "$f") -lt 3000000 ]] &&
[[ "$(head -c 8 "$f" | od -An -tx1 | tr -d ' \n')" == "89504e470d0a1a0a" ]]; then
cp "$f" "$OUT_DIR/screenshot.png"
fi
rm -f "$f"
}
vm_running() {
qm status "$ctid" 2>/dev/null | grep -q 'status: running'
}
vm_build_and_check() {
local hn mac pattern waited last
hn="ci-$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}" | cut -c1-40)-${ctid}"
echo "Building ${SCRIPT} as VM ${ctid} from ${PR_REPO}@${PR_SHA:0:7}"
local vm_env=(
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root TERM=dumb LANG=C.UTF-8 DIAGNOSTICS=no
VM_UNATTENDED=1 VM_VMID="$ctid" VM_HOSTNAME="$hn" VM_BRIDGE="$BRIDGE"
VM_STORAGE=local VM_ISO_STORAGE=local VM_START=yes
COMMUNITY_SCRIPTS_URL="$base"
)
# Release lookups (vm_latest_from_github and the like).
[[ -n "${GH_API_TOKEN:-}" ]] && vm_env+=(GITHUB_TOKEN="$GH_API_TOKEN")
touch "$RAW_LOG.start"
env -i "${vm_env[@]}" timeout --kill-after=1m "$BUILD_TIMEOUT" \
bash -c 'bash -c "$(curl -fsSL "$1")"' _ "$base/$SCRIPT" </dev/null >"$RAW_LOG" 2>&1 7>&- &
build_pid=$!
start_streams
wait "$build_pid"
exit_code=$?
build_pid=""
stop_streams
((exit_code == 124 || exit_code == 137)) && fail "build timed out after ${BUILD_TIMEOUT}"
if ((exit_code != 0)); then
last=$(failure_line)
step="script exited ${exit_code}${last:+: $last}"
status="fail"
classify_cpu_failure
fail "$step" "$status"
fi
# vm_finish ends on a msg_ok of the script's choosing ("VM created. ..."),
# older scripts on "Completed successfully". exit_script and friends leave
# with 0 too, but on a cross.
last=$(strip_ansi <"$RAW_LOG" | tail -n 1 | cut -c1-200)
if ! grep -qi 'completed successfully' "$RAW_LOG" && ! [[ "$last" =~ ^[[:space:]]*✔ ]]; then
fail "script exited 0 but never reported success${last:+ (last line: ${last})}"
fi
vm_running || fail "VM ${ctid} is not running after the script finished"
# The address dnsmasq handed to this VM's MAC. The VM must stay up while it
# boots: one that powers itself off or crashes is a failure.
mac=$(qm config "$ctid" 2>/dev/null | sed -nE 's/^net0:[^=]*=([0-9A-Fa-f:]{17}).*/\1/p' | tr 'A-F' 'a-f')
echo "Waiting up to ${VM_BOOT_WAIT}s for VM ${ctid} (${mac:-no NIC}) to boot and get an address"
for ((waited = 0; waited < VM_BOOT_WAIT; waited += 5)); do
vm_running || fail "VM ${ctid} stopped by itself while booting"
[[ -n "$mac" ]] && ct_ip=$(awk -v m="$mac" '$2 == m {print $3}' "$LEASES" 2>/dev/null | tail -n 1)
[[ -n "$ct_ip" ]] && break
sleep 5
done
[[ -n "$ct_ip" ]] && echo "VM ${ctid} got ${ct_ip}" || echo "VM ${ctid} got no DHCP lease within ${VM_BOOT_WAIT}s"
# Only a URL the script builds from the address it found; <VM-IP> in a
# summary is a hint for later (after an installer ran), not a promise.
# ${VM_IP}, $VM_IP and ${VM_IP:-<VM-IP>} all count.
local ip_re='\$\{?VM_IP(:-[^}]*)?\}?'
pattern=$(grep -oE "https?://${ip_re}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?" "$tree/$SCRIPT" | head -n1)
if [[ -n "$pattern" ]]; then
[[ -n "$ct_ip" ]] || fail "no IP address within ${VM_BOOT_WAIT}s, so ${pattern} could not be checked"
url=$(sed -E "s#${ip_re}#${ct_ip}#" <<<"$pattern")
for ((waited = 0; waited < VM_URL_WAIT; waited += 10)); do
vm_running || fail "VM ${ctid} stopped by itself while its web UI was awaited"
http_code=$(curl -sk -L --max-redirs 5 -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && break
sleep 10
done
[[ "$http_code" =~ ^[1-5][0-9][0-9]$ ]] && take_screenshot screenshot.png
else
# Let the console settle on a login prompt rather than a boot message.
sleep 20
fi
[[ -s "$OUT_DIR/screenshot.png" ]] || vm_console_shot
if [[ -n "$url" && ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
fail "${url} answered ${http_code/#000/nothing} instead of 200 within ${VM_URL_WAIT}s"
fi
vm_running || fail "VM ${ctid} stopped by itself"
status="pass"
finish
}
+52
View File
@@ -581,6 +581,58 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
</details>
## 2026-11-12
### 🆕 New Scripts
- Pixelfed ([#18523](https://github.com/community-scripts/ProxmoxVE/pull/18523))
### 🚀 Updated Scripts
- #### 🐞 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
- #### 🐞 Bug Fixes
- TrueNas: Fixing isolookup and dialog [@juronja](https://github.com/juronja) ([#18578](https://github.com/community-scripts/ProxmoxVE/pull/18578))
- #### ✨ New Features
- Immich: bump to 3.4.1 [@MickLesk](https://github.com/MickLesk) ([#18619](https://github.com/community-scripts/ProxmoxVE/pull/18619))
- Immich: bump to 3.4.0 [@MickLesk](https://github.com/MickLesk) ([#18600](https://github.com/community-scripts/ProxmoxVE/pull/18600))
- #### 💥 Breaking Changes
- Pocketbase: install and update from the new host (removed in github) [@MickLesk](https://github.com/MickLesk) ([#18609](https://github.com/community-scripts/ProxmoxVE/pull/18609))
- #### 🔧 Refactor
- Paperless-AI: install and update to 4.0 [@MickLesk](https://github.com/MickLesk) ([#18620](https://github.com/community-scripts/ProxmoxVE/pull/18620))
### 💾 Core
- core: drop the SUDO_ variables an update through sudo carries [@MickLesk](https://github.com/MickLesk) ([core#229](https://github.com/community-scripts/core/pull/229))
- feat: let setup_deb822_repo use a keyring already on the system [@MickLesk](https://github.com/MickLesk) ([core#227](https://github.com/community-scripts/core/pull/227))
### 📚 Documentation
- Fill platforms when a script moves from DevScripts [@MickLesk](https://github.com/MickLesk) ([#18611](https://github.com/community-scripts/ProxmoxVE/pull/18611))
## 2026-11-10
### 🚀 Updated Scripts
+5 -5
View File
@@ -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}"
+94 -207
View File
@@ -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,11 +79,8 @@ 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.3.6"
RELEASE="v3.4.1"
if [[ -f ~/.intel_version ]]; then
INTEL_SRC="https://raw.githubusercontent.com/immich-app/immich/${RELEASE}/machine-learning"
curl -fsSL "${INTEL_SRC}/scripts/install-intel-runtime.sh" -o ./intel-runtime 2>/dev/null ||
@@ -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
$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
@@ -234,7 +227,7 @@ EOF
# plugins
cd "$SRC_DIR"
export MISE_TRUSTED_CONFIG_PATHS="$SRC_DIR"/mise.toml
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,npm:@openapitools/openapi-generator-cli
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,restic,npm:@openapitools/openapi-generator-cli
export MISE_LOCKFILE_MODE=merge MISE_LOCKFILE_PLATFORMS="linux-$(arch_resolve x64 arm64)"
mise_ok=0
for i in 1 2 3; do
@@ -251,7 +244,8 @@ EOF
# extism-js ships as a bare gzip-compressed single binary (.gz) that
# fetch_and_deploy_gh_release cannot deploy; fetch + gunzip it directly.
EXTISM_ARCH="$(arch_resolve x86_64 aarch64)"
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-${EXTISM_ARCH}-linux-v1.6.0.gz"
EXTISM_VERSION="$(grep -oP '"github:extism/js-pdk"\s*=\s*"\K[^"]+' "$SRC_DIR"/mise.toml)"
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/${EXTISM_VERSION}/extism-js-${EXTISM_ARCH}-linux-${EXTISM_VERSION}.gz"
gunzip -f /tmp/extism-js.gz
install -m 0755 /tmp/extism-js /usr/local/bin/extism-js
rm -f /tmp/extism-js
@@ -276,6 +270,27 @@ EOF
cp ./packages/plugin-core/manifest.json "$PLUGIN_DIR"
msg_ok "Updated Immich server, web, cli and plugins"
RESTIC_REF="$(grep -oP 'futo-org/restic:sha-\K[0-9a-f]+' "$SRC_DIR"/server/Dockerfile)"
RESTIC_PROXY_REF="$(grep -oP 'futo-org/backups/restic-proxy:\K[^@]+' "$SRC_DIR"/server/Dockerfile)"
if [[ "$(cat ~/.immich_restic 2>/dev/null)" != "${RESTIC_REF} ${RESTIC_PROXY_REF}" || ! -x /usr/local/bin/restic ]]; then
FUTO_DIR=/opt/staging/futo
rm -rf "$FUTO_DIR"
$STD git clone --filter=blob:none --no-checkout https://github.com/futo-org/restic "$FUTO_DIR"/restic
$STD git -C "$FUTO_DIR"/restic checkout "$RESTIC_REF"
$STD git clone --depth 1 --filter=blob:none --sparse -b "$RESTIC_PROXY_REF" https://github.com/futo-org/backups "$FUTO_DIR"/backups
$STD git -C "$FUTO_DIR"/backups sparse-checkout set packages/restic-proxy
GO_VERSION="$(awk '/^go /{print $2}' "$FUTO_DIR"/backups/packages/restic-proxy/go.mod)" setup_go
msg_info "Building restic for FUTO Backups"
cd "$FUTO_DIR"/restic
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic ./cmd/restic
cd "$FUTO_DIR"/backups/packages/restic-proxy
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic-proxy .
cd "$SRC_DIR"
rm -rf "$FUTO_DIR"
echo "${RESTIC_REF} ${RESTIC_PROXY_REF}" >~/.immich_restic
msg_ok "Built restic for FUTO Backups"
fi
cd "$SRC_DIR"/machine-learning
mkdir -p "$ML_DIR"
# chown excluding upload dir contents (may be a mount with restricted permissions)
@@ -412,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
+41 -25
View File
@@ -32,36 +32,52 @@ function update_script() {
if check_for_gh_release "paperless-ai" "clusterzx/paperless-ai"; then
msg_info "Stopping Service"
systemctl stop paperless-ai paperless-rag
systemctl stop paperless-ai
if [[ -f /etc/systemd/system/paperless-rag.service ]]; then
systemctl stop paperless-rag
fi
msg_ok "Stopped Service"
msg_info "Backing up data"
cp -r /opt/paperless-ai/data /opt/paperless-ai-data-backup
msg_ok "Backed up data"
fetch_and_deploy_gh_release "paperless-ai" "clusterzx/paperless-ai" "tarball"
msg_info "Restoring data"
cp -r /opt/paperless-ai-data-backup/* /opt/paperless-ai/data/
rm -rf /opt/paperless-ai-data-backup
msg_ok "Restored data"
NODE_VERSION="22" setup_nodejs
CLEAN_INSTALL=1 CLEAN_INSTALL_KEEP="data" fetch_and_deploy_gh_release "paperless-ai" "clusterzx/paperless-ai" "tarball"
msg_info "Updating Paperless-AI"
cd /opt/paperless-ai
if [[ ! -d /opt/paperless-ai/venv ]]; then
msg_info "Recreating Python venv"
$STD python3 -m venv /opt/paperless-ai/venv
fi
source /opt/paperless-ai/venv/bin/activate
$STD pip install --upgrade pip
$STD pip install --no-cache-dir -r requirements.txt
mkdir -p data/chromadb
$STD npm ci --only=production
export ONNXRUNTIME_NODE_INSTALL=skip PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
$STD npm ci --ignore-scripts --no-audit --no-fund
$STD npm run build
$STD npm prune --omit=dev --ignore-scripts --no-audit --no-fund
msg_ok "Updated Paperless-AI"
if ! grep -q "dist/server/index.js" /etc/systemd/system/paperless-ai.service; then
msg_info "Migrating services to Paperless-AI 4"
if [[ -f /etc/systemd/system/paperless-rag.service ]]; then
systemctl disable -q paperless-rag
rm -f /etc/systemd/system/paperless-rag.service
fi
cat <<EOF >/etc/systemd/system/paperless-ai.service
[Unit]
Description=PaperlessAI Service
After=network.target
[Service]
WorkingDirectory=/opt/paperless-ai
Environment="NODE_ENV=production"
Environment="PAPERLESS_AI_PORT=3000"
Environment="PAPERLESS_AI_DATA_DIR=/opt/paperless-ai/data"
Environment="HF_HUB_DISABLE_TELEMETRY=1"
ExecStart=/usr/bin/node dist/server/index.js
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
msg_ok "Migrated services to Paperless-AI 4"
msg_warn "Settings and history from 3.x are migrated on first start. If this instance was reachable from the internet, read 'Upgrading from 3.x' in the upstream README first."
fi
msg_info "Starting Service"
systemctl start paperless-rag
sleep 3
systemctl start paperless-ai
msg_ok "Started Service"
msg_ok "Updated successfully!"
@@ -73,7 +89,7 @@ start
build_container
description
msg_ok "Completed successfully!\n"
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 URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}:3000${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://pixelfed.org/
APP="Pixelfed"
var_tags="${var_tags:-fediverse;social}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-10}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
# var_arm64="${var_arm64:-no}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/pixelfed ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "pixelfed" "pixelfed/pixelfed"; then
msg_info "Stopping Services"
systemctl stop pixelfed-horizon pixelfed-scheduler.timer
msg_ok "Services stopped"
create_backup /opt/pixelfed/.env \
/opt/pixelfed/storage
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "pixelfed" "pixelfed/pixelfed" "tarball" "latest" "/opt/pixelfed"
restore_backup
msg_info "Updating Pixelfed"
cd /opt/pixelfed
chmod -R 755 /opt/pixelfed
chmod -R 775 /opt/pixelfed/storage /opt/pixelfed/bootstrap/cache
export COMPOSER_ALLOW_SUPERUSER=1
$STD composer install --no-dev --no-ansi --no-interaction --optimize-autoloader
chown -R pixelfed:pixelfed /opt/pixelfed
$STD sudo -u pixelfed php artisan storage:link
$STD sudo -u pixelfed php artisan migrate --force
$STD sudo -u pixelfed php artisan route:cache
$STD sudo -u pixelfed php artisan view:cache
$STD sudo -u pixelfed php artisan config:cache
$STD sudo -u pixelfed php artisan horizon:publish
msg_ok "Updated Pixelfed"
msg_info "Starting Services"
systemctl start pixelfed-horizon pixelfed-scheduler.timer
msg_ok "Services started"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
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 URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}${CL}"
echo -e "${INFO}${YW} Create an admin account with:${CL}"
echo -e "${TAB}cd /opt/pixelfed && sudo -u pixelfed php artisan user:create"
+7 -6
View File
@@ -4,7 +4,7 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://pocketbase.io/ | Github: https://github.com/pocketbase/pocketbase
# Source: https://pocketbase.io/ | Repo: https://ganigeorgiev.com/pocketbase/pocketbase
APP="Pocketbase"
var_tags="${var_tags:-database}"
@@ -29,20 +29,21 @@ function update_script() {
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "pocketbase" "pocketbase/pocketbase"; then
RELEASE=$(curl_with_retry "https://ganigeorgiev.com/api/collections/releases/records?filter=channel.repository.name%3D%22pocketbase%22%20%26%26%20channel.name%3D%22default%22&sort=-semver&skipTotal=1&perPage=1" "-" | jq -r '.items[0].tag' | sed 's/^v//')
if [[ -n "$RELEASE" && "$RELEASE" != "$(cat ~/.pocketbase 2>/dev/null)" ]]; then
msg_info "Stopping Service"
systemctl stop pocketbase
msg_ok "Stopped Service"
msg_info "Updating ${APP}"
/opt/pocketbase/pocketbase update
echo "${CHECK_UPDATE_RELEASE}" >~/.pocketbase
msg_ok "Updated ${APP}"
fetch_and_deploy_from_url "https://ganigeorgiev.com/meta/pocketbase/pocketbase/download/v${RELEASE}/pocketbase_${RELEASE}_linux_$(arch_resolve).zip" "/opt/pocketbase"
echo "${RELEASE}" >~/.pocketbase
msg_info "Starting Service"
systemctl start pocketbase
msg_ok "Started Service"
msg_ok "Updated successfully!"
else
msg_ok "No update required. ${APP} is already at v${RELEASE}"
fi
exit
}
+1
View File
@@ -56,3 +56,4 @@ 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 URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:6969${CL}"
echo -e "${INFO}${YW} Login: whisparr, password in ~/whisparr-eros.creds inside the container${CL}"
+40 -14
View File
@@ -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
+69 -180
View File
@@ -23,7 +23,7 @@ if [[ "$(arch_resolve)" == "amd64" ]] && ! grep -qm1 sse4_2 /proc/cpuinfo; then
exit 236
fi
RELEASE="v3.3.6"
RELEASE="v3.4.1"
if lscpu | grep -q 'GenuineIntel'; then
echo ""
echo ""
@@ -68,13 +68,10 @@ msg_info "Installing dependencies"
$STD apt install --no-install-recommends -y \
git \
redis \
autoconf \
pigz \
build-essential \
python3-dev \
automake \
cmake \
jq \
libtool \
libltdl-dev \
libgdk-pixbuf-2.0-dev \
libbrotli-dev \
@@ -95,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 \
@@ -113,8 +108,7 @@ $STD apt install --no-install-recommends -y \
libhwy-dev \
libwebp-dev \
libaom-dev \
unzip \
ccache
unzip
setup_deb822_repo \
"jellyfin" \
@@ -201,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)"
@@ -415,7 +284,7 @@ cp -a web/build "$APP_DIR"/www
cp LICENSE "$APP_DIR"
cd "$SRC_DIR"
export MISE_TRUSTED_CONFIG_PATHS="$SRC_DIR"/mise.toml
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,npm:@openapitools/openapi-generator-cli
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,restic,npm:@openapitools/openapi-generator-cli
export MISE_LOCKFILE_MODE=merge MISE_LOCKFILE_PLATFORMS="linux-$(arch_resolve x64 arm64)"
mise_ok=0
for i in 1 2 3; do
@@ -433,7 +302,8 @@ if ! command -v extism-js >/dev/null 2>&1; then
# fetch_and_deploy_gh_release cannot deploy (singlefile leaves it compressed,
# prebuild only handles zip/tar). Fetch + gunzip it directly.
EXTISM_ARCH="$(arch_resolve x86_64 aarch64)"
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-${EXTISM_ARCH}-linux-v1.6.0.gz"
EXTISM_VERSION="$(grep -oP '"github:extism/js-pdk"\s*=\s*"\K[^"]+' "$SRC_DIR"/mise.toml)"
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/${EXTISM_VERSION}/extism-js-${EXTISM_ARCH}-linux-${EXTISM_VERSION}.gz"
gunzip -f /tmp/extism-js.gz
install -m 0755 /tmp/extism-js /usr/local/bin/extism-js
rm -f /tmp/extism-js
@@ -445,6 +315,25 @@ cp -r ./packages/plugin-core/dist "$PLUGIN_DIR"/dist
cp ./packages/plugin-core/manifest.json "$PLUGIN_DIR"
msg_ok "Installed Immich Server, Web and Plugin Components"
RESTIC_REF="$(grep -oP 'futo-org/restic:sha-\K[0-9a-f]+' "$SRC_DIR"/server/Dockerfile)"
RESTIC_PROXY_REF="$(grep -oP 'futo-org/backups/restic-proxy:\K[^@]+' "$SRC_DIR"/server/Dockerfile)"
FUTO_DIR=/opt/staging/futo
rm -rf "$FUTO_DIR"
$STD git clone --filter=blob:none --no-checkout https://github.com/futo-org/restic "$FUTO_DIR"/restic
$STD git -C "$FUTO_DIR"/restic checkout "$RESTIC_REF"
$STD git clone --depth 1 --filter=blob:none --sparse -b "$RESTIC_PROXY_REF" https://github.com/futo-org/backups "$FUTO_DIR"/backups
$STD git -C "$FUTO_DIR"/backups sparse-checkout set packages/restic-proxy
GO_VERSION="$(awk '/^go /{print $2}' "$FUTO_DIR"/backups/packages/restic-proxy/go.mod)" setup_go
msg_info "Building restic for FUTO Backups"
cd "$FUTO_DIR"/restic
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic ./cmd/restic
cd "$FUTO_DIR"/backups/packages/restic-proxy
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic-proxy .
cd "$SRC_DIR"
rm -rf "$FUTO_DIR"
echo "${RESTIC_REF} ${RESTIC_PROXY_REF}" >~/.immich_restic
msg_ok "Built restic for FUTO Backups"
cd "$SRC_DIR"/machine-learning
$STD useradd -U -s /usr/sbin/nologin -r -M -d "$INSTALL_DIR" immich
mkdir -p "$ML_DIR"
+9 -73
View File
@@ -13,100 +13,36 @@ setting_up_container
network_check
update_os
msg_info "Installing Dependencies"
$STD apt install -y \
build-essential
msg_ok "Installed Dependencies"
msg_info "Installing Python3"
$STD apt install -y \
python3-pip \
python3-dev \
python3-venv
mkdir -p ~/.config/pip
cat >~/.config/pip/pip.conf <<EOF
[global]
break-system-packages = true
EOF
msg_ok "Installed Python3"
NODE_VERSION="22" setup_nodejs
fetch_and_deploy_gh_release "paperless-ai" "clusterzx/paperless-ai" "tarball"
msg_info "Setup Paperless-AI"
cd /opt/paperless-ai
$STD python3 -m venv /opt/paperless-ai/venv
source /opt/paperless-ai/venv/bin/activate
# TMPDIR to use container disk instead of tmpfs for large pip downloads (https://github.com/community-scripts/ProxmoxVE/issues/10338)
export TMPDIR=/opt/paperless-ai/tmp
mkdir -p "$TMPDIR"
$STD pip install --upgrade pip
$STD pip install --no-cache-dir -r requirements.txt
rm -rf "$TMPDIR"
mkdir -p data/chromadb
$STD npm ci --only=production
export ONNXRUNTIME_NODE_INSTALL=skip PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
$STD npm ci --ignore-scripts --no-audit --no-fund
$STD npm run build
$STD npm prune --omit=dev --ignore-scripts --no-audit --no-fund
mkdir -p /opt/paperless-ai/data
cat <<EOF >/opt/paperless-ai/data/.env
PAPERLESS_API_URL=
PAPERLESS_API_TOKEN=
PAPERLESS_USERNAME=
AI_PROVIDER=openai
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
OLLAMA_API_URL=
OLLAMA_MODEL=
SCAN_INTERVAL=*/10 * * * *
SYSTEM_PROMPT=""
PROCESS_PREDEFINED_DOCUMENTS=no
TAGS=
ADD_AI_PROCESSED_TAG=no
AI_PROCESSED_TAG_NAME=ki-gen
USE_PROMPT_TAGS=no
PROMPT_TAGS=
USE_EXISTING_DATA=no
API_KEY=
CUSTOM_API_KEY=
CUSTOM_BASE_URL=
CUSTOM_MODEL=
RAG_SERVICE_URL=http://localhost:8000
RAG_SERVICE_ENABLED=true
EOF
msg_ok "Setup Paperless-AI"
msg_info "Creating Service"
cat <<EOF >/etc/systemd/system/paperless-ai.service
[Unit]
Description=PaperlessAI Service
After=network.target paperless-rag.service
Requires=paperless-rag.service
[Service]
WorkingDirectory=/opt/paperless-ai
Environment="NODE_ENV=production"
EnvironmentFile=/opt/paperless-ai/data/.env
ExecStart=/usr/bin/node server.js
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF >/etc/systemd/system/paperless-rag.service
[Unit]
Description=PaperlessAI-RAG Service
After=network.target
[Service]
WorkingDirectory=/opt/paperless-ai
EnvironmentFile=/opt/paperless-ai/data/.env
ExecStart=/opt/paperless-ai/venv/bin/python3 main.py --host 0.0.0.0 --port 8000 --initialize
Environment="NODE_ENV=production"
Environment="PAPERLESS_AI_PORT=3000"
Environment="PAPERLESS_AI_DATA_DIR=/opt/paperless-ai/data"
Environment="HF_HUB_DISABLE_TELEMETRY=1"
ExecStart=/usr/bin/node dist/server/index.js
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now paperless-rag
sleep 5
systemctl enable -q --now paperless-ai
msg_ok "Created Service"
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://pixelfed.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
msg_info "Installing Dependencies"
$STD apt install -y \
nginx \
redis-server \
ffmpeg \
jpegoptim \
optipng \
pngquant \
gifsicle \
libvips42
msg_ok "Installed Dependencies"
msg_info "Creating Pixelfed User"
useradd -rU -s /bin/bash pixelfed
usermod -aG redis pixelfed
msg_ok "Created Pixelfed User"
PG_VERSION="17" setup_postgresql
PG_DB_NAME="pixelfed" PG_DB_USER="pixelfed" setup_postgresql_db
PHP_VERSION="8.4" PHP_FPM="YES" PHP_MODULE="bcmath,ctype,curl,exif,gd,imagick,intl,mbstring,pgsql,redis,xml,zip" PHP_UPLOAD_MAX_FILESIZE="500M" PHP_POST_MAX_SIZE="500M" PHP_MAX_EXECUTION_TIME="600" setup_php
setup_composer
msg_info "Configuring Redis"
REDIS_PASS=$(random_password)
sed -i "s/^# requirepass foobared/requirepass $REDIS_PASS/" /etc/redis/redis.conf
sed -i "s/^requirepass .*/requirepass $REDIS_PASS/" /etc/redis/redis.conf
systemctl restart redis-server
msg_ok "Configured Redis"
msg_info "Configuring PHP-FPM Pool"
cp /etc/php/8.4/fpm/pool.d/www.conf /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's/\[www\]/[pixelfed]/' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's/^user = www-data/user = pixelfed/' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's/^group = www-data/group = pixelfed/' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's|^listen = .*|listen = /run/php/php8.4-fpm-pixelfed.sock|' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's/^listen.owner = .*/listen.owner = www-data/' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i 's/^listen.group = .*/listen.group = www-data/' /etc/php/8.4/fpm/pool.d/pixelfed.conf
sed -i -E 's/^\s*;?\s*ffi\.enable\s*=.*/ffi.enable=true/' /etc/php/8.4/fpm/php.ini
systemctl restart php8.4-fpm
msg_ok "Configured PHP-FPM Pool"
fetch_and_deploy_gh_release "pixelfed" "pixelfed/pixelfed" "tarball" "latest" "/opt/pixelfed"
msg_info "Installing Pixelfed (Patience)"
cd /opt/pixelfed
cat <<EOF >/opt/pixelfed/.env
APP_NAME="Pixelfed"
APP_ENV="production"
APP_KEY=
APP_DEBUG="false"
APP_URL=http://${LOCAL_IP}
APP_DOMAIN=${LOCAL_IP}
ADMIN_DOMAIN=${LOCAL_IP}
SESSION_DOMAIN=null
TRUST_PROXIES="*"
FORCE_HTTPS_URLS="false"
OPEN_REGISTRATION="false"
ENFORCE_EMAIL_VERIFICATION="false"
PF_MAX_USERS="1000"
OAUTH_ENABLED="true"
ENABLE_CONFIG_CACHE="true"
INSTANCE_DISCOVER_PUBLIC="true"
PF_OPTIMIZE_IMAGES="true"
IMAGE_QUALITY="80"
MAX_PHOTO_SIZE="15000"
MAX_CAPTION_LENGTH="500"
MAX_ALBUM_LENGTH="4"
DB_CONNECTION="pgsql"
DB_HOST="127.0.0.1"
DB_PORT="5432"
DB_DATABASE="${PG_DB_NAME}"
DB_USERNAME="${PG_DB_USER}"
DB_PASSWORD="${PG_DB_PASS}"
REDIS_CLIENT="predis"
REDIS_SCHEME="tcp"
REDIS_HOST="127.0.0.1"
REDIS_PASSWORD="${REDIS_PASS}"
REDIS_PORT="6379"
SESSION_DRIVER="database"
CACHE_STORE="redis"
QUEUE_DRIVER="redis"
BROADCAST_DRIVER="log"
LOG_CHANNEL="stack"
HORIZON_PREFIX="horizon-"
ACTIVITY_PUB="true"
AP_REMOTE_FOLLOW="true"
AP_INBOX="true"
AP_OUTBOX="true"
AP_SHAREDINBOX="true"
EXP_EMC="true"
MAIL_DRIVER="log"
MAIL_HOST="smtp.mailtrap.io"
MAIL_PORT="2525"
MAIL_USERNAME="null"
MAIL_PASSWORD="null"
MAIL_ENCRYPTION="null"
MAIL_FROM_ADDRESS="pixelfed@example.com"
MAIL_FROM_NAME="Pixelfed"
PF_ENABLE_CLOUD="false"
FILESYSTEM_CLOUD="s3"
SESSION_SECURE_COOKIE="false"
HTTPS="false"
EOF
chmod -R 755 /opt/pixelfed
chmod -R 775 /opt/pixelfed/storage /opt/pixelfed/bootstrap/cache
export COMPOSER_ALLOW_SUPERUSER=1
$STD composer install --no-dev --no-ansi --no-interaction --optimize-autoloader
$STD php artisan key:generate --force
$STD php artisan storage:link
$STD php artisan migrate --force
$STD php artisan import:cities
$STD php artisan passport:keys
$STD php artisan route:cache
$STD php artisan view:cache
$STD php artisan config:cache
$STD php artisan instance:actor
$STD php artisan horizon:install
chown -R pixelfed:pixelfed /opt/pixelfed
msg_ok "Installed Pixelfed"
msg_info "Configuring Nginx"
cat <<'EOF' >/etc/nginx/sites-available/pixelfed
server {
listen 80;
server_name _;
root /opt/pixelfed/public;
index index.php;
charset utf-8;
client_max_body_size 100M;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.4-fpm-pixelfed.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
EOF
nginx_enable_site pixelfed
msg_ok "Configured Nginx"
msg_info "Creating Services"
cat <<'EOF' >/etc/systemd/system/pixelfed-horizon.service
[Unit]
Description=Pixelfed Horizon Queue Worker
After=network.target redis-server.service postgresql.service
Requires=redis-server.service
[Service]
Type=simple
User=pixelfed
WorkingDirectory=/opt/pixelfed
ExecStart=/usr/bin/php /opt/pixelfed/artisan horizon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
cat <<'EOF' >/etc/systemd/system/pixelfed-scheduler.service
[Unit]
Description=Pixelfed Task Scheduler
After=network.target
[Service]
Type=oneshot
User=pixelfed
WorkingDirectory=/opt/pixelfed
ExecStart=/usr/bin/php /opt/pixelfed/artisan schedule:run
EOF
cat <<'EOF' >/etc/systemd/system/pixelfed-scheduler.timer
[Unit]
Description=Run Pixelfed Scheduler every minute
[Timer]
OnCalendar=*-*-* *:*:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl enable -q --now pixelfed-horizon pixelfed-scheduler.timer
msg_ok "Created Services"
motd_ssh
customize
cleanup_lxc
+4 -2
View File
@@ -3,7 +3,7 @@
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://pocketbase.io/ | Github: https://github.com/pocketbase/pocketbase
# Source: https://pocketbase.io/ | Repo: https://ganigeorgiev.com/pocketbase/pocketbase
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
@@ -13,7 +13,9 @@ setting_up_container
network_check
update_os
fetch_and_deploy_gh_release "pocketbase" "pocketbase/pocketbase" "prebuild" "latest" "/opt/pocketbase" "pocketbase*linux_$(arch_resolve).zip"
RELEASE=$(curl_with_retry "https://ganigeorgiev.com/api/collections/releases/records?filter=channel.repository.name%3D%22pocketbase%22%20%26%26%20channel.name%3D%22default%22&sort=-semver&skipTotal=1&perPage=1" "-" | jq -r '.items[0].tag' | sed 's/^v//')
fetch_and_deploy_from_url "https://ganigeorgiev.com/meta/pocketbase/pocketbase/download/v${RELEASE}/pocketbase_${RELEASE}_linux_$(arch_resolve).zip" "/opt/pocketbase"
echo "${RELEASE}" >~/.pocketbase
msg_info "Configuring Pocketbase"
mkdir -p /opt/pocketbase/{pb_public,pb_migrations,pb_hooks}
+14
View File
@@ -27,6 +27,7 @@ chmod 775 /var/lib/whisparr-eros/ /opt/Whisparr-Eros/
msg_ok "Configured Whisparr-Eros"
msg_info "Creating Service"
WHISPARR_PASS=$(random_password 20)
cat <<EOF >/etc/systemd/system/whisparr-eros.service
[Unit]
Description=Whisparr-Eros Daemon
@@ -35,6 +36,7 @@ After=syslog.target network.target
[Service]
UMask=0002
Type=simple
Environment=WHISPARR__AUTH__INITIALPASSWORD=${WHISPARR_PASS}
ExecStart=/opt/Whisparr-Eros/Whisparr -nobrowser -data=/var/lib/whisparr-eros/
TimeoutStopSec=20
KillMode=process
@@ -44,6 +46,18 @@ Restart=on-failure
WantedBy=multi-user.target
EOF
systemctl enable -q --now whisparr-eros
for _ in {1..60}; do
grep -qs "created 'whisparr' with the configured password" /var/lib/whisparr-eros/logs/whisparr.txt && break
sleep 2
done
sed -i '/WHISPARR__AUTH__INITIALPASSWORD/d' /etc/systemd/system/whisparr-eros.service
systemctl daemon-reload
cat <<EOF >~/whisparr-eros.creds
Whisparr-Eros Credentials
Username: whisparr
Password: ${WHISPARR_PASS}
EOF
chmod 600 ~/whisparr-eros.creds
msg_ok "Created Service"
motd_ssh
+42 -18
View File
@@ -37,42 +37,37 @@ function truenas_iso_lookup() {
declare -A latest_stables
local pre_releases=()
local excluded_codenames="(Angelfish|Bluefin|Cobia|Dragonfish|ElectricEel|Fangtooth)"
local all_paths=$(
curl -fsSL "${_CS_CURL_RETRY[@]}" --max-time 90 "$BASE_URL" |
grep -oE 'href="[^"]+\.iso"' |
sed 's/href="//; s/"$//' |
grep -vE '(MASTER|ALPHA)' |
grep -vE '(MASTER|ALPHA|BETA)' |
grep -E "$year_pattern"
)
while read -r path; do
[[ -n "$path" ]] || continue
local filename=$(basename "$path")
local version=$(echo "$filename" | sed -E 's/.*TrueNAS-SCALE-([0-9]{2}\.[0-9]{2}(\.[0-9]+)*(-RC[0-9]|-BETA[0-9])?)\.iso.*/\1/')
if [[ "$version" =~ (RC|BETA) ]]; then
pre_releases+=("$path")
[[ "$filename" =~ ^TrueNAS-(SCALE-)?([0-9]+\.[0-9]+(\.[0-9]+)*)\.iso$ ]] || continue
local version="${BASH_REMATCH[2]}"
local major_version=$(echo "$version" | cut -d'.' -f1,2)
local current_stored_path=${latest_stables["$major_version"]:-}
if [[ -z "$current_stored_path" ]]; then
latest_stables["$major_version"]="$path"
else
local major_version=$(echo "$version" | cut -d'.' -f1,2)
local current_stored_path=${latest_stables["$major_version"]:-}
if [[ -z "$current_stored_path" ]]; then
local stored_version=$(basename "$current_stored_path" | sed -E 's/.*TrueNAS-(SCALE-)?([0-9]+\.[0-9]+(\.[0-9]+)*)\.iso.*/\2/')
if printf '%s\n' "$version" "$stored_version" | sort -V | tail -n 1 | grep -qxF "$version"; then
latest_stables["$major_version"]="$path"
else
local stored_version=$(basename "$current_stored_path" | sed -E 's/.*TrueNAS-SCALE-([0-9]{2}\.[0-9]{2}(\.[0-9]+)*)\.iso.*/\1/')
if printf '%s\n' "$version" "$stored_version" | sort -V | tail -n 1 | grep -q "$version"; then
latest_stables["$major_version"]="$path"
fi
fi
fi
done <<<"$all_paths"
for key in "${!latest_stables[@]}"; do
for key in $(printf '%s\n' "${!latest_stables[@]}" | sort -V); do
echo "${latest_stables[$key]#/}"
done
for pre in "${pre_releases[@]}"; do
echo "${pre#/}"
done | sort -V
}
TEMP_DIR=$(mktemp -d)
@@ -115,6 +110,35 @@ function advanced_settings() {
vm_prompt_mtu
vm_prompt_verbose "no"
ISOARRAY=()
mapfile -t ALL_ISOS < <(truenas_iso_lookup)
ISO_COUNT=${#ALL_ISOS[@]}
if [ $ISO_COUNT -eq 0 ]; then
msg_error "Could not find any TrueNAS ISOs."
exit 115
fi
for i in "${!ALL_ISOS[@]}"; do
ISOPATH="${ALL_ISOS[$i]}"
FILENAME=$(basename "$ISOPATH")
# The newest stable release is preselected
if [[ "$i" -eq "$((ISO_COUNT - 1))" ]]; then
ISOARRAY+=("$ISOPATH" "$FILENAME" "ON")
else
ISOARRAY+=("$ISOPATH" "$FILENAME" "OFF")
fi
done
if vm_dialog radiolist "SELECT ISO TO INSTALL" --notags --cancel-button Exit-Script \
"\nSelect version (Latest stable preselected):" 20 58 12 "${ISOARRAY[@]}"; then
SELECTED_ISO="$VM_DIALOG_RESULT"
echo -e "${ISO}${BOLD}${DGN}ISO Chosen: ${BGN}$(basename "$SELECTED_ISO")${CL}"
else
exit_script
fi
if vm_dialog yesno "IMPORT ONBOARD DISKS" --defaultno "Would you like to import onboard disks?" 10 58; then
IMPORT_DISKS="yes"
else
@@ -140,7 +164,7 @@ post_to_api_vm
vm_select_storage "$HN"
if [ -z "${SELECTED_ISO:-}" ]; then
SELECTED_ISO=$(truenas_iso_lookup | grep -vE 'RC|BETA' | sort -V | tail -n 1) || true
SELECTED_ISO=$(truenas_iso_lookup | tail -n 1) || true
if [ -z "$SELECTED_ISO" ]; then
msg_error "Could not find a stable ISO for fallback."