From 96aee911db214ce7a986ce7979acbc5988364fd5 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:05:51 -0400 Subject: [PATCH 01/42] Add Apache-2.0 LICENSE --- LICENSE | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 7f8eaeab25d4705b9f8a4543c7bf56de11c0019d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:06:41 -0400 Subject: [PATCH 02/42] Make manager units packaging-friendly - container-creator-init: invoke node_modules/.bin/sequelize directly instead of 'npm run db:migrate' so npm is not a runtime dependency. - container-creator: create /var/log/opensource-server on demand via systemd LogDirectory instead of shipping an empty directory in the package. --- create-a-container/systemd/container-creator-init.service | 3 ++- create-a-container/systemd/container-creator.service | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/create-a-container/systemd/container-creator-init.service b/create-a-container/systemd/container-creator-init.service index ca09a474..bac393ef 100644 --- a/create-a-container/systemd/container-creator-init.service +++ b/create-a-container/systemd/container-creator-init.service @@ -21,7 +21,8 @@ ExecStart=/bin/bash -e -c '\ echo "POSTGRES_DATABASE=$${POSTGRES_DATABASE}" >> /etc/default/container-creator; \ echo "POSTGRES_USER=$${POSTGRES_USER}" >> /etc/default/container-creator; \ echo "POSTGRES_PASSWORD=$${POSTGRES_PASSWORD}" >> /etc/default/container-creator; \ - npm run db:migrate;' + node_modules/.bin/sequelize db:migrate; \ + node_modules/.bin/sequelize db:seed:all;' [Install] WantedBy=multi-user.target diff --git a/create-a-container/systemd/container-creator.service b/create-a-container/systemd/container-creator.service index 3325e919..26db1f30 100644 --- a/create-a-container/systemd/container-creator.service +++ b/create-a-container/systemd/container-creator.service @@ -12,6 +12,10 @@ Restart=on-failure Environment=NODE_ENV=production Environment=ACCESS_LOG=/var/log/opensource-server/access.log EnvironmentFile=/etc/default/container-creator +# Create and manage /var/log/opensource-server on demand instead of shipping +# an empty directory in the package. +LogDirectory=opensource-server +LogDirectoryMode=0755 [Install] WantedBy=multi-user.target From 7c1a1f66435f8748609f89b7be7a6b836371f988 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:13:10 -0400 Subject: [PATCH 03/42] Add create-a-container packaging (opensource-server) Self-contained Makefile with the standard deps/build/install/dev contract (default goal: build), DESTDIR/PREFIX/VERSION parameters, and deb/rpm/apk targets that stage the component and package it with nfpm. - dev runs 'npm run dev' (server + client watch); dev-client runs only the client bundle watcher for the compose client service. - install stages the app under PREFIX/create-a-container (cp -a to keep node_modules/.bin symlinks), the three systemd units, and the logrotate drop-in. - nfpm.yaml builds the opensource-server package: depends on opensource-agent + opensource-docs (the manager nginx config references their files) plus nodejs/sudo/libc; postinstall enables the units, preremove disables them on real removal. Logrotate ships as a config file. Relocated from images/manager/. --- create-a-container/.gitignore | 6 ++ create-a-container/Makefile | 96 +++++++++++++++++++ create-a-container/nfpm.yaml | 49 ++++++++++ .../nfpm}/opensource-server.logrotate | 0 create-a-container/nfpm/postinstall.sh | 24 +++++ create-a-container/nfpm/preremove.sh | 26 +++++ 6 files changed, 201 insertions(+) create mode 100644 create-a-container/Makefile create mode 100644 create-a-container/nfpm.yaml rename {images/manager => create-a-container/nfpm}/opensource-server.logrotate (100%) create mode 100755 create-a-container/nfpm/postinstall.sh create mode 100755 create-a-container/nfpm/preremove.sh diff --git a/create-a-container/.gitignore b/create-a-container/.gitignore index d83eef34..8824345a 100644 --- a/create-a-container/.gitignore +++ b/create-a-container/.gitignore @@ -1,3 +1,9 @@ .env node_modules data/ + +# packaging build artifacts +/build-root/ +/*.deb +/*.rpm +/*.apk diff --git a/create-a-container/Makefile b/create-a-container/Makefile new file mode 100644 index 00000000..2b59d913 --- /dev/null +++ b/create-a-container/Makefile @@ -0,0 +1,96 @@ +# create-a-container — Makefile +# +# Standard component contract shared across the repository: +# deps install build/runtime dependencies +# build (default) compile the component; depends on deps +# install stage built files into DESTDIR; depends on build +# dev run the development watch loop; depends on deps +# deb/rpm/apk package the staged tree with nfpm; depend on install +# +# PREFIX is the vendor install prefix every runtime reference in the project +# expects (systemd units, the manager-rendered nginx config). DESTDIR is the +# staging root. VERSION is derived from git but can be overridden. + +.DEFAULT_GOAL := build + +PREFIX ?= /opt/opensource-server +DESTDIR ?= / +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 +VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') + +PKG_NAME := opensource-server +DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container +UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system + +INSTALL := install +INSTALL_DATA := $(INSTALL) -m 0644 + +# Server runtime files and directories. Client sources, dev tooling and repo +# metadata are intentionally excluded; only the built client/dist ships. +APP_FILES := server.js job-runner.js package.json package-lock.json openapi.v1.yaml +APP_DIRS := bin config data middlewares migrations models node_modules \ + public routers seeders utils views + +.PHONY: deps build dev dev-client install deb rpm apk package clean + +deps: + npm ci --omit=dev + npm --prefix client ci + +build: deps + npm --prefix client run build + # Prune npm packaging cruft and foreign-platform / musl prebuilds; only + # linux-x64 glibc prebuilds are relevant for an amd64 package. + find node_modules \( -name '.eslintrc*' -o -name '.npmignore' -o -name '.eslintignore' \) -delete + for d in $$(find node_modules -type d -name prebuilds); do \ + find "$$d" -mindepth 1 -maxdepth 1 ! -name 'linux-x64' -exec rm -rf {} +; \ + rm -f "$$d"/linux-x64/*.musl.node; \ + done + +# Full local development: server (nodemon) + client (vite watch) together. +dev: deps + npm run dev + +# Client production-bundle watcher only. Used by the compose `client` service, +# where the server runs inside the Proxmox container, not here. +dev-client: deps + npm run client:build + npm run client:build:watch + +install: build + $(INSTALL) -d $(DESTBIN) + $(INSTALL_DATA) $(APP_FILES) $(DESTBIN)/ + # cp -a preserves the symlinks in node_modules/.bin (e.g. sequelize, + # invoked by the systemd units) and file modes. + cp -a $(APP_DIRS) $(DESTBIN)/ + $(INSTALL) -d $(DESTBIN)/client + cp -a client/dist $(DESTBIN)/client/ + # systemd units (enabled by the package postinstall script) + $(INSTALL) -d $(UNIT_DIR) + $(INSTALL_DATA) systemd/container-creator.service $(UNIT_DIR)/ + $(INSTALL_DATA) systemd/container-creator-init.service $(UNIT_DIR)/ + $(INSTALL_DATA) systemd/job-runner.service $(UNIT_DIR)/ + # logrotate for the morgan access log + $(INSTALL) -d $(DESTDIR)/etc/logrotate.d + $(INSTALL_DATA) nfpm/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server + +# Package the staged tree with nfpm. Staged into a clean root so the package +# contains only this component's files. +PACKAGER ?= deb +package: + rm -rf build-root + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + # The logrotate drop-in is added by nfpm.yaml as a config file from source, + # so drop the staged copy to avoid a content collision with the tree. + rm -f build-root/etc/logrotate.d/opensource-server + VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) + +deb: + $(MAKE) package PACKAGER=deb +rpm: + $(MAKE) package PACKAGER=rpm +apk: + $(MAKE) package PACKAGER=apk + +clean: + rm -rf build-root node_modules client/node_modules client/dist diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml new file mode 100644 index 00000000..cb5e0723 --- /dev/null +++ b/create-a-container/nfpm.yaml @@ -0,0 +1,49 @@ +# nfpm configuration for the opensource-server package (create-a-container). +# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / +# `make apk`, which stage the component into ./build-root first and pass +# VERSION through the environment. +# +# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json +name: opensource-server +arch: amd64 +platform: linux +version: ${VERSION} +section: admin +priority: optional +maintainer: "Medical Informatics Engineering " +description: | + MIE opensource-server cluster manager (create-a-container). + Web UI and REST API for self-service LXC container hosting on Proxmox VE: + container lifecycle, automated DNS and reverse-proxy configuration for + agents, LDAP authentication and ACME TLS orchestration. +homepage: "https://github.com/mieweb/opensource-server" +license: "Apache-2.0" + +# The manager's rendered nginx config references the agent's error pages and +# the docs site, so both packages are required at runtime. +depends: + - opensource-agent + - opensource-docs + - nodejs + - sudo + # Linked by the prebuilt native node addons (argon2, sqlite3). + - libc6 + - libgcc-s1 + - libstdc++6 +suggests: + - postgresql + +contents: + # Everything staged by `make install DESTDIR=./build-root`, except the + # logrotate drop-in which `make package` removes from the staged tree so it + # can be added here as a config file (preserving admin edits on upgrade). + - src: ./build-root + dst: / + type: tree + - src: ./nfpm/opensource-server.logrotate + dst: /etc/logrotate.d/opensource-server + type: config + +scripts: + postinstall: ./nfpm/postinstall.sh + preremove: ./nfpm/preremove.sh diff --git a/images/manager/opensource-server.logrotate b/create-a-container/nfpm/opensource-server.logrotate similarity index 100% rename from images/manager/opensource-server.logrotate rename to create-a-container/nfpm/opensource-server.logrotate diff --git a/create-a-container/nfpm/postinstall.sh b/create-a-container/nfpm/postinstall.sh new file mode 100755 index 00000000..3b4b9325 --- /dev/null +++ b/create-a-container/nfpm/postinstall.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# postinstall for opensource-server: enable the manager systemd units. +set -e + +UNITS="container-creator-init.service container-creator.service job-runner.service" + +if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true +fi + +# Enable on first install and upgrade so the services come up at boot. Use +# 'enable' (not '--now') so package installation never starts services in a +# build/chroot context; they start on the next boot or via the image. +for unit in $UNITS; do + systemctl enable "$unit" >/dev/null 2>&1 || true +done + +# If systemd is running, (re)start the long-running services so an upgrade +# picks up new code. The init service is oneshot and gated on its conditions. +if [ -d /run/systemd/system ]; then + systemctl restart container-creator.service job-runner.service || true +fi + +exit 0 diff --git a/create-a-container/nfpm/preremove.sh b/create-a-container/nfpm/preremove.sh new file mode 100755 index 00000000..13cbf75b --- /dev/null +++ b/create-a-container/nfpm/preremove.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# preremove for opensource-server: stop and disable the manager systemd units. +set -e + +UNITS="container-creator.service job-runner.service container-creator-init.service" + +# $1 is "remove"/"purge" (deb) or the remaining-version count (rpm: 0 on final +# removal). Only act on a real removal, not an upgrade. +case "${1:-}" in + upgrade|1) + # rpm upgrade ("1") / deb upgrade: leave units in place. + exit 0 + ;; +esac + +if [ -d /run/systemd/system ]; then + for unit in $UNITS; do + systemctl stop "$unit" >/dev/null 2>&1 || true + done +fi + +for unit in $UNITS; do + systemctl disable "$unit" >/dev/null 2>&1 || true +done + +exit 0 From c5aa71da4795b807ae37f915b841ff1dced61589 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:14:29 -0400 Subject: [PATCH 04/42] Add mie-opensource-landing packaging (opensource-docs) Makefile with deps (uv sync) / build (zensical build, default) / install (stage the prebuilt site under PREFIX) / dev (zensical serve) and deb/rpm/apk targets. nfpm.yaml builds the content-only opensource-docs package (arch all), suggesting nginx. --- mie-opensource-landing/.gitignore | 6 ++++ mie-opensource-landing/Makefile | 47 +++++++++++++++++++++++++++++++ mie-opensource-landing/nfpm.yaml | 27 ++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 mie-opensource-landing/Makefile create mode 100644 mie-opensource-landing/nfpm.yaml diff --git a/mie-opensource-landing/.gitignore b/mie-opensource-landing/.gitignore index 102b6feb..fa091fa3 100644 --- a/mie-opensource-landing/.gitignore +++ b/mie-opensource-landing/.gitignore @@ -14,3 +14,9 @@ __pycache__/ # Misc .DS_Store tmp/ + +# packaging build artifacts +/build-root/ +/*.deb +/*.rpm +/*.apk diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile new file mode 100644 index 00000000..a6fda699 --- /dev/null +++ b/mie-opensource-landing/Makefile @@ -0,0 +1,47 @@ +# mie-opensource-landing — Makefile +# +# Standard component contract (see create-a-container/Makefile for details): +# deps / build (default) / install / dev / deb / rpm / apk + +.DEFAULT_GOAL := build + +PREFIX ?= /opt/opensource-server +DESTDIR ?= / +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 +VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') + +PKG_NAME := opensource-docs +SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site + +.PHONY: deps build dev install deb rpm apk package clean + +deps: + uv sync --frozen + +build: deps + uv run --frozen zensical build + +# Live-reloading docs server. Honors VIRTUAL_ENV when set (compose sets +# VIRTUAL_ENV=/opt/zensical); otherwise uses the project .venv from `deps`. +dev: deps + uv run zensical serve + +install: build + install -d $(SITE_DEST) + cp -a site/. $(SITE_DEST)/ + +PACKAGER ?= deb +package: + rm -rf build-root + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) + +deb: + $(MAKE) package PACKAGER=deb +rpm: + $(MAKE) package PACKAGER=rpm +apk: + $(MAKE) package PACKAGER=apk + +clean: + rm -rf build-root site .venv diff --git a/mie-opensource-landing/nfpm.yaml b/mie-opensource-landing/nfpm.yaml new file mode 100644 index 00000000..fc20ac67 --- /dev/null +++ b/mie-opensource-landing/nfpm.yaml @@ -0,0 +1,27 @@ +# nfpm configuration for the opensource-docs package (mie-opensource-landing). +# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / +# `make apk`, which stage the prebuilt site into ./build-root first. +# +# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json +name: opensource-docs +arch: all +platform: linux +version: ${VERSION} +section: doc +priority: optional +maintainer: "Medical Informatics Engineering " +description: | + MIE opensource-server documentation site (static content). + Ships the prebuilt site under /opt/opensource-server/mie-opensource-landing. + Serving it is a deployment concern: the docs image adds an nginx vhost and + the cluster manager serves it via its rendered nginx configuration. +homepage: "https://github.com/mieweb/opensource-server" +license: "Apache-2.0" + +suggests: + - nginx + +contents: + - src: ./build-root + dst: / + type: tree From a5d66bd6b8471107ba7ba35f1ecb46bcd86bdddf Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:16:08 -0400 Subject: [PATCH 05/42] Add pull-config packaging (opensource-agent) Makefile with no-op deps/build/dev (plain bash) and an install target that stages the pull-config engine, instances, cron schedule, the shared error pages and the nginx forward-auth cache dir. nfpm.yaml builds the opensource-agent package, depending on nginx (with stream and ModSecurity modules), dnsmasq, cron and curl; instances and cron ship as config files (instances mode 0755). --- pull-config/.gitignore | 5 ++++ pull-config/Makefile | 62 ++++++++++++++++++++++++++++++++++++++++ pull-config/nfpm.yaml | 65 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 pull-config/.gitignore create mode 100644 pull-config/Makefile create mode 100644 pull-config/nfpm.yaml diff --git a/pull-config/.gitignore b/pull-config/.gitignore new file mode 100644 index 00000000..af647801 --- /dev/null +++ b/pull-config/.gitignore @@ -0,0 +1,5 @@ +# packaging build artifacts +/build-root/ +/*.deb +/*.rpm +/*.apk diff --git a/pull-config/Makefile b/pull-config/Makefile new file mode 100644 index 00000000..33ce2b1f --- /dev/null +++ b/pull-config/Makefile @@ -0,0 +1,62 @@ +# pull-config — Makefile (opensource-agent package) +# +# Standard component contract (see create-a-container/Makefile for details). +# pull-config is plain bash, so deps/build/dev are no-ops; install stages the +# engine, instances, cron schedule and the shared error pages. + +.DEFAULT_GOAL := build + +PREFIX ?= /opt/opensource-server +DESTDIR ?= / +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 +VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') + +PKG_NAME := opensource-agent + +INSTALL := install +INSTALL_DATA := $(INSTALL) -m 0644 +INSTALL_PROG := $(INSTALL) -m 0755 + +# The static error pages live at the repository root and are referenced by the +# manager-rendered nginx config; they ship with the agent package. +ERROR_PAGES := ../error-pages + +.PHONY: deps build dev install deb rpm apk package clean + +# Plain bash; nothing to install, compile, or watch. +deps: +build: deps +dev: deps + +install: build + # pull-config engine — instances exec this absolute path. + $(INSTALL) -D -m 0755 bin/pull-config $(DESTDIR)$(PREFIX)/pull-config/bin/pull-config + # pull-config instances (admin-customizable) + cron schedule. + $(INSTALL) -d $(DESTDIR)/etc/pull-config.d + $(INSTALL_PROG) etc/pull-config.d/* $(DESTDIR)/etc/pull-config.d/ + $(INSTALL) -D -m 0644 etc/cron.d/pull-config $(DESTDIR)/etc/cron.d/pull-config + # Static error pages referenced by the manager-rendered nginx config. + $(INSTALL) -d $(DESTDIR)$(PREFIX)/error-pages + $(INSTALL_DATA) $(ERROR_PAGES)/* $(DESTDIR)$(PREFIX)/error-pages/ + # Forward-auth cache directory used by the manager-rendered nginx config. + $(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache + +PACKAGER ?= deb +package: + rm -rf build-root + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + # Conffiles are added by nfpm.yaml as config files from source, so drop the + # staged copies to avoid content collisions with the tree. + rm -f build-root/etc/cron.d/pull-config + rm -rf build-root/etc/pull-config.d + VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) + +deb: + $(MAKE) package PACKAGER=deb +rpm: + $(MAKE) package PACKAGER=rpm +apk: + $(MAKE) package PACKAGER=apk + +clean: + rm -rf build-root diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml new file mode 100644 index 00000000..67b29f99 --- /dev/null +++ b/pull-config/nfpm.yaml @@ -0,0 +1,65 @@ +# nfpm configuration for the opensource-agent package (pull-config). +# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / +# `make apk`, which stage the component into ./build-root first. +# +# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json +name: opensource-agent +arch: all +platform: linux +version: ${VERSION} +section: admin +priority: optional +maintainer: "Medical Informatics Engineering " +description: | + MIE opensource-server edge agent (pull-config). + Cron-driven distribution of nginx and dnsmasq configuration to edge nodes: + each instance fetches its rendered config from the cluster manager with + ETag caching, validates it and reloads the service on change. Also ships + the static error pages referenced by the manager-rendered nginx config. +homepage: "https://github.com/mieweb/opensource-server" +license: "Apache-2.0" + +depends: + - nginx + - libnginx-mod-stream + - libnginx-mod-http-modsecurity + - modsecurity-crs + - ssl-cert + - dnsmasq + - curl + - cron + +contents: + # Everything staged by `make install`, minus the conffiles which `make + # package` removes from the staged tree so they can be added here as config + # files (preserving admin customizations on upgrade). + - src: ./build-root + dst: / + type: tree + - src: ./etc/cron.d/pull-config + dst: /etc/cron.d/pull-config + type: config + - src: ./etc/pull-config.d/nginx + dst: /etc/pull-config.d/nginx + type: config + file_info: { mode: 0755 } + - src: ./etc/pull-config.d/dnsmasq-conf + dst: /etc/pull-config.d/dnsmasq-conf + type: config + file_info: { mode: 0755 } + - src: ./etc/pull-config.d/dnsmasq-dhcp-hosts + dst: /etc/pull-config.d/dnsmasq-dhcp-hosts + type: config + file_info: { mode: 0755 } + - src: ./etc/pull-config.d/dnsmasq-dhcp-opts + dst: /etc/pull-config.d/dnsmasq-dhcp-opts + type: config + file_info: { mode: 0755 } + - src: ./etc/pull-config.d/dnsmasq-hosts + dst: /etc/pull-config.d/dnsmasq-hosts + type: config + file_info: { mode: 0755 } + - src: ./etc/pull-config.d/dnsmasq-servers + dst: /etc/pull-config.d/dnsmasq-servers + type: config + file_info: { mode: 0755 } From c6861dc9592a45a54c627d5324ccb5463f246e34 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:18:41 -0400 Subject: [PATCH 06/42] Replace top-level Makefile with component delegation The root Makefile now delegates the standard deps/build/install/dev/ deb/rpm/apk contract to the three component Makefiles and forwards PREFIX/DESTDIR/VERSION. 'make deb' builds all three packages and collects them into ./dist. Default goal is build. --- .gitignore | 3 +++ Makefile | 68 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 1176a0f3..d650d1a5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules .env .tmp-verify/ .playwright-mcp/ + +# packaging build artifacts +/dist/ diff --git a/Makefile b/Makefile index 40d4aeb3..0ef3203e 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,50 @@ -.PHONY: install install-create-container install-pull-config install-docs help +# opensource-server — top-level Makefile +# +# Delegates the standard component contract to each component's own Makefile: +# create-a-container -> opensource-server +# mie-opensource-landing -> opensource-docs +# pull-config -> opensource-agent +# +# Each component supports: deps, build (default), install, dev, deb/rpm/apk. +# Variables pass straight through: +# PREFIX vendor install prefix (default /opt/opensource-server) +# DESTDIR staging root for `install` (default /) +# VERSION package version (default derived from git tags) -help: - @echo "opensource-server installation" - @echo "" - @echo "Available targets:" - @echo " make install - Install all components" - @echo " make install-create-container - Install create-a-container web application" - @echo " make install-pull-config - Install pull-config system" - @echo " make install-docs - Install documentation server" - @echo "" +.DEFAULT_GOAL := build + +COMPONENTS := pull-config mie-opensource-landing create-a-container +PACKAGER ?= deb -install: install-create-container install-pull-config install-docs +# Forwarded to every component Makefile. +MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \ + $(if $(DESTDIR),DESTDIR=$(DESTDIR),) \ + $(if $(VERSION),VERSION=$(VERSION),) -SYSTEMD_DIR := create-a-container/systemd -SERVICES := $(wildcard $(SYSTEMD_DIR)/*.service) -install-create-container: - cd create-a-container && npm install --omit=dev - cd create-a-container/client && npm install && npm run build - install -m 644 -o root -g root $(SERVICES) /etc/systemd/system/ - systemctl daemon-reload || true - @for service in $(notdir $(SERVICES)); do \ - systemctl enable $$service; \ +.PHONY: deps build install dev deb rpm apk clean $(COMPONENTS) + +deps build install clean: + @for c in $(COMPONENTS); do \ + echo "==> $$c: $@"; \ + $(MAKE) -C $$c $@ $(MAKE_VARS) || exit $$?; \ done -install-pull-config: - cd pull-config && bash install.sh +# Package every component, then collect the artifacts into ./dist. +deb rpm apk: + @mkdir -p dist + @for c in $(COMPONENTS); do \ + echo "==> $$c: $@"; \ + $(MAKE) -C $$c $@ $(MAKE_VARS) || exit $$?; \ + cp -f $$c/*.$@ dist/ 2>/dev/null || true; \ + done + @echo "" + @echo "Packages collected in dist/:" + @ls -1 dist/ -install-docs: - cd mie-opensource-landing && uv run zensical build +# `make dev` isn't meaningful for the whole repo (each watcher is long-running); +# run it per component, e.g. `make -C create-a-container dev`. +dev: + @echo "Run 'dev' per component, e.g.:" + @echo " make -C create-a-container dev # server + client watch" + @echo " make -C create-a-container dev-client # client watch only" + @echo " make -C mie-opensource-landing dev # docs live server" From 1bb1cc304f02181e211dc701ec9fac0a46112a2e Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:38:12 -0400 Subject: [PATCH 07/42] Build images from nfpm packages + stage release apt source Add images/builder, a Debian image that runs 'make deb' (Node + uv + nfpm) to produce the three packages, exported as an artifact-only stage. docs/agent/manager now install those packages via a builder context instead of copying the repo and running make: - agent: installs opensource-agent; keeps image-only conffile edits (ModSecurity, dnsmasq, acme.sh). - docs: installs opensource-docs + nginx; keeps the vhost. - manager: installs opensource-docs + opensource-server (agent already present); keeps PGDG postgres + the Proxmox snakeoil drop-in. Each leaf image stages /etc/apt/sources.list.d/opensource-server.sources (flat repo at releases/latest/download) so 'apt upgrade' tracks future releases. The source is added last / removed-then-re-added in manager so an 'apt update' during the build is never blocked by the release not existing yet. Also fix container-creator.service to use the correct systemd directive LogsDirectory (not LogDirectory), so /var/log/opensource-server is created on demand; the typo silently disabled it and the app crashed opening access.log. --- .dockerignore | 12 ++++ .../systemd/container-creator.service | 4 +- images/agent/Dockerfile | 37 +++++------- images/builder/Dockerfile | 59 +++++++++++++++++++ images/docker-bake.hcl | 12 ++++ images/docs/Dockerfile | 18 +++--- images/manager/Dockerfile | 36 +++++++---- images/opensource-server.sources | 4 ++ 8 files changed, 134 insertions(+), 48 deletions(-) create mode 100644 images/builder/Dockerfile create mode 100644 images/opensource-server.sources diff --git a/.dockerignore b/.dockerignore index 85f712af..75782aff 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,17 @@ /create-a-container/certs/* !/create-a-container/certs/.gitignore /create-a-container/.env +/create-a-container/client/dist /mie-opensource-landing/build +/mie-opensource-landing/site +/mie-opensource-landing/.venv +/mie-opensource-landing/.cache **/node_modules +**/__pycache__ + +# packaging build artifacts (rebuilt inside the builder image) +**/build-root +/dist +**/*.deb +**/*.rpm +**/*.apk diff --git a/create-a-container/systemd/container-creator.service b/create-a-container/systemd/container-creator.service index 26db1f30..fa936f3a 100644 --- a/create-a-container/systemd/container-creator.service +++ b/create-a-container/systemd/container-creator.service @@ -14,8 +14,8 @@ Environment=ACCESS_LOG=/var/log/opensource-server/access.log EnvironmentFile=/etc/default/container-creator # Create and manage /var/log/opensource-server on demand instead of shipping # an empty directory in the package. -LogDirectory=opensource-server -LogDirectoryMode=0755 +LogsDirectory=opensource-server +LogsDirectoryMode=0755 [Install] WantedBy=multi-user.target diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile index df3cf944..c49d90ec 100644 --- a/images/agent/Dockerfile +++ b/images/agent/Dockerfile @@ -1,17 +1,18 @@ FROM nodejs -# Install all APT packages +# Install the agent package (and its dependencies: nginx with stream + +# ModSecurity modules, dnsmasq, cron, ...) built by the builder image. +COPY --from=builder /dist/opensource-agent_*.deb /tmp/debs/ RUN apt-get update && \ - apt-get install -y \ - libnginx-mod-stream \ - libnginx-mod-http-modsecurity \ - ssl-cert \ - make \ - dnsmasq \ - && \ + apt-get install -y /tmp/debs/*.deb && \ + rm -rf /tmp/debs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +# Everything below is image-level environment configuration that the package +# does not own because it edits conffiles belonging to other packages +# (nginx, modsecurity-crs, dnsmasq). + # configure nginx ModSecurity defaults RUN sed -i \ -e 's!^#\(include /usr/share/modsecurity-crs/owasp-crs.load\)$!\1!' \ @@ -19,9 +20,6 @@ RUN sed -i \ && sed -i -e 's/IncludeOptional/Include/' /usr/share/modsecurity-crs/owasp-crs.load \ && sed -i -e 's/^SecRuleEngine .*$/SecRuleEngine DetectionOnly/' /etc/nginx/modsecurity.conf -# Create the auth cache directory for NGINX forward auth -RUN mkdir -p /var/cache/nginx/auth_cache - # Logrotate overrides for NGINX and ModSecurity to work around a logrotate # repoen bug at https://github.com/owasp-modsecurity/ModSecurity-nginx/issues/351 COPY ./images/agent/nginx.logrotate /etc/logrotate.d/nginx @@ -31,7 +29,7 @@ COPY ./images/agent/nginx.logrotate /etc/logrotate.d/nginx COPY ./images/agent/crs-setup.conf /etc/modsecurity/crs/crs-setup.conf COPY ./images/agent/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf -# Install DNSMasq and configure it to only get it's config from our pull-config +# Configure DNSMasq to only get its config from our pull-config RUN sed -i \ -e 's/^CONFIG_DIR=\(.*\)$/#CONFIG_DIR=\1/' \ -e 's/^#IGNORE_RESOLVCONF=\(.*\)$/IGNORE_RESOLVCONF=\1/' \ @@ -41,17 +39,10 @@ RUN sed -i \ RUN curl -fsSL https://get.acme.sh | sh \ && /root/.acme.sh/acme.sh --upgrade --auto-upgrade -# Install uv for building the docs -ARG UV_VERSION=0.11.14 -RUN curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xzf - --strip-components=1 -C /usr/local/bin \ - uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx - -# Install the software. We include the .git directory so that the software can -# update itself without replacing the entire container. -COPY . /opt/opensource-server -RUN cd /opt/opensource-server \ - && make install-pull-config +# Stage the release APT source so `apt upgrade` pulls future releases of the +# opensource-* packages automatically. Added last so it is never consulted by +# an `apt update` during this build (the release may not exist yet). +COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources # Tag the exposed ports for services handled by the container # NGINX (http, https, quic) diff --git a/images/builder/Dockerfile b/images/builder/Dockerfile new file mode 100644 index 00000000..a7930698 --- /dev/null +++ b/images/builder/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 +# Builds the three opensource-server Debian packages with the component +# Makefiles + nfpm. The final stage contains only the built .deb artifacts so +# the runtime images can install them with: +# +# COPY --from=builder /dist/*.deb /tmp/debs/ +# +# and CI can export them with: +# +# docker buildx bake builder --set builder.output=type=local,dest=dist +FROM debian:13 AS build + +# Build toolchain: make/git for the component Makefiles, Node.js (with npm) +# from NodeSource for the manager build, uv for the docs build, and nfpm for +# packaging. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + make \ + && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ARG NODE_MAJOR_VERSION=24 +RUN curl -fsSL https://deb.nodesource.com/setup_$NODE_MAJOR_VERSION.x | bash - && \ + apt-get update && \ + apt-get install -y nodejs && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ARG UV_VERSION=0.11.14 +RUN curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xzf - --strip-components=1 -C /usr/local/bin \ + uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx + +ARG NFPM_VERSION=2.46.3 +RUN curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_amd64.deb" \ + -o /tmp/nfpm.deb && \ + apt-get update && \ + apt-get install -y /tmp/nfpm.deb && \ + rm /tmp/nfpm.deb && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY . /usr/src/opensource-server +WORKDIR /usr/src/opensource-server + +# git marks the COPYed tree as foreign-owned; allow it so `git describe` (the +# version source) works. +RUN git config --global --add safe.directory /usr/src/opensource-server + +# Build all three packages into ./dist. +RUN make deb + +# Artifact-only stage. +FROM scratch +COPY --from=build /usr/src/opensource-server/dist /dist diff --git a/images/docker-bake.hcl b/images/docker-bake.hcl index 7e8f1b28..8a5e4e8b 100644 --- a/images/docker-bake.hcl +++ b/images/docker-bake.hcl @@ -13,11 +13,21 @@ target "nodejs" { } } +# Builds the three Debian packages consumed by the docs, agent and manager +# images. Not part of the default group: built on demand as a dependency, and +# exported by CI with `docker buildx bake builder --set +# builder.output=type=local,dest=dist`. +target "builder" { + context = "../" + dockerfile = "images/builder/Dockerfile" +} + target "docs" { context = "../" dockerfile = "images/docs/Dockerfile" contexts = { base = "target:base" + builder = "target:builder" } } @@ -26,6 +36,7 @@ target "agent" { dockerfile = "images/agent/Dockerfile" contexts = { nodejs = "target:nodejs" + builder = "target:builder" } } @@ -34,6 +45,7 @@ target "manager" { dockerfile = "images/manager/Dockerfile" contexts = { agent = "target:agent" + builder = "target:builder" } } diff --git a/images/docs/Dockerfile b/images/docs/Dockerfile index 8af6f6af..749132fa 100644 --- a/images/docs/Dockerfile +++ b/images/docs/Dockerfile @@ -1,20 +1,16 @@ FROM base -# Install prerequisites +# Install nginx and the docs package built by the builder image. The package +# ships content only; serving it is this image's concern. +COPY --from=builder /dist/opensource-docs_*.deb /tmp/debs/ RUN apt-get update && \ - apt-get install -y make nginx && \ + apt-get install -y nginx /tmp/debs/*.deb && \ + rm -rf /tmp/debs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Install uv for building the docs -ARG UV_VERSION=0.11.6 -RUN curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xzf - --strip-components=1 -C /usr/local/bin \ - uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx - -# Build docs -COPY . /opt/opensource-server -RUN cd /opt/opensource-server && make install-docs +# Stage the release APT source so `apt upgrade` pulls future releases. +COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources # Replace the default nginx site with our docs config COPY ./images/docs/docs-site.conf /etc/nginx/sites-enabled/default diff --git a/images/manager/Dockerfile b/images/manager/Dockerfile index cdb0be99..18b326aa 100644 --- a/images/manager/Dockerfile +++ b/images/manager/Dockerfile @@ -5,9 +5,16 @@ FROM agent ENV SITE_ID=1 ENV MANAGER_URL=http://localhost:3000 +# The release APT source is inherited from the agent image. Remove it for the +# duration of this build so `apt update` (PGDG, package installs) is never +# blocked by the release not existing yet; it is re-added at the end. +RUN rm -f /etc/apt/sources.list.d/opensource-server.sources + # Install Postgres 18 from the PGDG repository and enable remote access only # for TLS encrypted connections. Debian creates a self-signed cert for this so -# we don't have to make our own. +# we don't have to make our own. The opensource-server package only suggests +# postgresql (a remote database is equally valid); this image opts into a +# local one. RUN apt update && apt -y install postgresql-common \ && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \ && apt -y install postgresql-18 \ @@ -18,17 +25,22 @@ RUN apt update && apt -y install postgresql-common \ && mkdir -p /etc/systemd/system/postgresql@.service.d # Proxmox injects a service which regenerates the snakeoil cert. This systemd -# override prevents a race condition at boot. +# override prevents a race condition at boot. It is environment-specific, so +# it lives in the image rather than the package. COPY images/manager/wait-proxmox-regenerate-snakeoil.conf /etc/systemd/system/postgresql@.service.d/ -# Directory for the create-a-container access log written by morgan -# (see Environment=ACCESS_LOG in container-creator.service) and its -# logrotate config. -RUN mkdir -p /var/log/opensource-server -COPY images/manager/opensource-server.logrotate /etc/logrotate.d/opensource-server +# Install the manager and docs packages built by the builder image. The agent +# package is already present from the parent image; apt resolves the +# opensource-server -> opensource-docs dependency. The package postinstall +# enables the manager systemd units, and systemd LogDirectory creates +# /var/log/opensource-server on demand. +COPY --from=builder /dist/opensource-docs_*.deb /dist/opensource-server_*.deb /tmp/debs/ +RUN apt-get update && \ + apt-get install -y /tmp/debs/*.deb && \ + rm -rf /tmp/debs && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* -# Install the software. We include the .git directory so that the software can -# update itself without replacing the entire container. -COPY . /opt/opensource-server -RUN cd /opt/opensource-server \ - && make install +# Re-add the release APT source (removed at the top) so `apt upgrade` pulls +# future releases of the opensource-* packages automatically. +COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources diff --git a/images/opensource-server.sources b/images/opensource-server.sources new file mode 100644 index 00000000..97a80bc4 --- /dev/null +++ b/images/opensource-server.sources @@ -0,0 +1,4 @@ +Types: deb +URIs: https://github.com/mieweb/opensource-server/releases/latest/download/ +Suites: ./ +Trusted: yes From 2d3775e5e5751e74f0c9a559d833617468e1d521 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:39:37 -0400 Subject: [PATCH 08/42] Use component make targets in compose dev services The node, client and zensical dev services now install make and invoke the component Makefile targets instead of inlining npm/uv commands: - node: 'make deps' (create-a-container) - client: 'make dev-client' (client bundle watcher only; the server runs inside Proxmox) - zensical: 'make dev' (docs live server), preserving VIRTUAL_ENV Keeps the dev workflow in lockstep with the packaging build steps. --- compose.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/compose.yml b/compose.yml index d5fe6a70..4f8126aa 100644 --- a/compose.yml +++ b/compose.yml @@ -37,7 +37,11 @@ services: volumes: - ./:/opt/opensource-server working_dir: /opt/opensource-server/create-a-container - command: npm ci --no-audit --no-fund + entrypoint: ["/bin/sh", "-c"] + command: + - | + apt-get update && apt-get install -y --no-install-recommends make git + exec make deps # Watches the React client and rebuilds the production bundle on change. The # Manager LXC inside Proxmox serves create-a-container/client/dist statically @@ -49,17 +53,18 @@ services: # # Like zensical, this is a development convenience and not a hard dependency: # it does an initial build so a dist always exists, then watches for changes. + # The server itself runs inside Proxmox, so this only runs the client watcher + # (create-a-container's `make dev-client`). client: image: node:24-trixie-slim volumes: - ./:/opt/opensource-server - working_dir: /opt/opensource-server/create-a-container/client + working_dir: /opt/opensource-server/create-a-container entrypoint: ["/bin/sh", "-c"] command: - | - npm ci --no-audit --no-fund - npm run build - exec npm run build:watch + apt-get update && apt-get install -y --no-install-recommends make git + exec make dev-client # Healthy once an initial bundle exists, so Proxmox can wait for it on a # fresh checkout (where client/dist isn't present yet) before serving. healthcheck: @@ -81,7 +86,11 @@ services: volumes: - ./:/opt/opensource-server working_dir: /opt/opensource-server/mie-opensource-landing - command: uv run --active zensical serve + entrypoint: ["/bin/sh", "-c"] + command: + - | + apt-get update && apt-get install -y --no-install-recommends make git + exec make dev environment: VIRTUAL_ENV: /opt/zensical PROXMOX_URL: https://localhost:8006 From e04a67cdc691b12740e5b7968219f2e46c72dfc0 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:40:32 -0400 Subject: [PATCH 09/42] Add release workflow and expand image build triggers (Commit A) - release.yml: on a v* tag, build the three packages via the builder bake target, generate flat APT repo metadata (Packages, Packages.gz) with dpkg-scanpackages, and attach the debs + metadata to the GitHub release so releases/latest/download serves a working 'deb [trusted=yes] ... ./' source. - build-images.yml: expand the paths filters to the component directories, **/Makefile, **/nfpm.yaml and .dockerignore, since the images now build from the component packages. --- .github/workflows/build-images.yml | 14 ++++++-- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index abcb07d9..aef3ef23 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -8,14 +8,24 @@ on: - '**' paths: - 'images/**' + - 'create-a-container/**' - 'mie-opensource-landing/**' - - 'Makefile' + - 'pull-config/**' + - 'error-pages/**' + - '**/Makefile' + - '**/nfpm.yaml' + - '.dockerignore' pull_request: types: [opened, synchronize, reopened, closed] paths: - 'images/**' + - 'create-a-container/**' - 'mie-opensource-landing/**' - - 'Makefile' + - 'pull-config/**' + - 'error-pages/**' + - '**/Makefile' + - '**/nfpm.yaml' + - '.dockerignore' schedule: # Run weekly on Sunday at 11:00 PM UTC (Sunday-Monday night depending on timezone) - cron: '0 23 * * 0' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6d0d1bf5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release Packages + +# On a vX.Y.Z tag, build the three Debian packages and attach them to the +# GitHub release together with flat APT repository metadata (Packages, +# Packages.gz) so the release can be used directly as an apt source: +# +# deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./ +# +# The release URLs (releases/latest/download/) serve the flat repo; the +# images ship this source so `apt upgrade` tracks future releases. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history so `git describe` (the version source) works. + fetch-depth: 0 + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build packages + uses: docker/bake-action@v5 + with: + workdir: ./images + targets: builder + files: | + ./docker-bake.hcl + set: | + builder.output=type=local,dest=../dist + + - name: Generate flat APT repository metadata + run: | + cd dist/dist + dpkg-scanpackages --multiversion . > Packages + gzip -k9f Packages + ls -l + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + files: | + dist/dist/*.deb + dist/dist/Packages + dist/dist/Packages.gz + fail_on_unmatched_files: true From 1b419f55caa5a6d49ad11ba681a0f189c635d5c8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:41:22 -0400 Subject: [PATCH 10/42] Tag images :latest only on non-prerelease releases (Commit B) Add a release trigger (published, released) and gate the :latest tag on a non-prerelease release event instead of every push to main. Merges to main still publish branch/sha tags; :latest now moves only when a real (non-prerelease) GitHub release is published, keeping the :latest image channel aligned with the releases/latest deb channel. --- .github/workflows/build-images.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index aef3ef23..3451309c 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -30,6 +30,11 @@ on: # Run weekly on Sunday at 11:00 PM UTC (Sunday-Monday night depending on timezone) - cron: '0 23 * * 0' workflow_dispatch: + # Images are tagged :latest only from release events for non-prerelease + # releases. "released" additionally covers a pre-release later being + # promoted to a full release. + release: + types: [published, released] env: REGISTRY: ghcr.io @@ -68,7 +73,7 @@ jobs: type=ref,event=branch type=ref,event=pr type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Docker Meta (NodeJS) id: meta-nodejs @@ -81,7 +86,7 @@ jobs: type=ref,event=branch type=ref,event=pr type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Docker Meta (Docs) id: meta-docs @@ -94,7 +99,7 @@ jobs: type=ref,event=branch type=ref,event=pr type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Docker Meta (Agent) id: meta-agent @@ -106,7 +111,7 @@ jobs: type=sha type=ref,event=branch type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Docker Meta (Manager) id: meta-manager @@ -118,7 +123,7 @@ jobs: type=sha type=ref,event=branch type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Docker Meta (Proxmox VE) id: meta-proxmox-ve @@ -130,7 +135,7 @@ jobs: type=sha type=ref,event=branch type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - name: Build and push uses: docker/bake-action@v5 From 58464dee571ff0d747b2471238ee99b626c83f49 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:44:01 -0400 Subject: [PATCH 11/42] Document the release pipeline; refresh stale docs - Add developers/release-pipeline.md covering the component Makefile contract (deps/build/install/dev), nfpm packaging and the three package names, the builder image, the flat APT repo on GitHub Releases, the deb apt source, and the latest-on-non-prerelease rule. - Wire it into the docs nav. - docker-images.md: fix the stale lego reference (acme.sh), describe the package-based image composition and the builder target. - pull-config.md: note the Makefile/package supersede install.sh. - release.yml: also publish *_latest.deb stable-name aliases. --- .github/workflows/release.yml | 7 + .../docs/developers/docker-images.md | 22 ++- .../docs/developers/pull-config.md | 3 +- .../docs/developers/release-pipeline.md | 139 ++++++++++++++++++ mie-opensource-landing/zensical.toml | 1 + 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 mie-opensource-landing/docs/developers/release-pipeline.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d0d1bf5..b59465ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,8 +44,15 @@ jobs: - name: Generate flat APT repository metadata run: | cd dist/dist + # Scan only the versioned packages so the apt index has no duplicates. dpkg-scanpackages --multiversion . > Packages gzip -k9f Packages + # Stable-name aliases for one-off downloads (the _latest URLs resolve + # via releases/latest/download for the newest non-prerelease release). + for pkg in opensource-server opensource-docs opensource-agent; do + f=$(ls ${pkg}_*.deb | head -1) + [ -n "$f" ] && cp -f "$f" "${pkg}_latest.deb" + done ls -l - name: Upload release assets diff --git a/mie-opensource-landing/docs/developers/docker-images.md b/mie-opensource-landing/docs/developers/docker-images.md index 84a086b0..18b1000a 100644 --- a/mie-opensource-landing/docs/developers/docker-images.md +++ b/mie-opensource-landing/docs/developers/docker-images.md @@ -22,13 +22,17 @@ Extends base with Node.js 24 from NodeSource. Inherits LDAP authentication. ### Agent (`agent`) -Extends nodejs with nginx (+ ModSecurity/OWASP CRS), dnsmasq, and [lego](https://github.com/go-acme/lego) for ACME certificate management. Used as the networking layer for each site — handles reverse proxy, DNS, and TLS. See [Deploying Agents](../admins/deploying-agents.md). +Extends nodejs with the `opensource-agent` package (pull-config, nginx with +ModSecurity/OWASP CRS, dnsmasq) and [acme.sh](https://github.com/acmesh-official/acme.sh) +for ACME certificate management. Used as the networking layer for each site — +handles reverse proxy, DNS, and TLS. See [Deploying Agents](../admins/deploying-agents.md). **Registry:** `ghcr.io/mieweb/opensource-server/agent` · **Source:** [`images/agent/`](https://github.com/mieweb/opensource-server/tree/main/images/agent) ### Manager (`manager`) -Extends agent with PostgreSQL 18. Runs the full management application. See [Installation Guide](../admins/installation.md). +Extends agent with PostgreSQL 18 and the `opensource-server` + `opensource-docs` +packages. Runs the full management application. See [Installation Guide](../admins/installation.md). **Registry:** `ghcr.io/mieweb/opensource-server/manager` · **Source:** [`images/manager/`](https://github.com/mieweb/opensource-server/tree/main/images/manager) @@ -36,6 +40,12 @@ Extends agent with PostgreSQL 18. Runs the full management application. See [Ins Images use **Docker Bake** (`docker buildx bake`) with [`images/docker-bake.hcl`](https://github.com/mieweb/opensource-server/blob/main/images/docker-bake.hcl) defining build order and dependencies. The `contexts` attribute ensures proper ordering (e.g., nodejs depends on base). +The application payloads are **not** copied into the images. Instead, the +`builder` target compiles the three Debian packages (`opensource-server`, +`opensource-docs`, `opensource-agent`) and the docs/agent/manager images +install them via a `builder` context. See [Release Pipeline](release-pipeline.md) +for the packaging details. + ``` images/ ├── docker-bake.hcl # Build config with dependency ordering @@ -45,10 +55,14 @@ images/ │ └── ldapusers ├── nodejs/ │ └── Dockerfile # Extends base image +├── builder/ +│ └── Dockerfile # Builds the .deb packages (artifact-only image) +├── docs/ +│ └── Dockerfile # Extends base (nginx + opensource-docs) ├── agent/ -│ └── Dockerfile # Extends nodejs (nginx, dnsmasq, lego) +│ └── Dockerfile # Extends nodejs (opensource-agent, ModSecurity, acme.sh) └── manager/ - └── Dockerfile # Extends agent (PostgreSQL, full app) + └── Dockerfile # Extends agent (PostgreSQL + opensource-server/docs) ``` ## CI Workflow diff --git a/mie-opensource-landing/docs/developers/pull-config.md b/mie-opensource-landing/docs/developers/pull-config.md index 1481e1ed..3bc155c6 100644 --- a/mie-opensource-landing/docs/developers/pull-config.md +++ b/mie-opensource-landing/docs/developers/pull-config.md @@ -21,7 +21,8 @@ pull-config/ │ ├── dnsmasq-hosts │ ├── dnsmasq-dhcp-opts │ └── dnsmasq-servers -└── install.sh # Copies scripts to /etc/ +├── Makefile # Builds the opensource-agent package (see Release Pipeline) +└── install.sh # Legacy direct installer (superseded by the package) ``` ## Environment Variables diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md new file mode 100644 index 00000000..fd25c7ae --- /dev/null +++ b/mie-opensource-landing/docs/developers/release-pipeline.md @@ -0,0 +1,139 @@ +# Release Pipeline + +The three deployable components are packaged as Debian packages and published +to GitHub Releases as a flat APT repository. The same component build commands +are reused by local development, the container images, and CI. + +## Components and packages + +| Directory | Package | Arch | Contents | +|---|---|---|---| +| [`create-a-container/`](https://github.com/mieweb/opensource-server/tree/main/create-a-container) | `opensource-server` | amd64 | Manager web app, job runner, systemd units | +| [`mie-opensource-landing/`](https://github.com/mieweb/opensource-server/tree/main/mie-opensource-landing) | `opensource-docs` | all | Prebuilt documentation site | +| [`pull-config/`](https://github.com/mieweb/opensource-server/tree/main/pull-config) | `opensource-agent` | all | pull-config engine, instances, error pages | + +Everything installs under the `/opt/opensource-server` prefix, matching the +paths referenced by the systemd units, the pull-config instances, and the +manager-rendered nginx configuration. `opensource-server` depends on +`opensource-agent` and `opensource-docs` because the manager's nginx config +serves the agent's error pages and the docs site. + +## The component Makefile contract + +Each component directory has a self-contained `Makefile` with the same targets. +The default goal is `build`. + +| Target | Description | +|---|---| +| `deps` | Install build/runtime dependencies (`npm ci`, `uv sync`, or nothing) | +| `build` | Compile the component (default goal); depends on `deps` | +| `install` | Stage built files into `DESTDIR` at their final paths; depends on `build` | +| `dev` | Run the development watch loop; depends on `deps` | +| `deb` / `rpm` / `apk` | Stage and package with [nfpm](https://nfpm.goreleaser.com); depend on `install` | + +Variables (overridable): + +| Variable | Default | Meaning | +|---|---|---| +| `PREFIX` | `/opt/opensource-server` | Vendor install prefix | +| `DESTDIR` | `/` | Staging root for `install` | +| `VERSION` | derived from `git describe` | Package version | + +`VERSION` is computed inline from git tags: an exact tag `v2026.6.2` becomes +`2026.6.2`; commits after a tag become `2026.6.2+.g` (valid semver build +metadata that sorts above the tag and below the next release). + +```bash +# Build and stage a component anywhere: +make -C pull-config install DESTDIR=/tmp/agent-root + +# Build one package: +make -C create-a-container deb # -> create-a-container/*.deb + +# Build all three and collect them into ./dist: +make deb +``` + +The top-level `Makefile` simply forwards these targets to every component and +collects the packages into `dist/`. + +## Development + +`make dev` runs the long-running watch loops: + +```bash +make -C create-a-container dev # server (nodemon) + client (vite watch) +make -C create-a-container dev-client # client bundle watcher only +make -C mie-opensource-landing dev # docs live server +``` + +The local development stack ([`compose.yml`](https://github.com/mieweb/opensource-server/blob/main/compose.yml)) +uses these same targets: the `client` service runs `make dev-client` (the +server runs inside the Proxmox container) and the `zensical` service runs +`make dev`. + +## Packaging with nfpm + +Each component has an `nfpm.yaml` that packages the staged tree (`type: tree`) +plus any config files and maintainer scripts. nfpm produces deb, rpm, and apk +from the same definition, so `make rpm` and `make apk` also work. + +- `opensource-server` ships the three systemd units and enables them via an + nfpm `postinstall` script (`preremove` disables them on real removal). The + log directory is created on demand by the unit's `LogsDirectory`, not shipped + in the package. The logrotate drop-in is a config file. +- `opensource-agent` ships the pull-config instances and cron schedule as + config files so admin customizations survive upgrades. +- `opensource-docs` ships content only. + +## Container images + +The [`builder`](https://github.com/mieweb/opensource-server/blob/main/images/builder/Dockerfile) +image runs `make deb` (Node.js, uv, and nfpm) to produce all three packages, +exported as an artifact-only stage. The `docs`, `agent`, and `manager` images +install those packages via a Docker Bake `builder` context instead of copying +the repository: + +```dockerfile +COPY --from=builder /dist/opensource-agent_*.deb /tmp/debs/ +RUN apt-get update && apt-get install -y /tmp/debs/*.deb && rm -rf /tmp/debs +``` + +Each leaf image also stages the release APT source +(`/etc/apt/sources.list.d/opensource-server.sources`) so a running container +picks up future releases with `apt upgrade`. + +## Releases + +On a `vX.Y.Z` tag, +[`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml) +builds the packages, generates flat APT repository metadata (`Packages`, +`Packages.gz`) with `dpkg-scanpackages`, and attaches the debs and metadata to +the GitHub release. Because GitHub serves `releases/latest/download/` for +the newest non-prerelease release, the release doubles as an apt source: + +```text +deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./ +``` + +Image tagging follows the same rule: `:latest` is published only when a +**non-prerelease** release is published, keeping the `:latest` image channel +aligned with the `releases/latest` package channel. Pre-releases publish their +own assets and `:vX.Y.Z` image tags without moving `:latest`. + +## Installing and updating on a host + +```bash +# One-off install (stable URL): +curl -fsSLO https://github.com/mieweb/opensource-server/releases/latest/download/opensource-agent_latest.deb +apt install -y ./opensource-agent_latest.deb + +# Or add the apt source once, then use apt normally: +cat >/etc/apt/sources.list.d/opensource-server.sources <<'EOF' +Types: deb +URIs: https://github.com/mieweb/opensource-server/releases/latest/download/ +Suites: ./ +Trusted: yes +EOF +apt update && apt install opensource-server +``` diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml index a61f3210..261e1c18 100644 --- a/mie-opensource-landing/zensical.toml +++ b/mie-opensource-landing/zensical.toml @@ -51,6 +51,7 @@ nav = [ { "Core Technologies" = "developers/core-technologies.md" }, { "Development Workflow" = "developers/development-workflow.md" }, { "Docker Images" = "developers/docker-images.md" }, + { "Release Pipeline" = "developers/release-pipeline.md" }, { "Database Schema" = "developers/database-schema.md" }, { "Pull Config" = "developers/pull-config.md" }, { "Contributing" = "developers/contributing.md" }, From a33967e5945dd00b6a1854b64eabd23776ef5f61 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:57:12 -0400 Subject: [PATCH 12/42] Refine opensource-server packaging; move DB init to manager image - nfpm.yaml no longer fights 'make install': /etc files are declared explicitly as config and the rest is packaged per top-level directory (/opt, /usr), so no file appears in both a tree and a config entry. 'make package' no longer rm -rf's or mutates the staged build-root; it reuses the DESTDIR install (run 'make clean' first for a pristine build). - container-creator-init.service is the manager image's concern, not the package's: it provisions a *local* PostgreSQL, which the package only suggests. Moved the unit to images/manager and made it Requires=postgresql.service. The package now ships only container-creator + job-runner; postinstall/preremove manage those two. --- create-a-container/Makefile | 28 +++++++++++-------- create-a-container/nfpm.yaml | 21 +++++++++----- create-a-container/nfpm/postinstall.sh | 6 ++-- create-a-container/nfpm/preremove.sh | 2 +- .../manager}/container-creator-init.service | 6 ++++ 5 files changed, 41 insertions(+), 22 deletions(-) rename {create-a-container/systemd => images/manager}/container-creator-init.service (78%) diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 2b59d913..f4dca307 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -5,11 +5,15 @@ # build (default) compile the component; depends on deps # install stage built files into DESTDIR; depends on build # dev run the development watch loop; depends on deps -# deb/rpm/apk package the staged tree with nfpm; depend on install +# deb/rpm/apk package the staged build-root with nfpm; depend on install # # PREFIX is the vendor install prefix every runtime reference in the project # expects (systemd units, the manager-rendered nginx config). DESTDIR is the # staging root. VERSION is derived from git but can be overridden. +# +# Packaging stages into ./build-root (a normal DESTDIR install) and points +# nfpm at it. The staged tree is reused across packagers; run `make clean` +# first for a guaranteed-pristine package. .DEFAULT_GOAL := build @@ -18,7 +22,6 @@ DESTDIR ?= / # v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') -PKG_NAME := opensource-server DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system @@ -57,6 +60,10 @@ dev-client: deps npm run client:build npm run client:build:watch +# Stage built files into DESTDIR. container-creator-init.service is NOT +# installed here: provisioning a local PostgreSQL is an image/environment +# choice (the package only suggests postgresql), so that unit lives in the +# manager image. install: build $(INSTALL) -d $(DESTBIN) $(INSTALL_DATA) $(APP_FILES) $(DESTBIN)/ @@ -68,23 +75,22 @@ install: build # systemd units (enabled by the package postinstall script) $(INSTALL) -d $(UNIT_DIR) $(INSTALL_DATA) systemd/container-creator.service $(UNIT_DIR)/ - $(INSTALL_DATA) systemd/container-creator-init.service $(UNIT_DIR)/ $(INSTALL_DATA) systemd/job-runner.service $(UNIT_DIR)/ # logrotate for the morgan access log $(INSTALL) -d $(DESTDIR)/etc/logrotate.d $(INSTALL_DATA) nfpm/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server -# Package the staged tree with nfpm. Staged into a clean root so the package -# contains only this component's files. +# Package the staged build-root with nfpm. nfpm trees the non-/etc paths and +# declares /etc files as config explicitly (see nfpm.yaml), so staging and +# packaging never fight over conffile tagging. PACKAGER ?= deb -package: - rm -rf build-root - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) - # The logrotate drop-in is added by nfpm.yaml as a config file from source, - # so drop the staged copy to avoid a content collision with the tree. - rm -f build-root/etc/logrotate.d/opensource-server +package: install-buildroot VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) +.PHONY: install-buildroot +install-buildroot: + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + deb: $(MAKE) package PACKAGER=deb rpm: diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml index cb5e0723..38198ae8 100644 --- a/create-a-container/nfpm.yaml +++ b/create-a-container/nfpm.yaml @@ -3,6 +3,11 @@ # `make apk`, which stage the component into ./build-root first and pass # VERSION through the environment. # +# /etc files are declared explicitly as config so admin edits survive upgrades; +# the rest of the staged tree is packaged per top-level directory. This avoids +# any overlap between the tree and the config entries (nfpm rejects a file that +# appears in both), so `make install` and `make package` never fight. +# # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-server arch: amd64 @@ -34,15 +39,17 @@ suggests: - postgresql contents: - # Everything staged by `make install DESTDIR=./build-root`, except the - # logrotate drop-in which `make package` removes from the staged tree so it - # can be added here as a config file (preserving admin edits on upgrade). - - src: ./build-root - dst: / - type: tree - - src: ./nfpm/opensource-server.logrotate + # Config files first, declared explicitly so they are tagged as conffiles. + - src: ./build-root/etc/logrotate.d/opensource-server dst: /etc/logrotate.d/opensource-server type: config + # Everything else, packaged per top-level directory (no /etc overlap). + - src: ./build-root/opt + dst: /opt + type: tree + - src: ./build-root/usr + dst: /usr + type: tree scripts: postinstall: ./nfpm/postinstall.sh diff --git a/create-a-container/nfpm/postinstall.sh b/create-a-container/nfpm/postinstall.sh index 3b4b9325..9b31237a 100755 --- a/create-a-container/nfpm/postinstall.sh +++ b/create-a-container/nfpm/postinstall.sh @@ -2,7 +2,7 @@ # postinstall for opensource-server: enable the manager systemd units. set -e -UNITS="container-creator-init.service container-creator.service job-runner.service" +UNITS="container-creator.service job-runner.service" if [ -d /run/systemd/system ]; then systemctl daemon-reload || true @@ -16,9 +16,9 @@ for unit in $UNITS; do done # If systemd is running, (re)start the long-running services so an upgrade -# picks up new code. The init service is oneshot and gated on its conditions. +# picks up new code. if [ -d /run/systemd/system ]; then - systemctl restart container-creator.service job-runner.service || true + systemctl restart $UNITS || true fi exit 0 diff --git a/create-a-container/nfpm/preremove.sh b/create-a-container/nfpm/preremove.sh index 13cbf75b..edfc8370 100755 --- a/create-a-container/nfpm/preremove.sh +++ b/create-a-container/nfpm/preremove.sh @@ -2,7 +2,7 @@ # preremove for opensource-server: stop and disable the manager systemd units. set -e -UNITS="container-creator.service job-runner.service container-creator-init.service" +UNITS="container-creator.service job-runner.service" # $1 is "remove"/"purge" (deb) or the remaining-version count (rpm: 0 on final # removal). Only act on a real removal, not an upgrade. diff --git a/create-a-container/systemd/container-creator-init.service b/images/manager/container-creator-init.service similarity index 78% rename from create-a-container/systemd/container-creator-init.service rename to images/manager/container-creator-init.service index bac393ef..23513902 100644 --- a/create-a-container/systemd/container-creator-init.service +++ b/images/manager/container-creator-init.service @@ -1,6 +1,12 @@ [Unit] Description=Initialize PostgreSQL for Container Creator +# This unit provisions a *local* PostgreSQL database for the manager. It is +# part of the manager image (which installs PostgreSQL), not the +# opensource-server package — the package only suggests postgresql and works +# equally with a remote database. Hence postgresql is a hard requirement here. +Requires=postgresql.service After=postgresql.service +Before=container-creator.service ConditionPathExists=!/etc/default/container-creator [Service] From 9c50c495770c7aeba0a86861f618848d1e1bd810 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 12:58:34 -0400 Subject: [PATCH 13/42] Stop make package from mutating build-root in docs and agent Apply the same nfpm tree-split as opensource-server: /etc files are declared explicitly as config entries and the rest is packaged per top-level directory, so no file appears in both a tree and a config entry. 'make package' now reuses the DESTDIR install instead of rm -rf'ing the staged tree and deleting conffiles from it (run 'make clean' first for a pristine package). --- mie-opensource-landing/Makefile | 10 +++++++--- pull-config/Makefile | 15 ++++++++------- pull-config/nfpm.yaml | 33 ++++++++++++++++++++------------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index a6fda699..fd3dadb7 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -30,12 +30,16 @@ install: build install -d $(SITE_DEST) cp -a site/. $(SITE_DEST)/ +# Package the staged build-root with nfpm. The staged tree is reused across +# packagers; run `make clean` first for a guaranteed-pristine package. PACKAGER ?= deb -package: - rm -rf build-root - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) +package: install-buildroot VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) +.PHONY: install-buildroot +install-buildroot: + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + deb: $(MAKE) package PACKAGER=deb rpm: diff --git a/pull-config/Makefile b/pull-config/Makefile index 33ce2b1f..c7d1887a 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -41,16 +41,17 @@ install: build # Forward-auth cache directory used by the manager-rendered nginx config. $(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache +# Package the staged build-root with nfpm. /etc files are declared as config +# in nfpm.yaml and the rest is packaged per top-level directory, so staging and +# packaging never fight. Run `make clean` first for a pristine package. PACKAGER ?= deb -package: - rm -rf build-root - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) - # Conffiles are added by nfpm.yaml as config files from source, so drop the - # staged copies to avoid content collisions with the tree. - rm -f build-root/etc/cron.d/pull-config - rm -rf build-root/etc/pull-config.d +package: install-buildroot VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) +.PHONY: install-buildroot +install-buildroot: + $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) + deb: $(MAKE) package PACKAGER=deb rpm: diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml index 67b29f99..dec58357 100644 --- a/pull-config/nfpm.yaml +++ b/pull-config/nfpm.yaml @@ -2,6 +2,11 @@ # See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / # `make apk`, which stage the component into ./build-root first. # +# /etc files are declared explicitly as config so admin customizations survive +# upgrades; the rest of the staged tree is packaged per top-level directory. +# This avoids any overlap between the tree and the config entries, so +# `make install` and `make package` never fight over conffile tagging. +# # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-agent arch: all @@ -30,36 +35,38 @@ depends: - cron contents: - # Everything staged by `make install`, minus the conffiles which `make - # package` removes from the staged tree so they can be added here as config - # files (preserving admin customizations on upgrade). - - src: ./build-root - dst: / - type: tree - - src: ./etc/cron.d/pull-config + # Config files first, declared explicitly so they are tagged as conffiles. + - src: ./build-root/etc/cron.d/pull-config dst: /etc/cron.d/pull-config type: config - - src: ./etc/pull-config.d/nginx + - src: ./build-root/etc/pull-config.d/nginx dst: /etc/pull-config.d/nginx type: config file_info: { mode: 0755 } - - src: ./etc/pull-config.d/dnsmasq-conf + - src: ./build-root/etc/pull-config.d/dnsmasq-conf dst: /etc/pull-config.d/dnsmasq-conf type: config file_info: { mode: 0755 } - - src: ./etc/pull-config.d/dnsmasq-dhcp-hosts + - src: ./build-root/etc/pull-config.d/dnsmasq-dhcp-hosts dst: /etc/pull-config.d/dnsmasq-dhcp-hosts type: config file_info: { mode: 0755 } - - src: ./etc/pull-config.d/dnsmasq-dhcp-opts + - src: ./build-root/etc/pull-config.d/dnsmasq-dhcp-opts dst: /etc/pull-config.d/dnsmasq-dhcp-opts type: config file_info: { mode: 0755 } - - src: ./etc/pull-config.d/dnsmasq-hosts + - src: ./build-root/etc/pull-config.d/dnsmasq-hosts dst: /etc/pull-config.d/dnsmasq-hosts type: config file_info: { mode: 0755 } - - src: ./etc/pull-config.d/dnsmasq-servers + - src: ./build-root/etc/pull-config.d/dnsmasq-servers dst: /etc/pull-config.d/dnsmasq-servers type: config file_info: { mode: 0755 } + # Everything else, packaged per top-level directory (no /etc overlap). + - src: ./build-root/opt + dst: /opt + type: tree + - src: ./build-root/var + dst: /var + type: tree From 59653e4e6a75aa6b47faf45d3ec508b503bc55e5 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:12:40 -0400 Subject: [PATCH 14/42] Install package debs via RUN --mount; leave apt source in place - Bind-mount the builder's /dist with RUN --mount=from=builder instead of COPY + RUN, removing the extra image layer per package install. - Stage the release APT source once and leave it in place for the whole build (and the final image) instead of removing and re-adding it. A missing release is a non-fatal 'apt update' warning for our own install steps ('|| true'); the PGDG setup script does a strict update, so the source is moved aside and restored within that single layer. - Install and enable container-creator-init.service in the manager image (it provisions local PostgreSQL, an image concern, not the package's). --- images/agent/Dockerfile | 25 ++++++++++++-------- images/docs/Dockerfile | 19 +++++++++------- images/manager/Dockerfile | 48 ++++++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile index c49d90ec..92690112 100644 --- a/images/agent/Dockerfile +++ b/images/agent/Dockerfile @@ -1,11 +1,21 @@ +# syntax=docker/dockerfile:1 FROM nodejs +# Stage the release APT source so `apt upgrade` pulls future releases of the +# opensource-* packages automatically. A missing release (e.g. during image +# builds) is a non-fatal `apt update` warning, so the source can live here for +# the whole build. +COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources + # Install the agent package (and its dependencies: nginx with stream + -# ModSecurity modules, dnsmasq, cron, ...) built by the builder image. -COPY --from=builder /dist/opensource-agent_*.deb /tmp/debs/ -RUN apt-get update && \ - apt-get install -y /tmp/debs/*.deb && \ - rm -rf /tmp/debs && \ +# ModSecurity modules, dnsmasq, cron, ...) built by the builder image. The +# package is bind-mounted from the builder stage rather than copied so it does +# not add an image layer. `apt-get update` is allowed to fail because the +# release APT source may 404 during a build (the release being built may not +# exist yet); the install uses cached indices. +RUN --mount=from=builder,source=/dist,target=/dist \ + { apt-get update || true; } && \ + apt-get install -y /dist/opensource-agent_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -39,11 +49,6 @@ RUN sed -i \ RUN curl -fsSL https://get.acme.sh | sh \ && /root/.acme.sh/acme.sh --upgrade --auto-upgrade -# Stage the release APT source so `apt upgrade` pulls future releases of the -# opensource-* packages automatically. Added last so it is never consulted by -# an `apt update` during this build (the release may not exist yet). -COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources - # Tag the exposed ports for services handled by the container # NGINX (http, https, quic) EXPOSE 80 diff --git a/images/docs/Dockerfile b/images/docs/Dockerfile index 749132fa..047432d5 100644 --- a/images/docs/Dockerfile +++ b/images/docs/Dockerfile @@ -1,17 +1,20 @@ +# syntax=docker/dockerfile:1 FROM base +# Stage the release APT source so `apt upgrade` pulls future releases. +COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources + # Install nginx and the docs package built by the builder image. The package -# ships content only; serving it is this image's concern. -COPY --from=builder /dist/opensource-docs_*.deb /tmp/debs/ -RUN apt-get update && \ - apt-get install -y nginx /tmp/debs/*.deb && \ - rm -rf /tmp/debs && \ +# ships content only; serving it is this image's concern. Bind-mounted from +# the builder stage so it does not add an image layer. `apt-get update` is +# allowed to fail because the release APT source may 404 during a build (the +# release being built may not exist yet); the install uses cached indices. +RUN --mount=from=builder,source=/dist,target=/dist \ + { apt-get update || true; } && \ + apt-get install -y nginx /dist/opensource-docs_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Stage the release APT source so `apt upgrade` pulls future releases. -COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources - # Replace the default nginx site with our docs config COPY ./images/docs/docs-site.conf /etc/nginx/sites-enabled/default diff --git a/images/manager/Dockerfile b/images/manager/Dockerfile index 18b326aa..17a32758 100644 --- a/images/manager/Dockerfile +++ b/images/manager/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM agent # Default environment for pull-config. The manager runs the app on localhost @@ -5,42 +6,47 @@ FROM agent ENV SITE_ID=1 ENV MANAGER_URL=http://localhost:3000 -# The release APT source is inherited from the agent image. Remove it for the -# duration of this build so `apt update` (PGDG, package installs) is never -# blocked by the release not existing yet; it is re-added at the end. -RUN rm -f /etc/apt/sources.list.d/opensource-server.sources - # Install Postgres 18 from the PGDG repository and enable remote access only # for TLS encrypted connections. Debian creates a self-signed cert for this so # we don't have to make our own. The opensource-server package only suggests # postgresql (a remote database is equally valid); this image opts into a # local one. -RUN apt update && apt -y install postgresql-common \ +# +# The PGDG setup script runs its own strict `apt-get update`, which would fail +# on the inherited release APT source if that release lacks a Packages index +# (e.g. during a build). Temporarily move the source aside for just this layer +# (restored before the layer ends, so it adds no image layer and stays present +# in the final image). +RUN mv /etc/apt/sources.list.d/opensource-server.sources /tmp/oss.sources \ + && apt-get update && apt-get -y install postgresql-common \ && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \ - && apt -y install postgresql-18 \ + && apt-get -y install postgresql-18 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && echo 'hostssl all all 0.0.0.0/0 scram-sha-256' >>/etc/postgresql/18/main/pg_hba.conf \ && echo "listen_addresses = '*'" >>/etc/postgresql/18/main/conf.d/opensource-server.conf \ - && mkdir -p /etc/systemd/system/postgresql@.service.d + && mkdir -p /etc/systemd/system/postgresql@.service.d \ + && mv /tmp/oss.sources /etc/apt/sources.list.d/opensource-server.sources # Proxmox injects a service which regenerates the snakeoil cert. This systemd # override prevents a race condition at boot. It is environment-specific, so # it lives in the image rather than the package. COPY images/manager/wait-proxmox-regenerate-snakeoil.conf /etc/systemd/system/postgresql@.service.d/ -# Install the manager and docs packages built by the builder image. The agent +# First-boot database initialization. This provisions the *local* PostgreSQL +# database, so it is part of this image (which installs PostgreSQL), not the +# opensource-server package — which only suggests postgresql and works with a +# remote database too. +COPY images/manager/container-creator-init.service /usr/lib/systemd/system/container-creator-init.service + +# Install the manager and docs packages built by the builder image (the agent # package is already present from the parent image; apt resolves the -# opensource-server -> opensource-docs dependency. The package postinstall -# enables the manager systemd units, and systemd LogDirectory creates -# /var/log/opensource-server on demand. -COPY --from=builder /dist/opensource-docs_*.deb /dist/opensource-server_*.deb /tmp/debs/ -RUN apt-get update && \ - apt-get install -y /tmp/debs/*.deb && \ - rm -rf /tmp/debs && \ +# opensource-server -> opensource-docs dependency), then enable the init +# service. The package postinstall enables the manager units, and systemd +# LogsDirectory creates /var/log/opensource-server on demand. +RUN --mount=from=builder,source=/dist,target=/dist \ + { apt-get update || true; } && \ + apt-get install -y /dist/opensource-docs_*.deb /dist/opensource-server_*.deb && \ apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Re-add the release APT source (removed at the top) so `apt upgrade` pulls -# future releases of the opensource-* packages automatically. -COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources + rm -rf /var/lib/apt/lists/* && \ + systemctl enable container-creator-init.service || true From 3258885d4868dece08cfe5e52e7209146756943e Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:13:27 -0400 Subject: [PATCH 15/42] Revert compose dev services to built-in tooling Installing make in each slim dev image was ugly. The node, client and zensical services emulate their component make targets (deps, dev-client, dev) directly with npm/uv instead, matching the original approach. Comments cross-reference the equivalent make target. --- compose.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/compose.yml b/compose.yml index 4f8126aa..9790337f 100644 --- a/compose.yml +++ b/compose.yml @@ -32,6 +32,8 @@ services: # node_modules are populated in the user's create-a-container directory. This # is usually handled by the manager's Dockerfile, but we're mounting over that # directory so we need to be sure the user has them. + # + # Mirrors create-a-container's `make deps` (the slim image has no make). node: image: node:24-trixie-slim volumes: @@ -40,8 +42,8 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | - apt-get update && apt-get install -y --no-install-recommends make git - exec make deps + npm ci --omit=dev --no-audit --no-fund + exec npm --prefix client ci --no-audit --no-fund # Watches the React client and rebuilds the production bundle on change. The # Manager LXC inside Proxmox serves create-a-container/client/dist statically @@ -53,18 +55,19 @@ services: # # Like zensical, this is a development convenience and not a hard dependency: # it does an initial build so a dist always exists, then watches for changes. - # The server itself runs inside Proxmox, so this only runs the client watcher - # (create-a-container's `make dev-client`). + # The server itself runs inside Proxmox, so this only runs the client watcher. + # Mirrors create-a-container's `make dev-client` (the slim image has no make). client: image: node:24-trixie-slim volumes: - ./:/opt/opensource-server - working_dir: /opt/opensource-server/create-a-container + working_dir: /opt/opensource-server/create-a-container/client entrypoint: ["/bin/sh", "-c"] command: - | - apt-get update && apt-get install -y --no-install-recommends make git - exec make dev-client + npm ci --no-audit --no-fund + npm run build + exec npm run build:watch # Healthy once an initial bundle exists, so Proxmox can wait for it on a # fresh checkout (where client/dist isn't present yet) before serving. healthcheck: @@ -81,16 +84,14 @@ services: # direct dependency on this because it's meant to be a development convenience # not a hard dependency. The Proxmox service works just fine if the docs are # not getting rebuilt or even if they were never built in the first place. + # + # Mirrors mie-opensource-landing's `make dev` (the uv image has no make). zensical: image: astral/uv:0.11.14-trixie-slim volumes: - ./:/opt/opensource-server working_dir: /opt/opensource-server/mie-opensource-landing - entrypoint: ["/bin/sh", "-c"] - command: - - | - apt-get update && apt-get install -y --no-install-recommends make git - exec make dev + command: uv run --active zensical serve environment: VIRTUAL_ENV: /opt/zensical PROXMOX_URL: https://localhost:8006 From 142ff57fdaf95c4485f3ee947e2e28f96218c19d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:14:26 -0400 Subject: [PATCH 16/42] Update release-pipeline docs for packaging refinements Reflect that container-creator-init lives in the manager image (not the package), the nfpm tree-split that avoids the config/tree collision and the no-rm-rf packaging, and the RUN --mount install with the apt source left in place. --- .../docs/developers/release-pipeline.md | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md index fd25c7ae..47e8f808 100644 --- a/mie-opensource-landing/docs/developers/release-pipeline.md +++ b/mie-opensource-landing/docs/developers/release-pipeline.md @@ -78,30 +78,43 @@ Each component has an `nfpm.yaml` that packages the staged tree (`type: tree`) plus any config files and maintainer scripts. nfpm produces deb, rpm, and apk from the same definition, so `make rpm` and `make apk` also work. -- `opensource-server` ships the three systemd units and enables them via an - nfpm `postinstall` script (`preremove` disables them on real removal). The - log directory is created on demand by the unit's `LogsDirectory`, not shipped - in the package. The logrotate drop-in is a config file. +- `opensource-server` ships the `container-creator` and `job-runner` systemd + units and enables them via an nfpm `postinstall` script (`preremove` disables + them on real removal). The log directory is created on demand by the unit's + `LogsDirectory`, not shipped in the package. The logrotate drop-in is a config + file. The `container-creator-init` unit (which provisions a *local* + PostgreSQL) is **not** in the package — it is part of the manager image, since + the package only suggests postgresql and works with a remote database too. - `opensource-agent` ships the pull-config instances and cron schedule as config files so admin customizations survive upgrades. - `opensource-docs` ships content only. +Each `nfpm.yaml` declares its `/etc` files explicitly as `config` and packages +the rest of the staged tree per top-level directory (`/opt`, `/usr`, `/var`). +This keeps conffile tagging without any file appearing in both a `tree` and a +`config` entry (which nfpm rejects), so `make install` and `make package` never +fight. `make package` reuses the `DESTDIR` install in `build-root` rather than +mutating it — run `make clean` first for a guaranteed-pristine package. + ## Container images The [`builder`](https://github.com/mieweb/opensource-server/blob/main/images/builder/Dockerfile) image runs `make deb` (Node.js, uv, and nfpm) to produce all three packages, exported as an artifact-only stage. The `docs`, `agent`, and `manager` images install those packages via a Docker Bake `builder` context instead of copying -the repository: +the repository. The packages are bind-mounted (no extra image layer): ```dockerfile -COPY --from=builder /dist/opensource-agent_*.deb /tmp/debs/ -RUN apt-get update && apt-get install -y /tmp/debs/*.deb && rm -rf /tmp/debs +RUN --mount=from=builder,source=/dist,target=/dist \ + { apt-get update || true; } && \ + apt-get install -y /dist/opensource-agent_*.deb ``` Each leaf image also stages the release APT source (`/etc/apt/sources.list.d/opensource-server.sources`) so a running container -picks up future releases with `apt upgrade`. +picks up future releases with `apt upgrade`. The source stays in place for the +whole build; a missing release is a non-fatal `apt update` warning (hence the +`|| true`). ## Releases From 3829e6eb4527038416267d1e6b315209df7a7b50 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:22:50 -0400 Subject: [PATCH 17/42] pull-config: drop config tagging; stage to DESTDIR, not hard-coded build-root - The pull-config engine and instances are program code, not configuration (runtime config comes from /etc/environment, which is not packaged), so nothing is tagged as a config file. - nfpm.yaml no longer hard-codes ./build-root: nfpm runs from the staging root, so content sources are relative (etc, opt, var) and work for any DESTDIR. Packaging stages into a dedicated .pkg-root (never a user-supplied DESTDIR); make clean removes only that staging dir. --- pull-config/.gitignore | 2 +- pull-config/Makefile | 22 +++++++++---------- pull-config/nfpm.yaml | 49 +++++++++++------------------------------- 3 files changed, 23 insertions(+), 50 deletions(-) diff --git a/pull-config/.gitignore b/pull-config/.gitignore index af647801..d24dc6b2 100644 --- a/pull-config/.gitignore +++ b/pull-config/.gitignore @@ -1,5 +1,5 @@ # packaging build artifacts -/build-root/ +/.pkg-root/ /*.deb /*.rpm /*.apk diff --git a/pull-config/Makefile b/pull-config/Makefile index c7d1887a..baedb36d 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -11,7 +11,8 @@ DESTDIR ?= / # v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') -PKG_NAME := opensource-agent +# Dedicated staging directory for packaging (never a user-supplied DESTDIR). +STAGE := $(CURDIR)/.pkg-root INSTALL := install INSTALL_DATA := $(INSTALL) -m 0644 @@ -31,7 +32,7 @@ dev: deps install: build # pull-config engine — instances exec this absolute path. $(INSTALL) -D -m 0755 bin/pull-config $(DESTDIR)$(PREFIX)/pull-config/bin/pull-config - # pull-config instances (admin-customizable) + cron schedule. + # pull-config instances (program code) + cron schedule. $(INSTALL) -d $(DESTDIR)/etc/pull-config.d $(INSTALL_PROG) etc/pull-config.d/* $(DESTDIR)/etc/pull-config.d/ $(INSTALL) -D -m 0644 etc/cron.d/pull-config $(DESTDIR)/etc/cron.d/pull-config @@ -41,16 +42,13 @@ install: build # Forward-auth cache directory used by the manager-rendered nginx config. $(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache -# Package the staged build-root with nfpm. /etc files are declared as config -# in nfpm.yaml and the rest is packaged per top-level directory, so staging and -# packaging never fight. Run `make clean` first for a pristine package. +# Stage into a clean dedicated root and package it. nfpm runs from the staging +# root so the content sources in nfpm.yaml are relative to it. PACKAGER ?= deb -package: install-buildroot - VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) - -.PHONY: install-buildroot -install-buildroot: - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) +package: + rm -rf $(STAGE) + $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) + cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb @@ -60,4 +58,4 @@ apk: $(MAKE) package PACKAGER=apk clean: - rm -rf build-root + rm -rf $(STAGE) diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml index dec58357..8a8aa9c1 100644 --- a/pull-config/nfpm.yaml +++ b/pull-config/nfpm.yaml @@ -1,11 +1,11 @@ # nfpm configuration for the opensource-agent package (pull-config). -# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / -# `make apk`, which stage the component into ./build-root first. +# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into +# a DESTDIR and run nfpm from there, so content sources are relative to the +# staging root (DESTDIR) and VERSION is passed via the environment. # -# /etc files are declared explicitly as config so admin customizations survive -# upgrades; the rest of the staged tree is packaged per top-level directory. -# This avoids any overlap between the tree and the config entries, so -# `make install` and `make package` never fight over conffile tagging. +# The pull-config engine and instance scripts are program code, not +# configuration — runtime configuration is supplied via /etc/environment, which +# is not part of this package. So nothing here is tagged as a config file. # # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-agent @@ -34,39 +34,14 @@ depends: - curl - cron +# Sources are relative to the staging root (nfpm runs from DESTDIR). contents: - # Config files first, declared explicitly so they are tagged as conffiles. - - src: ./build-root/etc/cron.d/pull-config - dst: /etc/cron.d/pull-config - type: config - - src: ./build-root/etc/pull-config.d/nginx - dst: /etc/pull-config.d/nginx - type: config - file_info: { mode: 0755 } - - src: ./build-root/etc/pull-config.d/dnsmasq-conf - dst: /etc/pull-config.d/dnsmasq-conf - type: config - file_info: { mode: 0755 } - - src: ./build-root/etc/pull-config.d/dnsmasq-dhcp-hosts - dst: /etc/pull-config.d/dnsmasq-dhcp-hosts - type: config - file_info: { mode: 0755 } - - src: ./build-root/etc/pull-config.d/dnsmasq-dhcp-opts - dst: /etc/pull-config.d/dnsmasq-dhcp-opts - type: config - file_info: { mode: 0755 } - - src: ./build-root/etc/pull-config.d/dnsmasq-hosts - dst: /etc/pull-config.d/dnsmasq-hosts - type: config - file_info: { mode: 0755 } - - src: ./build-root/etc/pull-config.d/dnsmasq-servers - dst: /etc/pull-config.d/dnsmasq-servers - type: config - file_info: { mode: 0755 } - # Everything else, packaged per top-level directory (no /etc overlap). - - src: ./build-root/opt + - src: etc + dst: /etc + type: tree + - src: opt dst: /opt type: tree - - src: ./build-root/var + - src: var dst: /var type: tree From 59762938794ab5697a334783922163d716effcb0 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:28:56 -0400 Subject: [PATCH 18/42] Stage to DESTDIR instead of hard-coded build-root in docs and server nfpm now runs from a dedicated .pkg-root staging dir, so nfpm.yaml content sources are relative (opt, usr, etc) and support any DESTDIR without hard-coding build-root. Removed the install-buildroot helper target. For opensource-server, maintainer scripts are staged under .pkg-root/.nfpm/ (outside the packaged contents) so they resolve relative to nfpm's working directory. make clean only removes the internal staging dir, never a user-supplied DESTDIR. --- create-a-container/.gitignore | 2 +- create-a-container/Makefile | 31 +++++++++++++++++-------------- create-a-container/nfpm.yaml | 21 ++++++++++++--------- mie-opensource-landing/.gitignore | 2 +- mie-opensource-landing/Makefile | 19 ++++++++++--------- mie-opensource-landing/nfpm.yaml | 10 ++++++---- 6 files changed, 47 insertions(+), 38 deletions(-) diff --git a/create-a-container/.gitignore b/create-a-container/.gitignore index 8824345a..d1961928 100644 --- a/create-a-container/.gitignore +++ b/create-a-container/.gitignore @@ -3,7 +3,7 @@ node_modules data/ # packaging build artifacts -/build-root/ +/.pkg-root/ /*.deb /*.rpm /*.apk diff --git a/create-a-container/Makefile b/create-a-container/Makefile index f4dca307..9d161397 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -5,15 +5,15 @@ # build (default) compile the component; depends on deps # install stage built files into DESTDIR; depends on build # dev run the development watch loop; depends on deps -# deb/rpm/apk package the staged build-root with nfpm; depend on install +# deb/rpm/apk package the staged tree with nfpm; depend on install # # PREFIX is the vendor install prefix every runtime reference in the project # expects (systemd units, the manager-rendered nginx config). DESTDIR is the # staging root. VERSION is derived from git but can be overridden. # -# Packaging stages into ./build-root (a normal DESTDIR install) and points -# nfpm at it. The staged tree is reused across packagers; run `make clean` -# first for a guaranteed-pristine package. +# Packaging stages into a dedicated .pkg-root (never a user-supplied DESTDIR) +# and runs nfpm from there, so nfpm.yaml's content sources are relative to the +# staging root and work for any DESTDIR. .DEFAULT_GOAL := build @@ -25,6 +25,9 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system +# Dedicated staging directory for packaging (never a user-supplied DESTDIR). +STAGE := $(CURDIR)/.pkg-root + INSTALL := install INSTALL_DATA := $(INSTALL) -m 0644 @@ -80,16 +83,16 @@ install: build $(INSTALL) -d $(DESTDIR)/etc/logrotate.d $(INSTALL_DATA) nfpm/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server -# Package the staged build-root with nfpm. nfpm trees the non-/etc paths and -# declares /etc files as config explicitly (see nfpm.yaml), so staging and -# packaging never fight over conffile tagging. +# Package the staged tree with nfpm. nfpm runs from the staging root so the +# content sources in nfpm.yaml are relative to it; the maintainer scripts are +# staged under .nfpm/ (outside the packaged contents) for the same reason. PACKAGER ?= deb -package: install-buildroot - VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) - -.PHONY: install-buildroot -install-buildroot: - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) +package: + rm -rf $(STAGE) + $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) + $(INSTALL) -d $(STAGE)/.nfpm + $(INSTALL) -m 0755 nfpm/postinstall.sh nfpm/preremove.sh $(STAGE)/.nfpm/ + cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb @@ -99,4 +102,4 @@ apk: $(MAKE) package PACKAGER=apk clean: - rm -rf build-root node_modules client/node_modules client/dist + rm -rf $(STAGE) node_modules client/node_modules client/dist diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml index 38198ae8..2e07f930 100644 --- a/create-a-container/nfpm.yaml +++ b/create-a-container/nfpm.yaml @@ -1,12 +1,12 @@ # nfpm configuration for the opensource-server package (create-a-container). -# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / -# `make apk`, which stage the component into ./build-root first and pass -# VERSION through the environment. +# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into +# a staging root and run nfpm from there, so content/script sources are +# relative to the staging root and VERSION is passed via the environment. # # /etc files are declared explicitly as config so admin edits survive upgrades; # the rest of the staged tree is packaged per top-level directory. This avoids # any overlap between the tree and the config entries (nfpm rejects a file that -# appears in both), so `make install` and `make package` never fight. +# appears in both), so install and packaging never fight. # # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-server @@ -38,19 +38,22 @@ depends: suggests: - postgresql +# Sources are relative to the staging root (nfpm runs from DESTDIR). contents: # Config files first, declared explicitly so they are tagged as conffiles. - - src: ./build-root/etc/logrotate.d/opensource-server + - src: etc/logrotate.d/opensource-server dst: /etc/logrotate.d/opensource-server type: config # Everything else, packaged per top-level directory (no /etc overlap). - - src: ./build-root/opt + - src: opt dst: /opt type: tree - - src: ./build-root/usr + - src: usr dst: /usr type: tree +# Maintainer scripts are staged into the staging root under .nfpm/ (outside +# the packaged contents) so they resolve relative to nfpm's working directory. scripts: - postinstall: ./nfpm/postinstall.sh - preremove: ./nfpm/preremove.sh + postinstall: .nfpm/postinstall.sh + preremove: .nfpm/preremove.sh diff --git a/mie-opensource-landing/.gitignore b/mie-opensource-landing/.gitignore index fa091fa3..0175df66 100644 --- a/mie-opensource-landing/.gitignore +++ b/mie-opensource-landing/.gitignore @@ -16,7 +16,7 @@ __pycache__/ tmp/ # packaging build artifacts -/build-root/ +/.pkg-root/ /*.deb /*.rpm /*.apk diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index fd3dadb7..d3464feb 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -13,6 +13,9 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site +# Dedicated staging directory for packaging (never a user-supplied DESTDIR). +STAGE := $(CURDIR)/.pkg-root + .PHONY: deps build dev install deb rpm apk package clean deps: @@ -30,15 +33,13 @@ install: build install -d $(SITE_DEST) cp -a site/. $(SITE_DEST)/ -# Package the staged build-root with nfpm. The staged tree is reused across -# packagers; run `make clean` first for a guaranteed-pristine package. +# Stage into a clean dedicated root and package it. nfpm runs from the staging +# root so the content sources in nfpm.yaml are relative to it. PACKAGER ?= deb -package: install-buildroot - VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) - -.PHONY: install-buildroot -install-buildroot: - $(MAKE) install DESTDIR=$(CURDIR)/build-root PREFIX=$(PREFIX) +package: + rm -rf $(STAGE) + $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) + cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb @@ -48,4 +49,4 @@ apk: $(MAKE) package PACKAGER=apk clean: - rm -rf build-root site .venv + rm -rf $(STAGE) site .venv diff --git a/mie-opensource-landing/nfpm.yaml b/mie-opensource-landing/nfpm.yaml index fc20ac67..129483c3 100644 --- a/mie-opensource-landing/nfpm.yaml +++ b/mie-opensource-landing/nfpm.yaml @@ -1,6 +1,7 @@ # nfpm configuration for the opensource-docs package (mie-opensource-landing). -# See https://nfpm.goreleaser.com. Packaged via `make deb` / `make rpm` / -# `make apk`, which stage the prebuilt site into ./build-root first. +# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the prebuilt site +# into a staging root and run nfpm from there, so content sources are relative +# to the staging root and VERSION is passed via the environment. # # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-docs @@ -21,7 +22,8 @@ license: "Apache-2.0" suggests: - nginx +# Sources are relative to the staging root (nfpm runs from DESTDIR). contents: - - src: ./build-root - dst: / + - src: opt + dst: /opt type: tree From 023bcec730e12889b7cd10fce34e4ecbf8b65c58 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:53:17 -0400 Subject: [PATCH 19/42] Make docs and agent nfpm.yaml DRY via expand:true; rename staging dir Use nfpm's expand:true on content entries to interpolate ${DESTDIR} in the source paths, so nfpm.yaml no longer hard-codes the staging path and nfpm runs from the component directory (not the staging root). Rename the dedicated packaging staging dir to .nfpm/buildroot, descriptive of the tooling. --- mie-opensource-landing/.gitignore | 2 +- mie-opensource-landing/Makefile | 8 ++++---- mie-opensource-landing/nfpm.yaml | 9 +++++---- pull-config/.gitignore | 2 +- pull-config/Makefile | 8 ++++---- pull-config/nfpm.yaml | 15 +++++++++------ 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/mie-opensource-landing/.gitignore b/mie-opensource-landing/.gitignore index 0175df66..e9ac63b8 100644 --- a/mie-opensource-landing/.gitignore +++ b/mie-opensource-landing/.gitignore @@ -16,7 +16,7 @@ __pycache__/ tmp/ # packaging build artifacts -/.pkg-root/ +/.nfpm/buildroot/ /*.deb /*.rpm /*.apk diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index d3464feb..de16afdf 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -14,7 +14,7 @@ PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site # Dedicated staging directory for packaging (never a user-supplied DESTDIR). -STAGE := $(CURDIR)/.pkg-root +STAGE := $(CURDIR)/.nfpm/buildroot .PHONY: deps build dev install deb rpm apk package clean @@ -33,13 +33,13 @@ install: build install -d $(SITE_DEST) cp -a site/. $(SITE_DEST)/ -# Stage into a clean dedicated root and package it. nfpm runs from the staging -# root so the content sources in nfpm.yaml are relative to it. +# Stage into a dedicated .nfpm/buildroot and package it; nfpm.yaml interpolates the +# staging path via ${DESTDIR}. PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/mie-opensource-landing/nfpm.yaml b/mie-opensource-landing/nfpm.yaml index 129483c3..9caf81f2 100644 --- a/mie-opensource-landing/nfpm.yaml +++ b/mie-opensource-landing/nfpm.yaml @@ -1,7 +1,8 @@ # nfpm configuration for the opensource-docs package (mie-opensource-landing). # See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the prebuilt site -# into a staging root and run nfpm from there, so content sources are relative -# to the staging root and VERSION is passed via the environment. +# into a staging root (DESTDIR) and run nfpm from the component directory; the +# content source below interpolates ${DESTDIR} via `expand: true`, and VERSION +# is passed via the environment. # # yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json name: opensource-docs @@ -22,8 +23,8 @@ license: "Apache-2.0" suggests: - nginx -# Sources are relative to the staging root (nfpm runs from DESTDIR). contents: - - src: opt + - src: ${DESTDIR}/opt dst: /opt type: tree + expand: true diff --git a/pull-config/.gitignore b/pull-config/.gitignore index d24dc6b2..9a68f9d0 100644 --- a/pull-config/.gitignore +++ b/pull-config/.gitignore @@ -1,5 +1,5 @@ # packaging build artifacts -/.pkg-root/ +/.nfpm/buildroot/ /*.deb /*.rpm /*.apk diff --git a/pull-config/Makefile b/pull-config/Makefile index baedb36d..695e3af6 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -12,7 +12,7 @@ DESTDIR ?= / VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') # Dedicated staging directory for packaging (never a user-supplied DESTDIR). -STAGE := $(CURDIR)/.pkg-root +STAGE := $(CURDIR)/.nfpm/buildroot INSTALL := install INSTALL_DATA := $(INSTALL) -m 0644 @@ -42,13 +42,13 @@ install: build # Forward-auth cache directory used by the manager-rendered nginx config. $(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache -# Stage into a clean dedicated root and package it. nfpm runs from the staging -# root so the content sources in nfpm.yaml are relative to it. +# Stage into a dedicated .nfpm/buildroot and package it; nfpm.yaml interpolates the +# staging path via ${DESTDIR}. PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml index 8a8aa9c1..05a50310 100644 --- a/pull-config/nfpm.yaml +++ b/pull-config/nfpm.yaml @@ -1,7 +1,8 @@ # nfpm configuration for the opensource-agent package (pull-config). # See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into -# a DESTDIR and run nfpm from there, so content sources are relative to the -# staging root (DESTDIR) and VERSION is passed via the environment. +# a staging root (DESTDIR) and run nfpm from the component directory; the +# content sources below interpolate ${DESTDIR} via `expand: true`, and VERSION +# is passed via the environment. # # The pull-config engine and instance scripts are program code, not # configuration — runtime configuration is supplied via /etc/environment, which @@ -34,14 +35,16 @@ depends: - curl - cron -# Sources are relative to the staging root (nfpm runs from DESTDIR). contents: - - src: etc + - src: ${DESTDIR}/etc dst: /etc type: tree - - src: opt + expand: true + - src: ${DESTDIR}/opt dst: /opt type: tree - - src: var + expand: true + - src: ${DESTDIR}/var dst: /var type: tree + expand: true From a9cc2ba827e5e2bd9d2bcb529f794995845cad93 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:53:27 -0400 Subject: [PATCH 20/42] create-a-container: move systemd units and maintainer scripts to contrib/ Group the distro-integration files under contrib/ (the conventional location): contrib/systemd/{container-creator,job-runner}.service, contrib/{postinstall,preremove}.sh and contrib/opensource-server.logrotate. Update the Makefile install paths and nfpm.yaml script references accordingly. Also adopt expand:true for ${DESTDIR} interpolation and the .nfpm/buildroot staging dir, and drop the '|| true' guards from the maintainer scripts in favor of detecting systemctl availability and whether systemd is running. --- create-a-container/.gitignore | 2 +- create-a-container/Makefile | 26 +++++++++---------- .../opensource-server.logrotate | 0 create-a-container/contrib/postinstall.sh | 20 ++++++++++++++ .../{nfpm => contrib}/preremove.sh | 13 +++++----- .../systemd/container-creator.service | 0 .../{ => contrib}/systemd/job-runner.service | 0 create-a-container/nfpm.yaml | 25 +++++++++++------- create-a-container/nfpm/postinstall.sh | 24 ----------------- 9 files changed, 55 insertions(+), 55 deletions(-) rename create-a-container/{nfpm => contrib}/opensource-server.logrotate (100%) create mode 100755 create-a-container/contrib/postinstall.sh rename create-a-container/{nfpm => contrib}/preremove.sh (66%) rename create-a-container/{ => contrib}/systemd/container-creator.service (100%) rename create-a-container/{ => contrib}/systemd/job-runner.service (100%) delete mode 100755 create-a-container/nfpm/postinstall.sh diff --git a/create-a-container/.gitignore b/create-a-container/.gitignore index d1961928..3594a5e9 100644 --- a/create-a-container/.gitignore +++ b/create-a-container/.gitignore @@ -3,7 +3,7 @@ node_modules data/ # packaging build artifacts -/.pkg-root/ +/.nfpm/buildroot/ /*.deb /*.rpm /*.apk diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 9d161397..55d4eeab 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -11,9 +11,10 @@ # expects (systemd units, the manager-rendered nginx config). DESTDIR is the # staging root. VERSION is derived from git but can be overridden. # -# Packaging stages into a dedicated .pkg-root (never a user-supplied DESTDIR) -# and runs nfpm from there, so nfpm.yaml's content sources are relative to the -# staging root and work for any DESTDIR. +# Packaging stages into a dedicated .nfpm/buildroot (never a user-supplied DESTDIR) +# and runs nfpm from the component directory; nfpm.yaml interpolates the +# staging path via ${DESTDIR} (expand: true), so it stays DRY and works for +# any DESTDIR. .DEFAULT_GOAL := build @@ -26,7 +27,7 @@ DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system # Dedicated staging directory for packaging (never a user-supplied DESTDIR). -STAGE := $(CURDIR)/.pkg-root +STAGE := $(CURDIR)/.nfpm/buildroot INSTALL := install INSTALL_DATA := $(INSTALL) -m 0644 @@ -77,22 +78,21 @@ install: build cp -a client/dist $(DESTBIN)/client/ # systemd units (enabled by the package postinstall script) $(INSTALL) -d $(UNIT_DIR) - $(INSTALL_DATA) systemd/container-creator.service $(UNIT_DIR)/ - $(INSTALL_DATA) systemd/job-runner.service $(UNIT_DIR)/ + $(INSTALL_DATA) contrib/systemd/container-creator.service $(UNIT_DIR)/ + $(INSTALL_DATA) contrib/systemd/job-runner.service $(UNIT_DIR)/ # logrotate for the morgan access log $(INSTALL) -d $(DESTDIR)/etc/logrotate.d - $(INSTALL_DATA) nfpm/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server + $(INSTALL_DATA) contrib/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server -# Package the staged tree with nfpm. nfpm runs from the staging root so the -# content sources in nfpm.yaml are relative to it; the maintainer scripts are -# staged under .nfpm/ (outside the packaged contents) for the same reason. +# Package the staged tree with nfpm. Files are staged into a dedicated +# .nfpm/buildroot (never a user-supplied DESTDIR); nfpm.yaml interpolates that +# path via ${DESTDIR}, so nfpm runs from the component directory (where its +# contrib/ maintainer scripts live) and packaging never mutates the staged tree. PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - $(INSTALL) -d $(STAGE)/.nfpm - $(INSTALL) -m 0755 nfpm/postinstall.sh nfpm/preremove.sh $(STAGE)/.nfpm/ - cd $(STAGE) && VERSION=$(VERSION) nfpm package -f $(CURDIR)/nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/create-a-container/nfpm/opensource-server.logrotate b/create-a-container/contrib/opensource-server.logrotate similarity index 100% rename from create-a-container/nfpm/opensource-server.logrotate rename to create-a-container/contrib/opensource-server.logrotate diff --git a/create-a-container/contrib/postinstall.sh b/create-a-container/contrib/postinstall.sh new file mode 100755 index 00000000..7071155b --- /dev/null +++ b/create-a-container/contrib/postinstall.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# postinstall for opensource-server: enable the manager systemd units. +set -e + +UNITS="container-creator.service job-runner.service" + +# Nothing to do without systemctl (non-systemd container/chroot). +command -v systemctl >/dev/null 2>&1 || exit 0 + +# `systemctl enable` only creates static symlinks, so it works during an image +# build too (no running systemd required) — the units come up on first boot. +systemctl enable $UNITS + +# daemon-reload and restart need a running systemd; skip them at build time. +if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl restart $UNITS +fi + +exit 0 diff --git a/create-a-container/nfpm/preremove.sh b/create-a-container/contrib/preremove.sh similarity index 66% rename from create-a-container/nfpm/preremove.sh rename to create-a-container/contrib/preremove.sh index edfc8370..e9012463 100755 --- a/create-a-container/nfpm/preremove.sh +++ b/create-a-container/contrib/preremove.sh @@ -13,14 +13,13 @@ case "${1:-}" in ;; esac +# Nothing to do without systemctl (non-systemd container/chroot). +command -v systemctl >/dev/null 2>&1 || exit 0 + +# Stopping needs a running systemd; disabling (symlink removal) does not. if [ -d /run/systemd/system ]; then - for unit in $UNITS; do - systemctl stop "$unit" >/dev/null 2>&1 || true - done + systemctl stop $UNITS fi - -for unit in $UNITS; do - systemctl disable "$unit" >/dev/null 2>&1 || true -done +systemctl disable $UNITS exit 0 diff --git a/create-a-container/systemd/container-creator.service b/create-a-container/contrib/systemd/container-creator.service similarity index 100% rename from create-a-container/systemd/container-creator.service rename to create-a-container/contrib/systemd/container-creator.service diff --git a/create-a-container/systemd/job-runner.service b/create-a-container/contrib/systemd/job-runner.service similarity index 100% rename from create-a-container/systemd/job-runner.service rename to create-a-container/contrib/systemd/job-runner.service diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml index 2e07f930..59e0b304 100644 --- a/create-a-container/nfpm.yaml +++ b/create-a-container/nfpm.yaml @@ -1,7 +1,8 @@ # nfpm configuration for the opensource-server package (create-a-container). # See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into -# a staging root and run nfpm from there, so content/script sources are -# relative to the staging root and VERSION is passed via the environment. +# a staging root (DESTDIR) and run nfpm from the component directory; the +# content sources below interpolate ${DESTDIR} via `expand: true`, and VERSION +# is passed through the environment. # # /etc files are declared explicitly as config so admin edits survive upgrades; # the rest of the staged tree is packaged per top-level directory. This avoids @@ -38,22 +39,26 @@ depends: suggests: - postgresql -# Sources are relative to the staging root (nfpm runs from DESTDIR). +# Sources are taken from the staging root via ${DESTDIR} (expand: true tells +# nfpm to interpolate environment variables in the path). contents: # Config files first, declared explicitly so they are tagged as conffiles. - - src: etc/logrotate.d/opensource-server + - src: ${DESTDIR}/etc/logrotate.d/opensource-server dst: /etc/logrotate.d/opensource-server type: config + expand: true # Everything else, packaged per top-level directory (no /etc overlap). - - src: opt + - src: ${DESTDIR}/opt dst: /opt type: tree - - src: usr + expand: true + - src: ${DESTDIR}/usr dst: /usr type: tree + expand: true -# Maintainer scripts are staged into the staging root under .nfpm/ (outside -# the packaged contents) so they resolve relative to nfpm's working directory. +# Maintainer scripts are referenced relative to nfpm's working directory (the +# component directory, where `make` runs nfpm). scripts: - postinstall: .nfpm/postinstall.sh - preremove: .nfpm/preremove.sh + postinstall: ./contrib/postinstall.sh + preremove: ./contrib/preremove.sh diff --git a/create-a-container/nfpm/postinstall.sh b/create-a-container/nfpm/postinstall.sh deleted file mode 100755 index 9b31237a..00000000 --- a/create-a-container/nfpm/postinstall.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# postinstall for opensource-server: enable the manager systemd units. -set -e - -UNITS="container-creator.service job-runner.service" - -if [ -d /run/systemd/system ]; then - systemctl daemon-reload || true -fi - -# Enable on first install and upgrade so the services come up at boot. Use -# 'enable' (not '--now') so package installation never starts services in a -# build/chroot context; they start on the next boot or via the image. -for unit in $UNITS; do - systemctl enable "$unit" >/dev/null 2>&1 || true -done - -# If systemd is running, (re)start the long-running services so an upgrade -# picks up new code. -if [ -d /run/systemd/system ]; then - systemctl restart $UNITS || true -fi - -exit 0 From 9152ca0274e0d2f098f6b6ce2d515bec1891ce0d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 13:53:40 -0400 Subject: [PATCH 21/42] Remove apt-get update guards; rely on flat-repo Packages on every release Drop the '|| true' guards and the manager's source move-aside dance. Now that every release carries flat APT repository metadata (an empty Packages index suffices for the bootstrap release), apt-get update against the release source succeeds throughout the image build, so the release source can stay in place and failures are surfaced rather than ignored. Also drop the redundant '|| true' on systemctl enable, which succeeds at build time by creating static symlinks. --- images/agent/Dockerfile | 12 +++++------- images/docs/Dockerfile | 6 ++---- images/manager/Dockerfile | 16 ++++------------ 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile index 92690112..bd5d0863 100644 --- a/images/agent/Dockerfile +++ b/images/agent/Dockerfile @@ -2,19 +2,17 @@ FROM nodejs # Stage the release APT source so `apt upgrade` pulls future releases of the -# opensource-* packages automatically. A missing release (e.g. during image -# builds) is a non-fatal `apt update` warning, so the source can live here for -# the whole build. +# opensource-* packages automatically. Every release (including the bootstrap +# one) carries flat-repo metadata (an empty Packages index is enough), so +# `apt-get update` against it succeeds throughout the build. COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources # Install the agent package (and its dependencies: nginx with stream + # ModSecurity modules, dnsmasq, cron, ...) built by the builder image. The # package is bind-mounted from the builder stage rather than copied so it does -# not add an image layer. `apt-get update` is allowed to fail because the -# release APT source may 404 during a build (the release being built may not -# exist yet); the install uses cached indices. +# not add an image layer. RUN --mount=from=builder,source=/dist,target=/dist \ - { apt-get update || true; } && \ + apt-get update && \ apt-get install -y /dist/opensource-agent_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/images/docs/Dockerfile b/images/docs/Dockerfile index 047432d5..301c7fee 100644 --- a/images/docs/Dockerfile +++ b/images/docs/Dockerfile @@ -6,11 +6,9 @@ COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server. # Install nginx and the docs package built by the builder image. The package # ships content only; serving it is this image's concern. Bind-mounted from -# the builder stage so it does not add an image layer. `apt-get update` is -# allowed to fail because the release APT source may 404 during a build (the -# release being built may not exist yet); the install uses cached indices. +# the builder stage so it does not add an image layer. RUN --mount=from=builder,source=/dist,target=/dist \ - { apt-get update || true; } && \ + apt-get update && \ apt-get install -y nginx /dist/opensource-docs_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/images/manager/Dockerfile b/images/manager/Dockerfile index 17a32758..e80a6c4a 100644 --- a/images/manager/Dockerfile +++ b/images/manager/Dockerfile @@ -11,22 +11,14 @@ ENV MANAGER_URL=http://localhost:3000 # we don't have to make our own. The opensource-server package only suggests # postgresql (a remote database is equally valid); this image opts into a # local one. -# -# The PGDG setup script runs its own strict `apt-get update`, which would fail -# on the inherited release APT source if that release lacks a Packages index -# (e.g. during a build). Temporarily move the source aside for just this layer -# (restored before the layer ends, so it adds no image layer and stays present -# in the final image). -RUN mv /etc/apt/sources.list.d/opensource-server.sources /tmp/oss.sources \ - && apt-get update && apt-get -y install postgresql-common \ +RUN apt-get update && apt-get -y install postgresql-common \ && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \ && apt-get -y install postgresql-18 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && echo 'hostssl all all 0.0.0.0/0 scram-sha-256' >>/etc/postgresql/18/main/pg_hba.conf \ && echo "listen_addresses = '*'" >>/etc/postgresql/18/main/conf.d/opensource-server.conf \ - && mkdir -p /etc/systemd/system/postgresql@.service.d \ - && mv /tmp/oss.sources /etc/apt/sources.list.d/opensource-server.sources + && mkdir -p /etc/systemd/system/postgresql@.service.d # Proxmox injects a service which regenerates the snakeoil cert. This systemd # override prevents a race condition at boot. It is environment-specific, so @@ -45,8 +37,8 @@ COPY images/manager/container-creator-init.service /usr/lib/systemd/system/conta # service. The package postinstall enables the manager units, and systemd # LogsDirectory creates /var/log/opensource-server on demand. RUN --mount=from=builder,source=/dist,target=/dist \ - { apt-get update || true; } && \ + apt-get update && \ apt-get install -y /dist/opensource-docs_*.deb /dist/opensource-server_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ - systemctl enable container-creator-init.service || true + systemctl enable container-creator-init.service From 1945952bcfd309131ea692182b16c5092a68b285 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 14:09:50 -0400 Subject: [PATCH 22/42] make clean: remove built packages Each component's clean target now also removes its built *.deb/*.rpm/*.apk artifacts, and the top-level clean removes the dist/ collection directory in addition to delegating to the components. --- Makefile | 11 ++++++++++- create-a-container/Makefile | 1 + mie-opensource-landing/Makefile | 1 + pull-config/Makefile | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0ef3203e..81d37ece 100644 --- a/Makefile +++ b/Makefile @@ -23,12 +23,21 @@ MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \ .PHONY: deps build install dev deb rpm apk clean $(COMPONENTS) -deps build install clean: +deps build install: @for c in $(COMPONENTS); do \ echo "==> $$c: $@"; \ $(MAKE) -C $$c $@ $(MAKE_VARS) || exit $$?; \ done +# Clean each component (which removes its built packages) and the dist/ +# collection directory. +clean: + @for c in $(COMPONENTS); do \ + echo "==> $$c: clean"; \ + $(MAKE) -C $$c clean $(MAKE_VARS) || exit $$?; \ + done + rm -rf dist + # Package every component, then collect the artifacts into ./dist. deb rpm apk: @mkdir -p dist diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 55d4eeab..03059843 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -103,3 +103,4 @@ apk: clean: rm -rf $(STAGE) node_modules client/node_modules client/dist + rm -f *.deb *.rpm *.apk diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index de16afdf..eabd9151 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -50,3 +50,4 @@ apk: clean: rm -rf $(STAGE) site .venv + rm -f *.deb *.rpm *.apk diff --git a/pull-config/Makefile b/pull-config/Makefile index 695e3af6..184ab71d 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -59,3 +59,4 @@ apk: clean: rm -rf $(STAGE) + rm -f *.deb *.rpm *.apk From d5806ca71d0f5f376e94148972a97524fde01e78 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 14:33:23 -0400 Subject: [PATCH 23/42] Fix package version on tagless/shallow checkouts The CI image build failed with 'version number does not start with digit' because a shallow checkout has no tags, so 'git describe --tags --always' returned a bare commit SHA which is not a valid Debian version. - VERSION now falls back to 0.0.0+g when no tag is reachable, and only uses the tag-derived form when 'git describe --tags' succeeds, so the version is always digit-leading and valid. - build-images.yml checks out with fetch-depth: 0 so the builder image's git describe sees tags and produces the proper 2026.6.2+N.gSHA version (release.yml already did this). --- .github/workflows/build-images.yml | 4 ++++ create-a-container/Makefile | 6 ++++-- mie-opensource-landing/Makefile | 6 ++++-- pull-config/Makefile | 6 ++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 3451309c..9d058d9b 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -50,6 +50,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + # Full history + tags so the builder image's `git describe` derives a + # proper package version (otherwise a shallow checkout yields a bare + # SHA, which is not a valid Debian version). + fetch-depth: 0 persist-credentials: false - name: Set up Docker Buildx diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 03059843..aba4d90f 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -20,8 +20,10 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 -VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; +# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, +# digit-leading package version). +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index eabd9151..04ca5b7e 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -7,8 +7,10 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 -VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; +# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, +# digit-leading package version). +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site diff --git a/pull-config/Makefile b/pull-config/Makefile index 184ab71d..714ca71e 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -8,8 +8,10 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 -VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/') +# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; +# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, +# digit-leading package version). +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) # Dedicated staging directory for packaging (never a user-supplied DESTDIR). STAGE := $(CURDIR)/.nfpm/buildroot From 896a497eb1a47d89ee47071c4c6c0ecbee0b4371 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 14:43:32 -0400 Subject: [PATCH 24/42] Trigger release workflow from a published release; upload assets to it The release workflow no longer creates or mutates the GitHub release. It runs on 'release: published' (full or prerelease), checks out the release tag, builds the packages, and uploads the debs + flat-repo metadata to the triggering release via 'gh release upload --clobber'. This removes the prerelease race entirely: create the release (choosing full vs prerelease) in GitHub first, then the action just attaches assets. workflow_dispatch still builds packages for a manual test but skips the upload. Docs updated accordingly. --- .github/workflows/release.yml | 40 +++++++++++-------- .../docs/developers/release-pipeline.md | 15 ++++--- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b59465ad..616af1b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,20 @@ name: Release Packages -# On a vX.Y.Z tag, build the three Debian packages and attach them to the -# GitHub release together with flat APT repository metadata (Packages, -# Packages.gz) so the release can be used directly as an apt source: +# Triggered by publishing a GitHub release (full or prerelease). Builds the +# three Debian packages and uploads them to the release that triggered the +# workflow, together with flat APT repository metadata (Packages, Packages.gz) +# so the release can be used directly as an apt source: # # deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./ # # The release URLs (releases/latest/download/) serve the flat repo; the -# images ship this source so `apt upgrade` tracks future releases. +# images ship this source so `apt upgrade` tracks future releases. This +# workflow never creates or modifies the release itself — create the release +# (and choose full vs prerelease) in GitHub first, then this attaches assets. on: - push: - tags: - - 'v*' + release: + types: [published] workflow_dispatch: jobs: @@ -24,7 +26,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - # Full history so `git describe` (the version source) works. + # The release tag, with full history so `git describe` (the version + # source) sees it. Falls back to the dispatch ref for manual runs. + ref: ${{ github.event.release.tag_name || github.ref }} fetch-depth: 0 persist-credentials: false @@ -55,11 +59,15 @@ jobs: done ls -l - - name: Upload release assets - uses: softprops/action-gh-release@v2 - with: - files: | - dist/dist/*.deb - dist/dist/Packages - dist/dist/Packages.gz - fail_on_unmatched_files: true + - name: Upload assets to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload "$TAG" \ + dist/dist/*.deb \ + dist/dist/Packages \ + dist/dist/Packages.gz \ + --repo "${{ github.repository }}" \ + --clobber diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md index 47e8f808..4ffb52f9 100644 --- a/mie-opensource-landing/docs/developers/release-pipeline.md +++ b/mie-opensource-landing/docs/developers/release-pipeline.md @@ -118,12 +118,15 @@ whole build; a missing release is a non-fatal `apt update` warning (hence the ## Releases -On a `vX.Y.Z` tag, -[`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml) -builds the packages, generates flat APT repository metadata (`Packages`, -`Packages.gz`) with `dpkg-scanpackages`, and attaches the debs and metadata to -the GitHub release. Because GitHub serves `releases/latest/download/` for -the newest non-prerelease release, the release doubles as an apt source: +To cut a release, **publish a GitHub release** (full or prerelease) for a +`vX.Y.Z` tag. Publishing the release triggers +[`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml), +which builds the packages, generates flat APT repository metadata (`Packages`, +`Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to +that release. The workflow never creates or modifies the release itself — you +choose full vs prerelease when creating it. Because GitHub serves +`releases/latest/download/` for the newest non-prerelease release, the +release doubles as an apt source: ```text deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./ From b6c899473eed02e7c6d214370ac332d8137333b2 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 14:56:40 -0400 Subject: [PATCH 25/42] Emit plain semver as VERSION; let nfpm render per-format The previous version logic translated prerelease suffixes to deb's ~rc1 form, which is invalid for apk (which needs _rc1). Instead, VERSION now produces a plain semver string (e.g. 2026.6.3-rc1, 2026.6.3-rc1+15.gSHA) and nfpm's default semver schema renders the correct version for each packager: ~rc1 for deb/rpm, _rc1 for apk, with snapshot metadata mapped to each format's convention. Verified prerelease and snapshot builds across deb, rpm and apk. --- create-a-container/Makefile | 11 ++++++++--- mie-opensource-landing/Makefile | 11 ++++++++--- pull-config/Makefile | 11 ++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/create-a-container/Makefile b/create-a-container/Makefile index aba4d90f..e7cd38d4 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -20,9 +20,14 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; -# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, -# digit-leading package version). +# Version derived from the nearest git tag, as a plain semver string. nfpm +# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses +# _rc1, etc.), so no separator translation happens here: +# v2026.6.3 -> 2026.6.3 +# v2026.6.3-rc1 -> 2026.6.3-rc1 +# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index 04ca5b7e..b7c50612 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -7,9 +7,14 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; -# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, -# digit-leading package version). +# Version derived from the nearest git tag, as a plain semver string. nfpm +# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses +# _rc1, etc.), so no separator translation happens here: +# v2026.6.3 -> 2026.6.3 +# v2026.6.3-rc1 -> 2026.6.3-rc1 +# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) PKG_NAME := opensource-docs diff --git a/pull-config/Makefile b/pull-config/Makefile index 714ca71e..fa6d8792 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -8,9 +8,14 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# v2026.6.2 -> 2026.6.2 ; v2026.6.2-15-gabc1234 -> 2026.6.2+15.gabc1234 ; -# no tags reachable (e.g. shallow checkout) -> 0.0.0+g (always a valid, -# digit-leading package version). +# Version derived from the nearest git tag, as a plain semver string. nfpm +# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses +# _rc1, etc.), so no separator translation happens here: +# v2026.6.3 -> 2026.6.3 +# v2026.6.3-rc1 -> 2026.6.3-rc1 +# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) # Dedicated staging directory for packaging (never a user-supplied DESTDIR). From a770732d2e2aa0980b6883c4d68a766fa658132a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 15:10:22 -0400 Subject: [PATCH 26/42] Drop the v prefix from version handling Tags are now unprefixed semver (e.g. 2026.6.3, 2026.6.3-rc1), so the '^v' strip is removed from the VERSION sed in all three component Makefiles. git describe must reach an unprefixed tag for a valid version. --- create-a-container/Makefile | 13 +++++++------ mie-opensource-landing/Makefile | 13 +++++++------ pull-config/Makefile | 13 +++++++------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/create-a-container/Makefile b/create-a-container/Makefile index e7cd38d4..fc20428f 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -20,15 +20,16 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag, as a plain semver string. nfpm +# Version derived from the nearest git tag (an unprefixed semver, e.g. +# 2026.6.3). nfpm # renders the correct per-format version from it (deb/rpm use ~rc1, apk uses # _rc1, etc.), so no separator translation happens here: -# v2026.6.3 -> 2026.6.3 -# v2026.6.3-rc1 -> 2026.6.3-rc1 -# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# 2026.6.3 -> 2026.6.3 +# 2026.6.3-rc1 -> 2026.6.3-rc1 +# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 # no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index b7c50612..3beff551 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -7,15 +7,16 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag, as a plain semver string. nfpm +# Version derived from the nearest git tag (an unprefixed semver, e.g. +# 2026.6.3). nfpm # renders the correct per-format version from it (deb/rpm use ~rc1, apk uses # _rc1, etc.), so no separator translation happens here: -# v2026.6.3 -> 2026.6.3 -# v2026.6.3-rc1 -> 2026.6.3-rc1 -# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# 2026.6.3 -> 2026.6.3 +# 2026.6.3-rc1 -> 2026.6.3-rc1 +# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 # no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site diff --git a/pull-config/Makefile b/pull-config/Makefile index fa6d8792..974baa11 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -8,15 +8,16 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag, as a plain semver string. nfpm +# Version derived from the nearest git tag (an unprefixed semver, e.g. +# 2026.6.3). nfpm # renders the correct per-format version from it (deb/rpm use ~rc1, apk uses # _rc1, etc.), so no separator translation happens here: -# v2026.6.3 -> 2026.6.3 -# v2026.6.3-rc1 -> 2026.6.3-rc1 -# v2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# v2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 +# 2026.6.3 -> 2026.6.3 +# 2026.6.3-rc1 -> 2026.6.3-rc1 +# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) +# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 # no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/^v//' -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) # Dedicated staging directory for packaging (never a user-supplied DESTDIR). STAGE := $(CURDIR)/.nfpm/buildroot From a0e44111fa9b787dcd71fb81a0f2e84bdfd0cee3 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 15:11:32 -0400 Subject: [PATCH 27/42] docs: use unprefixed semver tags in release pipeline --- .../docs/developers/release-pipeline.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md index 4ffb52f9..e4389643 100644 --- a/mie-opensource-landing/docs/developers/release-pipeline.md +++ b/mie-opensource-landing/docs/developers/release-pipeline.md @@ -39,9 +39,11 @@ Variables (overridable): | `DESTDIR` | `/` | Staging root for `install` | | `VERSION` | derived from `git describe` | Package version | -`VERSION` is computed inline from git tags: an exact tag `v2026.6.2` becomes -`2026.6.2`; commits after a tag become `2026.6.2+.g` (valid semver build -metadata that sorts above the tag and below the next release). +`VERSION` is computed inline from git tags: an exact tag `2026.6.2` is used +as-is; commits after a tag become `2026.6.2+.g` (valid semver build +metadata that sorts above the tag and below the next release); a prerelease tag +`2026.6.3-rc1` sorts below the eventual `2026.6.3`. Tags are unprefixed semver +(no leading `v`). ```bash # Build and stage a component anywhere: @@ -118,8 +120,9 @@ whole build; a missing release is a non-fatal `apt update` warning (hence the ## Releases -To cut a release, **publish a GitHub release** (full or prerelease) for a -`vX.Y.Z` tag. Publishing the release triggers +To cut a release, **publish a GitHub release** (full or prerelease) for an +unprefixed semver tag (e.g. `2026.6.3`, or `2026.6.3-rc1` for a prerelease). +Publishing the release triggers [`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml), which builds the packages, generates flat APT repository metadata (`Packages`, `Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to @@ -135,7 +138,7 @@ deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/do Image tagging follows the same rule: `:latest` is published only when a **non-prerelease** release is published, keeping the `:latest` image channel aligned with the `releases/latest` package channel. Pre-releases publish their -own assets and `:vX.Y.Z` image tags without moving `:latest`. +own assets and `:X.Y.Z` image tags without moving `:latest`. ## Installing and updating on a host From dd24d6d6a78c248eb75bdb9a468bf8eddd5bf124 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 15:26:29 -0400 Subject: [PATCH 28/42] Remove the 0.0.0 version fallback Now that releases are tagged, git describe always finds a tag, so the VERSION sed collapses to a single describe|sed pipeline. --- .github/workflows/build-images.yml | 5 ++--- create-a-container/Makefile | 8 +++----- mie-opensource-landing/Makefile | 8 +++----- pull-config/Makefile | 8 +++----- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 9d058d9b..f1de1432 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -50,9 +50,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - # Full history + tags so the builder image's `git describe` derives a - # proper package version (otherwise a shallow checkout yields a bare - # SHA, which is not a valid Debian version). + # Full history + tags so the builder image's `git describe` derives + # the package version. fetch-depth: 0 persist-credentials: false diff --git a/create-a-container/Makefile b/create-a-container/Makefile index fc20428f..1940a340 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -21,15 +21,13 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / # Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm -# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses -# _rc1, etc.), so no separator translation happens here: +# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use +# ~rc1, apk uses _rc1, etc.): # 2026.6.3 -> 2026.6.3 # 2026.6.3-rc1 -> 2026.6.3-rc1 # 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) # 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index 3beff551..0c9313af 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -8,15 +8,13 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / # Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm -# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses -# _rc1, etc.), so no separator translation happens here: +# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use +# ~rc1, apk uses _rc1, etc.): # 2026.6.3 -> 2026.6.3 # 2026.6.3-rc1 -> 2026.6.3-rc1 # 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) # 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site diff --git a/pull-config/Makefile b/pull-config/Makefile index 974baa11..b8b7722c 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -9,15 +9,13 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / # Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm -# renders the correct per-format version from it (deb/rpm use ~rc1, apk uses -# _rc1, etc.), so no separator translation happens here: +# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use +# ~rc1, apk uses _rc1, etc.): # 2026.6.3 -> 2026.6.3 # 2026.6.3-rc1 -> 2026.6.3-rc1 # 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) # 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -# no tag reachable (e.g. shallow checkout) -> 0.0.0+g. -VERSION ?= $(shell d=$$(git describe --tags 2>/dev/null); if [ -n "$$d" ]; then echo "$$d" | sed -e 's/-\([0-9]\+\)-g/+\1.g/'; else echo "0.0.0+g$$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; fi) +VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') # Dedicated staging directory for packaging (never a user-supplied DESTDIR). STAGE := $(CURDIR)/.nfpm/buildroot From 1772c3a0f92302010beb72c8e64cc083e6fb15b8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 16:00:25 -0400 Subject: [PATCH 29/42] Stop auto-tagging :latest on prereleases metadata-action's flavor latest=auto adds :latest for type=ref,event=tag events independently of the explicit type=raw,value=latest rule, so publishing the 2026.6.3 prerelease moved :latest even though the raw rule was disabled for prereleases. Set flavor: latest=false on every metadata step so :latest is controlled solely by the explicit non-prerelease-gated raw rule. --- .github/workflows/build-images.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index f1de1432..928a1f5f 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -71,6 +71,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/base bake-target: base + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch @@ -84,6 +87,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/nodejs bake-target: nodejs + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch @@ -97,6 +103,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/docs bake-target: docs + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch @@ -110,6 +119,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/agent bake-target: agent + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch @@ -122,6 +134,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/manager bake-target: manager + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch @@ -134,6 +149,9 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ github.repository }}/proxmox-ve bake-target: proxmox-ve + # Disable auto-latest (fires for type=ref,event=tag); :latest is + # controlled solely by the explicit type=raw rule below. + flavor: latest=false tags: | type=sha type=ref,event=branch From cea0ce242b55d0b3f097ba4b0749291cab8198b7 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Sun, 14 Jun 2026 18:06:26 -0400 Subject: [PATCH 30/42] Parse version parts from git; compose per-format version for nfpm Each Makefile now parses VERSION (leading v stripped, 0.0.0 fallback), PRERELEASE, commit count, hash, and dirty state from git describe --tags --long --dirty. scripts/nfpm-version.sh composes a format-appropriate version string (deb: ~pre +meta; rpm: ~pre ^snap; apk: _pre _gitN) and nfpm.yaml sets version_schema: none so nfpm uses it verbatim instead of its own semver parsing. --- create-a-container/Makefile | 30 +++++++++++----- create-a-container/nfpm.yaml | 2 ++ mie-opensource-landing/Makefile | 30 +++++++++++----- mie-opensource-landing/nfpm.yaml | 2 ++ pull-config/Makefile | 34 ++++++++++++------ pull-config/nfpm.yaml | 2 ++ scripts/nfpm-version.sh | 61 ++++++++++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 28 deletions(-) create mode 100755 scripts/nfpm-version.sh diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 1940a340..34cc7e8c 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -20,14 +20,24 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use -# ~rc1, apk uses _rc1, etc.): -# 2026.6.3 -> 2026.6.3 -# 2026.6.3-rc1 -> 2026.6.3-rc1 -# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') +# Version parts parsed from git. `git describe --tags --long --dirty` yields +# --g[-dirty]; we split it into the pieces below and let +# scripts/nfpm-version.sh compose a format-specific string at package time. +# VERSION base version, leading 'v' stripped, 0.0.0 if no tag +# PRERELEASE prerelease label (e.g. rc1), empty otherwise +# COMMITS commits since the tag (0 on an exact tag) +# HASH short commit hash (g) +# DIRTY 1 if the working tree has uncommitted changes, else 0 +GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null) +DIRTY := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0) +_DESC := $(GIT_DESCRIBE:%-dirty=%) +HASH := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),) +_REST := $(_DESC:%-$(HASH)=%) +COMMITS := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0) +_TAG := $(_REST:%-$(COMMITS)=%) +_BASE := $(patsubst v%,%,$(_TAG)) +VERSION := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0) +PRERELEASE := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),) DESTBIN := $(DESTDIR)$(PREFIX)/create-a-container UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system @@ -98,7 +108,9 @@ PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) \ + VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \ + nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml index 59e0b304..69ae31d2 100644 --- a/create-a-container/nfpm.yaml +++ b/create-a-container/nfpm.yaml @@ -14,6 +14,8 @@ name: opensource-server arch: amd64 platform: linux version: ${VERSION} +# The Makefile composes a format-specific version string; use it verbatim. +version_schema: none section: admin priority: optional maintainer: "Medical Informatics Engineering " diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile index 0c9313af..200f9dcc 100644 --- a/mie-opensource-landing/Makefile +++ b/mie-opensource-landing/Makefile @@ -7,14 +7,24 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use -# ~rc1, apk uses _rc1, etc.): -# 2026.6.3 -> 2026.6.3 -# 2026.6.3-rc1 -> 2026.6.3-rc1 -# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') +# Version parts parsed from git. `git describe --tags --long --dirty` yields +# --g[-dirty]; we split it into the pieces below and let +# scripts/nfpm-version.sh compose a format-specific string at package time. +# VERSION base version, leading 'v' stripped, 0.0.0 if no tag +# PRERELEASE prerelease label (e.g. rc1), empty otherwise +# COMMITS commits since the tag (0 on an exact tag) +# HASH short commit hash (g) +# DIRTY 1 if the working tree has uncommitted changes, else 0 +GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null) +DIRTY := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0) +_DESC := $(GIT_DESCRIBE:%-dirty=%) +HASH := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),) +_REST := $(_DESC:%-$(HASH)=%) +COMMITS := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0) +_TAG := $(_REST:%-$(COMMITS)=%) +_BASE := $(patsubst v%,%,$(_TAG)) +VERSION := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0) +PRERELEASE := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),) PKG_NAME := opensource-docs SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site @@ -45,7 +55,9 @@ PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) \ + VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \ + nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/mie-opensource-landing/nfpm.yaml b/mie-opensource-landing/nfpm.yaml index 9caf81f2..a5bdb6e0 100644 --- a/mie-opensource-landing/nfpm.yaml +++ b/mie-opensource-landing/nfpm.yaml @@ -9,6 +9,8 @@ name: opensource-docs arch: all platform: linux version: ${VERSION} +# The Makefile composes a format-specific version string; use it verbatim. +version_schema: none section: doc priority: optional maintainer: "Medical Informatics Engineering " diff --git a/pull-config/Makefile b/pull-config/Makefile index b8b7722c..c5c2e7b4 100644 --- a/pull-config/Makefile +++ b/pull-config/Makefile @@ -8,14 +8,24 @@ PREFIX ?= /opt/opensource-server DESTDIR ?= / -# Version derived from the nearest git tag (an unprefixed semver, e.g. -# 2026.6.3). nfpm renders the correct per-format version from it (deb/rpm use -# ~rc1, apk uses _rc1, etc.): -# 2026.6.3 -> 2026.6.3 -# 2026.6.3-rc1 -> 2026.6.3-rc1 -# 2026.6.3-15-gabc1234 -> 2026.6.3+15.gabc1234 (snapshot metadata) -# 2026.6.3-rc1-15-gabc1234 -> 2026.6.3-rc1+15.gabc1234 -VERSION ?= $(shell git describe --tags 2>/dev/null | sed -e 's/-\([0-9]\+\)-g/+\1.g/') +# Version parts parsed from git. `git describe --tags --long --dirty` yields +# --g[-dirty]; we split it into the pieces below and let +# scripts/nfpm-version.sh compose a format-specific string at package time. +# VERSION base version, leading 'v' stripped, 0.0.0 if no tag +# PRERELEASE prerelease label (e.g. rc1), empty otherwise +# COMMITS commits since the tag (0 on an exact tag) +# HASH short commit hash (g) +# DIRTY 1 if the working tree has uncommitted changes, else 0 +GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null) +DIRTY := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0) +_DESC := $(GIT_DESCRIBE:%-dirty=%) +HASH := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),) +_REST := $(_DESC:%-$(HASH)=%) +COMMITS := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0) +_TAG := $(_REST:%-$(COMMITS)=%) +_BASE := $(patsubst v%,%,$(_TAG)) +VERSION := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0) +PRERELEASE := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),) # Dedicated staging directory for packaging (never a user-supplied DESTDIR). STAGE := $(CURDIR)/.nfpm/buildroot @@ -48,13 +58,17 @@ install: build # Forward-auth cache directory used by the manager-rendered nginx config. $(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache -# Stage into a dedicated .nfpm/buildroot and package it; nfpm.yaml interpolates the +# Stage into a dedicated .nfpm/buildroot and package it. The version string is +# composed for the target format from the git parts above; nfpm.yaml sets +# version_schema: none so nfpm uses it verbatim. nfpm.yaml interpolates the # staging path via ${DESTDIR}. PACKAGER ?= deb package: rm -rf $(STAGE) $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) - DESTDIR=$(STAGE) VERSION=$(VERSION) nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) + DESTDIR=$(STAGE) \ + VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \ + nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR) deb: $(MAKE) package PACKAGER=deb diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml index 05a50310..94f21cf8 100644 --- a/pull-config/nfpm.yaml +++ b/pull-config/nfpm.yaml @@ -13,6 +13,8 @@ name: opensource-agent arch: all platform: linux version: ${VERSION} +# The Makefile composes a format-specific version string; use it verbatim. +version_schema: none section: admin priority: optional maintainer: "Medical Informatics Engineering " diff --git a/scripts/nfpm-version.sh b/scripts/nfpm-version.sh new file mode 100755 index 00000000..04bf4026 --- /dev/null +++ b/scripts/nfpm-version.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Compose a package-format-specific version string from version parts. +# +# Usage: nfpm-version.sh +# packager deb | rpm | apk +# version base version, e.g. 2026.6.3 (no leading v) +# prerelease e.g. rc1, or empty +# count commits since the tag (0 on an exact tag) +# hash short commit hash, e.g. g8192107 (empty if no tag) +# dirty 1 if the working tree is dirty, else 0 +# +# Each format has different version grammar, so the separators differ: +# prerelease : deb/rpm use ~
 (sorts below the release); apk uses _
+#   snapshot   : deb uses +. (build metadata); rpm uses
+#                ^. (post-release, sorts above); apk uses
+#                _git (apk cannot carry the hash)
+#   dirty      : appended as .dirty for deb/rpm; ignored for apk
+set -eu
+
+packager=$1
+version=$2
+prerelease=${3:-}
+count=${4:-0}
+hash=${5:-}
+dirty=${6:-0}
+
+case "$packager" in
+    deb)
+        v=$version
+        [ -n "$prerelease" ] && v="$v~$prerelease"
+        if [ "$count" != "0" ]; then
+            v="$v+$count.$hash"
+            [ "$dirty" = "1" ] && v="$v.dirty"
+        elif [ "$dirty" = "1" ]; then
+            v="$v+dirty"
+        fi
+        ;;
+    rpm)
+        v=$version
+        [ -n "$prerelease" ] && v="$v~$prerelease"
+        if [ "$count" != "0" ]; then
+            v="$v^$count.$hash"
+            [ "$dirty" = "1" ] && v="$v.dirty"
+        elif [ "$dirty" = "1" ]; then
+            v="$v^dirty"
+        fi
+        ;;
+    apk)
+        # apk grammar is restrictive: digits(.digits)*[_suffix[digits]]* — it
+        # cannot embed the commit hash, so snapshots use _git.
+        v=$version
+        [ -n "$prerelease" ] && v="${v}_$prerelease"
+        [ "$count" != "0" ] && v="${v}_git$count"
+        ;;
+    *)
+        echo "nfpm-version: unknown packager '$packager'" >&2
+        exit 1
+        ;;
+esac
+
+printf '%s\n' "$v"

From 25c7957acace8d7a20a15c8416078d045098c580 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 18:12:58 -0400
Subject: [PATCH 31/42] Consolidate version construction into ./package-version

Move all version parsing and per-format composition into a single
script at the repo root, ./package-version , which derives
the version from git itself. The component Makefiles no longer parse git
or hold version parts; their package targets just call the script. The
script has no .sh suffix and no tool-specific name so the implementation
can change later. Drop the now-unused VERSION plumbing from the
top-level Makefile.
---
 Makefile                        |  5 +-
 create-a-container/Makefile     | 21 +------
 mie-opensource-landing/Makefile | 20 +------
 package-version                 | 97 +++++++++++++++++++++++++++++++++
 pull-config/Makefile            | 20 +------
 scripts/nfpm-version.sh         | 61 ---------------------
 6 files changed, 102 insertions(+), 122 deletions(-)
 create mode 100755 package-version
 delete mode 100755 scripts/nfpm-version.sh

diff --git a/Makefile b/Makefile
index 81d37ece..3e8edcdd 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@
 # Variables pass straight through:
 #   PREFIX   vendor install prefix (default /opt/opensource-server)
 #   DESTDIR  staging root for `install` (default /)
-#   VERSION  package version (default derived from git tags)
+# The package version is derived from git by ./package-version.
 
 .DEFAULT_GOAL := build
 
@@ -18,8 +18,7 @@ PACKAGER   ?= deb
 
 # Forwarded to every component Makefile.
 MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \
-            $(if $(DESTDIR),DESTDIR=$(DESTDIR),) \
-            $(if $(VERSION),VERSION=$(VERSION),)
+            $(if $(DESTDIR),DESTDIR=$(DESTDIR),)
 
 .PHONY: deps build install dev deb rpm apk clean $(COMPONENTS)
 
diff --git a/create-a-container/Makefile b/create-a-container/Makefile
index 34cc7e8c..121c5d38 100644
--- a/create-a-container/Makefile
+++ b/create-a-container/Makefile
@@ -20,25 +20,6 @@
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
-# Version parts parsed from git. `git describe --tags --long --dirty` yields
-# --g[-dirty]; we split it into the pieces below and let
-# scripts/nfpm-version.sh compose a format-specific string at package time.
-#   VERSION     base version, leading 'v' stripped, 0.0.0 if no tag
-#   PRERELEASE  prerelease label (e.g. rc1), empty otherwise
-#   COMMITS     commits since the tag (0 on an exact tag)
-#   HASH        short commit hash (g)
-#   DIRTY       1 if the working tree has uncommitted changes, else 0
-GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null)
-DIRTY        := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0)
-_DESC        := $(GIT_DESCRIBE:%-dirty=%)
-HASH         := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),)
-_REST        := $(_DESC:%-$(HASH)=%)
-COMMITS      := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0)
-_TAG         := $(_REST:%-$(COMMITS)=%)
-_BASE        := $(patsubst v%,%,$(_TAG))
-VERSION      := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0)
-PRERELEASE   := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),)
-
 DESTBIN  := $(DESTDIR)$(PREFIX)/create-a-container
 UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system
 
@@ -109,7 +90,7 @@ package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
 	DESTDIR=$(STAGE) \
-	VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \
+	VERSION="$$(../package-version $(PACKAGER))" \
 	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
 
 deb:
diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile
index 200f9dcc..4115dd61 100644
--- a/mie-opensource-landing/Makefile
+++ b/mie-opensource-landing/Makefile
@@ -7,24 +7,6 @@
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
-# Version parts parsed from git. `git describe --tags --long --dirty` yields
-# --g[-dirty]; we split it into the pieces below and let
-# scripts/nfpm-version.sh compose a format-specific string at package time.
-#   VERSION     base version, leading 'v' stripped, 0.0.0 if no tag
-#   PRERELEASE  prerelease label (e.g. rc1), empty otherwise
-#   COMMITS     commits since the tag (0 on an exact tag)
-#   HASH        short commit hash (g)
-#   DIRTY       1 if the working tree has uncommitted changes, else 0
-GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null)
-DIRTY        := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0)
-_DESC        := $(GIT_DESCRIBE:%-dirty=%)
-HASH         := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),)
-_REST        := $(_DESC:%-$(HASH)=%)
-COMMITS      := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0)
-_TAG         := $(_REST:%-$(COMMITS)=%)
-_BASE        := $(patsubst v%,%,$(_TAG))
-VERSION      := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0)
-PRERELEASE   := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),)
 
 PKG_NAME := opensource-docs
 SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site
@@ -56,7 +38,7 @@ package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
 	DESTDIR=$(STAGE) \
-	VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \
+	VERSION="$$(../package-version $(PACKAGER))" \
 	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
 
 deb:
diff --git a/package-version b/package-version
new file mode 100755
index 00000000..51863d15
--- /dev/null
+++ b/package-version
@@ -0,0 +1,97 @@
+#!/bin/sh
+# Print the package version string for a given packaging format, derived from
+# the current git state.
+#
+# Usage: ./package-version 
+#
+# The version is parsed from `git describe --tags --long --dirty`:
+#   VERSION     base version, leading 'v' stripped, 0.0.0 if there is no tag
+#   PRERELEASE  prerelease label (e.g. rc1), empty otherwise
+#   COMMITS     commits since the tag (0 on an exact tag)
+#   HASH        short commit hash (g)
+#   DIRTY       whether the working tree has uncommitted changes
+#
+# Each format has a different version grammar, so the separators differ:
+#   prerelease : deb/rpm use ~
 (sorts below the release); apk uses _
+#   snapshot   : deb uses +. (build metadata); rpm uses
+#                ^. (post-release, sorts above); apk uses
+#                _git (apk cannot carry the hash)
+#   dirty      : appended as .dirty for deb/rpm; ignored for apk
+set -eu
+
+packager=${1:-}
+case "$packager" in
+    deb | rpm | apk) ;;
+    *)
+        echo "usage: $0 " >&2
+        exit 2
+        ;;
+esac
+
+# --- parse version parts from git ------------------------------------------
+describe=$(git describe --tags --long --dirty 2>/dev/null || true)
+
+dirty=0
+case "$describe" in
+    *-dirty)
+        dirty=1
+        describe=${describe%-dirty}
+        ;;
+esac
+
+if [ -n "$describe" ]; then
+    # describe is --g
+    hash=${describe##*-}            # g
+    rest=${describe%-*}             # -
+    commits=${rest##*-}            # 
+    tag=${rest%-*}                 # 
+    base=${tag#v}                  # strip a leading v
+    case "$base" in
+        *-*)
+            version=${base%%-*}
+            prerelease=${base#*-}
+            ;;
+        *)
+            version=$base
+            prerelease=""
+            ;;
+    esac
+else
+    version="0.0.0"
+    prerelease=""
+    commits=0
+    hash=""
+fi
+
+# --- compose the format-specific version string ----------------------------
+case "$packager" in
+    deb)
+        v=$version
+        [ -n "$prerelease" ] && v="$v~$prerelease"
+        if [ "$commits" != "0" ]; then
+            v="$v+$commits.$hash"
+            [ "$dirty" = "1" ] && v="$v.dirty"
+        elif [ "$dirty" = "1" ]; then
+            v="$v+dirty"
+        fi
+        ;;
+    rpm)
+        v=$version
+        [ -n "$prerelease" ] && v="$v~$prerelease"
+        if [ "$commits" != "0" ]; then
+            v="$v^$commits.$hash"
+            [ "$dirty" = "1" ] && v="$v.dirty"
+        elif [ "$dirty" = "1" ]; then
+            v="$v^dirty"
+        fi
+        ;;
+    apk)
+        # apk grammar is restrictive: digits(.digits)*[_suffix[digits]]* — it
+        # cannot embed the commit hash, so snapshots use _git.
+        v=$version
+        [ -n "$prerelease" ] && v="${v}_$prerelease"
+        [ "$commits" != "0" ] && v="${v}_git$commits"
+        ;;
+esac
+
+printf '%s\n' "$v"
diff --git a/pull-config/Makefile b/pull-config/Makefile
index c5c2e7b4..deaf6677 100644
--- a/pull-config/Makefile
+++ b/pull-config/Makefile
@@ -8,24 +8,6 @@
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
-# Version parts parsed from git. `git describe --tags --long --dirty` yields
-# --g[-dirty]; we split it into the pieces below and let
-# scripts/nfpm-version.sh compose a format-specific string at package time.
-#   VERSION     base version, leading 'v' stripped, 0.0.0 if no tag
-#   PRERELEASE  prerelease label (e.g. rc1), empty otherwise
-#   COMMITS     commits since the tag (0 on an exact tag)
-#   HASH        short commit hash (g)
-#   DIRTY       1 if the working tree has uncommitted changes, else 0
-GIT_DESCRIBE := $(shell git describe --tags --long --dirty 2>/dev/null)
-DIRTY        := $(if $(filter %-dirty,$(GIT_DESCRIBE)),1,0)
-_DESC        := $(GIT_DESCRIBE:%-dirty=%)
-HASH         := $(if $(_DESC),$(lastword $(subst -, ,$(_DESC))),)
-_REST        := $(_DESC:%-$(HASH)=%)
-COMMITS      := $(if $(_DESC),$(lastword $(subst -, ,$(_REST))),0)
-_TAG         := $(_REST:%-$(COMMITS)=%)
-_BASE        := $(patsubst v%,%,$(_TAG))
-VERSION      := $(if $(_BASE),$(firstword $(subst -, ,$(_BASE))),0.0.0)
-PRERELEASE   := $(if $(findstring -,$(_BASE)),$(patsubst $(VERSION)-%,%,$(_BASE)),)
 
 # Dedicated staging directory for packaging (never a user-supplied DESTDIR).
 STAGE := $(CURDIR)/.nfpm/buildroot
@@ -67,7 +49,7 @@ package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
 	DESTDIR=$(STAGE) \
-	VERSION="$$(../scripts/nfpm-version.sh $(PACKAGER) '$(VERSION)' '$(PRERELEASE)' '$(COMMITS)' '$(HASH)' '$(DIRTY)')" \
+	VERSION="$$(../package-version $(PACKAGER))" \
 	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
 
 deb:
diff --git a/scripts/nfpm-version.sh b/scripts/nfpm-version.sh
deleted file mode 100755
index 04bf4026..00000000
--- a/scripts/nfpm-version.sh
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/bin/sh
-# Compose a package-format-specific version string from version parts.
-#
-# Usage: nfpm-version.sh      
-#   packager    deb | rpm | apk
-#   version     base version, e.g. 2026.6.3 (no leading v)
-#   prerelease  e.g. rc1, or empty
-#   count       commits since the tag (0 on an exact tag)
-#   hash        short commit hash, e.g. g8192107 (empty if no tag)
-#   dirty       1 if the working tree is dirty, else 0
-#
-# Each format has different version grammar, so the separators differ:
-#   prerelease : deb/rpm use ~
 (sorts below the release); apk uses _
-#   snapshot   : deb uses +. (build metadata); rpm uses
-#                ^. (post-release, sorts above); apk uses
-#                _git (apk cannot carry the hash)
-#   dirty      : appended as .dirty for deb/rpm; ignored for apk
-set -eu
-
-packager=$1
-version=$2
-prerelease=${3:-}
-count=${4:-0}
-hash=${5:-}
-dirty=${6:-0}
-
-case "$packager" in
-    deb)
-        v=$version
-        [ -n "$prerelease" ] && v="$v~$prerelease"
-        if [ "$count" != "0" ]; then
-            v="$v+$count.$hash"
-            [ "$dirty" = "1" ] && v="$v.dirty"
-        elif [ "$dirty" = "1" ]; then
-            v="$v+dirty"
-        fi
-        ;;
-    rpm)
-        v=$version
-        [ -n "$prerelease" ] && v="$v~$prerelease"
-        if [ "$count" != "0" ]; then
-            v="$v^$count.$hash"
-            [ "$dirty" = "1" ] && v="$v.dirty"
-        elif [ "$dirty" = "1" ]; then
-            v="$v^dirty"
-        fi
-        ;;
-    apk)
-        # apk grammar is restrictive: digits(.digits)*[_suffix[digits]]* — it
-        # cannot embed the commit hash, so snapshots use _git.
-        v=$version
-        [ -n "$prerelease" ] && v="${v}_$prerelease"
-        [ "$count" != "0" ] && v="${v}_git$count"
-        ;;
-    *)
-        echo "nfpm-version: unknown packager '$packager'" >&2
-        exit 1
-        ;;
-esac
-
-printf '%s\n' "$v"

From b954b6df212f2fca9d13fbfad8976db7a330eadc Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 18:34:39 -0400
Subject: [PATCH 32/42] Switch packaging from nfpm to classic fpm

Replace the three nfpm.yaml files with fpm options files
(.fpm) holding the static package metadata, and have each
Makefile's package target invoke fpm with the dynamic options on the
CLI (output type, version from ../package-version, arch, staging dir,
and the manager's version-pinned inter-package deps).

fpm's dir input packages the staged tree verbatim from -C ,
preserving symlinks (e.g. node_modules/.bin/sequelize) and the
directory layout, which removes nfpm's awkward per-directory src/dst
'tree' entries and ${DESTDIR} interpolation. Config files are marked
explicitly with --config-files and --deb-no-default-config-files stops
the auto-marking of everything under /etc that nfpm/dpkg defaults to.

The packaging staging dir is renamed from .nfpm/buildroot to
.pkg/buildroot (a dir literally named .fpm is consumed by fpm as an rc
options file).
---
 create-a-container/.gitignore              |  2 +-
 create-a-container/Makefile                | 28 ++++++---
 create-a-container/nfpm.yaml               | 66 ----------------------
 create-a-container/opensource-server.fpm   | 17 ++++++
 mie-opensource-landing/.gitignore          |  2 +-
 mie-opensource-landing/Makefile            | 17 ++++--
 mie-opensource-landing/nfpm.yaml           | 32 -----------
 mie-opensource-landing/opensource-docs.fpm |  9 +++
 pull-config/.gitignore                     |  2 +-
 pull-config/Makefile                       | 23 +++++---
 pull-config/nfpm.yaml                      | 52 -----------------
 pull-config/opensource-agent.fpm           | 16 ++++++
 12 files changed, 93 insertions(+), 173 deletions(-)
 delete mode 100644 create-a-container/nfpm.yaml
 create mode 100644 create-a-container/opensource-server.fpm
 delete mode 100644 mie-opensource-landing/nfpm.yaml
 create mode 100644 mie-opensource-landing/opensource-docs.fpm
 delete mode 100644 pull-config/nfpm.yaml
 create mode 100644 pull-config/opensource-agent.fpm

diff --git a/create-a-container/.gitignore b/create-a-container/.gitignore
index 3594a5e9..ca7e5ca8 100644
--- a/create-a-container/.gitignore
+++ b/create-a-container/.gitignore
@@ -3,7 +3,7 @@ node_modules
 data/
 
 # packaging build artifacts
-/.nfpm/buildroot/
+/.pkg/
 /*.deb
 /*.rpm
 /*.apk
diff --git a/create-a-container/Makefile b/create-a-container/Makefile
index 121c5d38..698e86c3 100644
--- a/create-a-container/Makefile
+++ b/create-a-container/Makefile
@@ -24,7 +24,13 @@ DESTBIN  := $(DESTDIR)$(PREFIX)/create-a-container
 UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system
 
 # Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-STAGE := $(CURDIR)/.nfpm/buildroot
+# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+STAGE := $(CURDIR)/.pkg/buildroot
+
+# amd64: ships prebuilt native node addons (argon2, sqlite3).
+# FPM_DIRS are the top-level staged dirs.
+ARCH     := amd64
+FPM_DIRS := etc opt usr
 
 INSTALL      := install
 INSTALL_DATA := $(INSTALL) -m 0644
@@ -81,17 +87,23 @@ install: build
 	$(INSTALL) -d $(DESTDIR)/etc/logrotate.d
 	$(INSTALL_DATA) contrib/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server
 
-# Package the staged tree with nfpm. Files are staged into a dedicated
-# .nfpm/buildroot (never a user-supplied DESTDIR); nfpm.yaml interpolates that
-# path via ${DESTDIR}, so nfpm runs from the component directory (where its
-# contrib/ maintainer scripts live) and packaging never mutates the staged tree.
+# Stage into a dedicated .pkg/buildroot and build the package with fpm. Static
+# options live in opensource-server.fpm; the version (composed per format by
+# ../package-version), output type, arch, staging dir and the version-pinned
+# inter-package dependencies (opensource-agent/-docs of the same version, which
+# provide the error pages and docs site the manager's nginx config references)
+# are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	DESTDIR=$(STAGE) \
-	VERSION="$$(../package-version $(PACKAGER))" \
-	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
+	ver="$$(../package-version $(PACKAGER))"; \
+	fpm --fpm-options-file opensource-server.fpm \
+		-s dir -t $(PACKAGER) -a $(ARCH) -v "$$ver" \
+		--depends "opensource-agent = $$ver" \
+		--depends "opensource-docs = $$ver" \
+		-C $(STAGE) -p $(CURDIR) -f \
+		$(FPM_DIRS)
 
 deb:
 	$(MAKE) package PACKAGER=deb
diff --git a/create-a-container/nfpm.yaml b/create-a-container/nfpm.yaml
deleted file mode 100644
index 69ae31d2..00000000
--- a/create-a-container/nfpm.yaml
+++ /dev/null
@@ -1,66 +0,0 @@
-# nfpm configuration for the opensource-server package (create-a-container).
-# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into
-# a staging root (DESTDIR) and run nfpm from the component directory; the
-# content sources below interpolate ${DESTDIR} via `expand: true`, and VERSION
-# is passed through the environment.
-#
-# /etc files are declared explicitly as config so admin edits survive upgrades;
-# the rest of the staged tree is packaged per top-level directory. This avoids
-# any overlap between the tree and the config entries (nfpm rejects a file that
-# appears in both), so install and packaging never fight.
-#
-# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json
-name: opensource-server
-arch: amd64
-platform: linux
-version: ${VERSION}
-# The Makefile composes a format-specific version string; use it verbatim.
-version_schema: none
-section: admin
-priority: optional
-maintainer: "Medical Informatics Engineering "
-description: |
-  MIE opensource-server cluster manager (create-a-container).
-  Web UI and REST API for self-service LXC container hosting on Proxmox VE:
-  container lifecycle, automated DNS and reverse-proxy configuration for
-  agents, LDAP authentication and ACME TLS orchestration.
-homepage: "https://github.com/mieweb/opensource-server"
-license: "Apache-2.0"
-
-# The manager's rendered nginx config references the agent's error pages and
-# the docs site, so both packages are required at runtime.
-depends:
-  - opensource-agent
-  - opensource-docs
-  - nodejs
-  - sudo
-  # Linked by the prebuilt native node addons (argon2, sqlite3).
-  - libc6
-  - libgcc-s1
-  - libstdc++6
-suggests:
-  - postgresql
-
-# Sources are taken from the staging root via ${DESTDIR} (expand: true tells
-# nfpm to interpolate environment variables in the path).
-contents:
-  # Config files first, declared explicitly so they are tagged as conffiles.
-  - src: ${DESTDIR}/etc/logrotate.d/opensource-server
-    dst: /etc/logrotate.d/opensource-server
-    type: config
-    expand: true
-  # Everything else, packaged per top-level directory (no /etc overlap).
-  - src: ${DESTDIR}/opt
-    dst: /opt
-    type: tree
-    expand: true
-  - src: ${DESTDIR}/usr
-    dst: /usr
-    type: tree
-    expand: true
-
-# Maintainer scripts are referenced relative to nfpm's working directory (the
-# component directory, where `make` runs nfpm).
-scripts:
-  postinstall: ./contrib/postinstall.sh
-  preremove: ./contrib/preremove.sh
diff --git a/create-a-container/opensource-server.fpm b/create-a-container/opensource-server.fpm
new file mode 100644
index 00000000..d093ebb4
--- /dev/null
+++ b/create-a-container/opensource-server.fpm
@@ -0,0 +1,17 @@
+--name opensource-server
+--license Apache-2.0
+--maintainer "Medical Informatics Engineering "
+--vendor "Medical Informatics Engineering"
+--url "https://github.com/mieweb/opensource-server"
+--category admin
+--description "MIE opensource-server cluster manager (create-a-container): web UI and REST API for self-service LXC container hosting on Proxmox VE, with automated DNS and reverse-proxy configuration, LDAP authentication and ACME TLS orchestration."
+--depends nodejs
+--depends sudo
+--depends libc6
+--depends libgcc-s1
+--depends libstdc++6
+--deb-suggests postgresql
+--config-files /etc/logrotate.d/opensource-server
+--deb-no-default-config-files
+--after-install contrib/postinstall.sh
+--before-remove contrib/preremove.sh
diff --git a/mie-opensource-landing/.gitignore b/mie-opensource-landing/.gitignore
index e9ac63b8..b7d8e8e4 100644
--- a/mie-opensource-landing/.gitignore
+++ b/mie-opensource-landing/.gitignore
@@ -16,7 +16,7 @@ __pycache__/
 tmp/
 
 # packaging build artifacts
-/.nfpm/buildroot/
+/.pkg/
 /*.deb
 /*.rpm
 /*.apk
diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile
index 4115dd61..9057d304 100644
--- a/mie-opensource-landing/Makefile
+++ b/mie-opensource-landing/Makefile
@@ -12,7 +12,12 @@ PKG_NAME := opensource-docs
 SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site
 
 # Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-STAGE := $(CURDIR)/.nfpm/buildroot
+# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+STAGE := $(CURDIR)/.pkg/buildroot
+
+# ARCH=all (content-only payload); FPM_DIRS are the top-level staged dirs.
+ARCH     := all
+FPM_DIRS := opt
 
 .PHONY: deps build dev install deb rpm apk package clean
 
@@ -33,13 +38,17 @@ install: build
 
 # Stage into a dedicated .nfpm/buildroot and package it; nfpm.yaml interpolates the
 # staging path via ${DESTDIR}.
+# Stage into a dedicated .pkg/buildroot and build the package with fpm. Static
+# options live in opensource-docs.fpm; version/type/arch/staging are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	DESTDIR=$(STAGE) \
-	VERSION="$$(../package-version $(PACKAGER))" \
-	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
+	fpm --fpm-options-file opensource-docs.fpm \
+		-s dir -t $(PACKAGER) -a $(ARCH) \
+		-v "$$(../package-version $(PACKAGER))" \
+		-C $(STAGE) -p $(CURDIR) -f \
+		$(FPM_DIRS)
 
 deb:
 	$(MAKE) package PACKAGER=deb
diff --git a/mie-opensource-landing/nfpm.yaml b/mie-opensource-landing/nfpm.yaml
deleted file mode 100644
index a5bdb6e0..00000000
--- a/mie-opensource-landing/nfpm.yaml
+++ /dev/null
@@ -1,32 +0,0 @@
-# nfpm configuration for the opensource-docs package (mie-opensource-landing).
-# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the prebuilt site
-# into a staging root (DESTDIR) and run nfpm from the component directory; the
-# content source below interpolates ${DESTDIR} via `expand: true`, and VERSION
-# is passed via the environment.
-#
-# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json
-name: opensource-docs
-arch: all
-platform: linux
-version: ${VERSION}
-# The Makefile composes a format-specific version string; use it verbatim.
-version_schema: none
-section: doc
-priority: optional
-maintainer: "Medical Informatics Engineering "
-description: |
-  MIE opensource-server documentation site (static content).
-  Ships the prebuilt site under /opt/opensource-server/mie-opensource-landing.
-  Serving it is a deployment concern: the docs image adds an nginx vhost and
-  the cluster manager serves it via its rendered nginx configuration.
-homepage: "https://github.com/mieweb/opensource-server"
-license: "Apache-2.0"
-
-suggests:
-  - nginx
-
-contents:
-  - src: ${DESTDIR}/opt
-    dst: /opt
-    type: tree
-    expand: true
diff --git a/mie-opensource-landing/opensource-docs.fpm b/mie-opensource-landing/opensource-docs.fpm
new file mode 100644
index 00000000..eaade806
--- /dev/null
+++ b/mie-opensource-landing/opensource-docs.fpm
@@ -0,0 +1,9 @@
+--name opensource-docs
+--license Apache-2.0
+--maintainer "Medical Informatics Engineering "
+--vendor "Medical Informatics Engineering"
+--url "https://github.com/mieweb/opensource-server"
+--category doc
+--description "MIE opensource-server documentation site (static content). Ships the prebuilt site under /opt/opensource-server/mie-opensource-landing; serving it is a deployment concern."
+--deb-suggests nginx
+
diff --git a/pull-config/.gitignore b/pull-config/.gitignore
index 9a68f9d0..1c09fed7 100644
--- a/pull-config/.gitignore
+++ b/pull-config/.gitignore
@@ -1,5 +1,5 @@
 # packaging build artifacts
-/.nfpm/buildroot/
+/.pkg/
 /*.deb
 /*.rpm
 /*.apk
diff --git a/pull-config/Makefile b/pull-config/Makefile
index deaf6677..33e18f20 100644
--- a/pull-config/Makefile
+++ b/pull-config/Makefile
@@ -10,7 +10,13 @@ PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
 
 # Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-STAGE := $(CURDIR)/.nfpm/buildroot
+# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+STAGE := $(CURDIR)/.pkg/buildroot
+
+# Package metadata not in the .fpm file. ARCH=all (arch-independent payload).
+# FPM_DIRS are the top-level directories under the staging root to package.
+ARCH     := all
+FPM_DIRS := etc opt var
 
 INSTALL      := install
 INSTALL_DATA := $(INSTALL) -m 0644
@@ -40,17 +46,18 @@ install: build
 	# Forward-auth cache directory used by the manager-rendered nginx config.
 	$(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache
 
-# Stage into a dedicated .nfpm/buildroot and package it. The version string is
-# composed for the target format from the git parts above; nfpm.yaml sets
-# version_schema: none so nfpm uses it verbatim. nfpm.yaml interpolates the
-# staging path via ${DESTDIR}.
+# Stage into a dedicated .fpm/buildroot and build the package with fpm. Static
+# options live in opensource-agent.fpm; the version (composed per format by
+# ../package-version), output type, arch and staging dir are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	DESTDIR=$(STAGE) \
-	VERSION="$$(../package-version $(PACKAGER))" \
-	nfpm package -f nfpm.yaml -p $(PACKAGER) -t $(CURDIR)
+	fpm --fpm-options-file opensource-agent.fpm \
+		-s dir -t $(PACKAGER) -a $(ARCH) \
+		-v "$$(../package-version $(PACKAGER))" \
+		-C $(STAGE) -p $(CURDIR) -f \
+		$(FPM_DIRS)
 
 deb:
 	$(MAKE) package PACKAGER=deb
diff --git a/pull-config/nfpm.yaml b/pull-config/nfpm.yaml
deleted file mode 100644
index 94f21cf8..00000000
--- a/pull-config/nfpm.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-# nfpm configuration for the opensource-agent package (pull-config).
-# See https://nfpm.goreleaser.com. `make deb/rpm/apk` stage the component into
-# a staging root (DESTDIR) and run nfpm from the component directory; the
-# content sources below interpolate ${DESTDIR} via `expand: true`, and VERSION
-# is passed via the environment.
-#
-# The pull-config engine and instance scripts are program code, not
-# configuration — runtime configuration is supplied via /etc/environment, which
-# is not part of this package. So nothing here is tagged as a config file.
-#
-# yaml-language-server: $schema=https://nfpm.goreleaser.com/static/schema.json
-name: opensource-agent
-arch: all
-platform: linux
-version: ${VERSION}
-# The Makefile composes a format-specific version string; use it verbatim.
-version_schema: none
-section: admin
-priority: optional
-maintainer: "Medical Informatics Engineering "
-description: |
-  MIE opensource-server edge agent (pull-config).
-  Cron-driven distribution of nginx and dnsmasq configuration to edge nodes:
-  each instance fetches its rendered config from the cluster manager with
-  ETag caching, validates it and reloads the service on change. Also ships
-  the static error pages referenced by the manager-rendered nginx config.
-homepage: "https://github.com/mieweb/opensource-server"
-license: "Apache-2.0"
-
-depends:
-  - nginx
-  - libnginx-mod-stream
-  - libnginx-mod-http-modsecurity
-  - modsecurity-crs
-  - ssl-cert
-  - dnsmasq
-  - curl
-  - cron
-
-contents:
-  - src: ${DESTDIR}/etc
-    dst: /etc
-    type: tree
-    expand: true
-  - src: ${DESTDIR}/opt
-    dst: /opt
-    type: tree
-    expand: true
-  - src: ${DESTDIR}/var
-    dst: /var
-    type: tree
-    expand: true
diff --git a/pull-config/opensource-agent.fpm b/pull-config/opensource-agent.fpm
new file mode 100644
index 00000000..5003bae3
--- /dev/null
+++ b/pull-config/opensource-agent.fpm
@@ -0,0 +1,16 @@
+--name opensource-agent
+--license Apache-2.0
+--maintainer "Medical Informatics Engineering "
+--vendor "Medical Informatics Engineering"
+--url "https://github.com/mieweb/opensource-server"
+--category admin
+--description "MIE opensource-server edge agent (pull-config): cron-driven distribution of nginx and dnsmasq configuration to edge nodes. Also ships the static error pages referenced by the manager-rendered nginx config."
+--depends nginx
+--depends libnginx-mod-stream
+--depends libnginx-mod-http-modsecurity
+--depends modsecurity-crs
+--depends ssl-cert
+--depends dnsmasq
+--depends curl
+--depends cron
+--deb-no-default-config-files

From 680426917d6e65f1e41d8a9bc6261c352985a048 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 18:45:16 -0400
Subject: [PATCH 33/42] Simplify fpm invocation

Rename the per-component fpm options files to .fpm so fpm auto-loads
them, dropping --fpm-options-file. Move --architecture into each .fpm.
Let fpm copy the whole staging root from -C instead of listing the
top-level dirs. Add opensource-agent/opensource-docs to the manager's
.fpm as plain (unversioned) depends, dropping the per-format
version-pinned --depends from the Makefile.
---
 .../{opensource-server.fpm => .fpm}           |  3 +++
 create-a-container/Makefile                   | 24 ++++---------------
 .../{opensource-docs.fpm => .fpm}             |  1 +
 mie-opensource-landing/Makefile               | 19 ++++-----------
 pull-config/{opensource-agent.fpm => .fpm}    |  1 +
 pull-config/Makefile                          | 21 ++++------------
 6 files changed, 17 insertions(+), 52 deletions(-)
 rename create-a-container/{opensource-server.fpm => .fpm} (90%)
 rename mie-opensource-landing/{opensource-docs.fpm => .fpm} (95%)
 rename pull-config/{opensource-agent.fpm => .fpm} (97%)

diff --git a/create-a-container/opensource-server.fpm b/create-a-container/.fpm
similarity index 90%
rename from create-a-container/opensource-server.fpm
rename to create-a-container/.fpm
index d093ebb4..baa9b50a 100644
--- a/create-a-container/opensource-server.fpm
+++ b/create-a-container/.fpm
@@ -1,10 +1,13 @@
 --name opensource-server
+--architecture amd64
 --license Apache-2.0
 --maintainer "Medical Informatics Engineering "
 --vendor "Medical Informatics Engineering"
 --url "https://github.com/mieweb/opensource-server"
 --category admin
 --description "MIE opensource-server cluster manager (create-a-container): web UI and REST API for self-service LXC container hosting on Proxmox VE, with automated DNS and reverse-proxy configuration, LDAP authentication and ACME TLS orchestration."
+--depends opensource-agent
+--depends opensource-docs
 --depends nodejs
 --depends sudo
 --depends libc6
diff --git a/create-a-container/Makefile b/create-a-container/Makefile
index 698e86c3..a5c287b4 100644
--- a/create-a-container/Makefile
+++ b/create-a-container/Makefile
@@ -23,15 +23,10 @@ DESTDIR ?= /
 DESTBIN  := $(DESTDIR)$(PREFIX)/create-a-container
 UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system
 
-# Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
+# name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
-# amd64: ships prebuilt native node addons (argon2, sqlite3).
-# FPM_DIRS are the top-level staged dirs.
-ARCH     := amd64
-FPM_DIRS := etc opt usr
-
 INSTALL      := install
 INSTALL_DATA := $(INSTALL) -m 0644
 
@@ -87,23 +82,12 @@ install: build
 	$(INSTALL) -d $(DESTDIR)/etc/logrotate.d
 	$(INSTALL_DATA) contrib/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server
 
-# Stage into a dedicated .pkg/buildroot and build the package with fpm. Static
-# options live in opensource-server.fpm; the version (composed per format by
-# ../package-version), output type, arch, staging dir and the version-pinned
-# inter-package dependencies (opensource-agent/-docs of the same version, which
-# provide the error pages and docs site the manager's nginx config references)
-# are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	ver="$$(../package-version $(PACKAGER))"; \
-	fpm --fpm-options-file opensource-server.fpm \
-		-s dir -t $(PACKAGER) -a $(ARCH) -v "$$ver" \
-		--depends "opensource-agent = $$ver" \
-		--depends "opensource-docs = $$ver" \
-		-C $(STAGE) -p $(CURDIR) -f \
-		$(FPM_DIRS)
+	fpm -s dir -t $(PACKAGER) -v "$$(../package-version $(PACKAGER))" \
+		-C $(STAGE) -p $(CURDIR) -f
 
 deb:
 	$(MAKE) package PACKAGER=deb
diff --git a/mie-opensource-landing/opensource-docs.fpm b/mie-opensource-landing/.fpm
similarity index 95%
rename from mie-opensource-landing/opensource-docs.fpm
rename to mie-opensource-landing/.fpm
index eaade806..cb7e1519 100644
--- a/mie-opensource-landing/opensource-docs.fpm
+++ b/mie-opensource-landing/.fpm
@@ -1,4 +1,5 @@
 --name opensource-docs
+--architecture all
 --license Apache-2.0
 --maintainer "Medical Informatics Engineering "
 --vendor "Medical Informatics Engineering"
diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile
index 9057d304..7ce61a29 100644
--- a/mie-opensource-landing/Makefile
+++ b/mie-opensource-landing/Makefile
@@ -11,14 +11,10 @@ DESTDIR ?= /
 PKG_NAME := opensource-docs
 SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site
 
-# Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
+# name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
-# ARCH=all (content-only payload); FPM_DIRS are the top-level staged dirs.
-ARCH     := all
-FPM_DIRS := opt
-
 .PHONY: deps build dev install deb rpm apk package clean
 
 deps:
@@ -36,19 +32,12 @@ install: build
 	install -d $(SITE_DEST)
 	cp -a site/. $(SITE_DEST)/
 
-# Stage into a dedicated .nfpm/buildroot and package it; nfpm.yaml interpolates the
-# staging path via ${DESTDIR}.
-# Stage into a dedicated .pkg/buildroot and build the package with fpm. Static
-# options live in opensource-docs.fpm; version/type/arch/staging are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	fpm --fpm-options-file opensource-docs.fpm \
-		-s dir -t $(PACKAGER) -a $(ARCH) \
-		-v "$$(../package-version $(PACKAGER))" \
-		-C $(STAGE) -p $(CURDIR) -f \
-		$(FPM_DIRS)
+	fpm -s dir -t $(PACKAGER) -v "$$(../package-version $(PACKAGER))" \
+		-C $(STAGE) -p $(CURDIR) -f
 
 deb:
 	$(MAKE) package PACKAGER=deb
diff --git a/pull-config/opensource-agent.fpm b/pull-config/.fpm
similarity index 97%
rename from pull-config/opensource-agent.fpm
rename to pull-config/.fpm
index 5003bae3..eb462614 100644
--- a/pull-config/opensource-agent.fpm
+++ b/pull-config/.fpm
@@ -1,4 +1,5 @@
 --name opensource-agent
+--architecture all
 --license Apache-2.0
 --maintainer "Medical Informatics Engineering "
 --vendor "Medical Informatics Engineering"
diff --git a/pull-config/Makefile b/pull-config/Makefile
index 33e18f20..0fc0fc8e 100644
--- a/pull-config/Makefile
+++ b/pull-config/Makefile
@@ -9,21 +9,14 @@
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
 
-# Dedicated staging directory for packaging (never a user-supplied DESTDIR).
-# Not named ".fpm" — fpm treats a file/dir of that name as an rc options file.
+# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
+# name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
-# Package metadata not in the .fpm file. ARCH=all (arch-independent payload).
-# FPM_DIRS are the top-level directories under the staging root to package.
-ARCH     := all
-FPM_DIRS := etc opt var
-
 INSTALL      := install
 INSTALL_DATA := $(INSTALL) -m 0644
 INSTALL_PROG := $(INSTALL) -m 0755
 
-# The static error pages live at the repository root and are referenced by the
-# manager-rendered nginx config; they ship with the agent package.
 ERROR_PAGES := ../error-pages
 
 .PHONY: deps build dev install deb rpm apk package clean
@@ -46,18 +39,12 @@ install: build
 	# Forward-auth cache directory used by the manager-rendered nginx config.
 	$(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache
 
-# Stage into a dedicated .fpm/buildroot and build the package with fpm. Static
-# options live in opensource-agent.fpm; the version (composed per format by
-# ../package-version), output type, arch and staging dir are passed here.
 PACKAGER ?= deb
 package:
 	rm -rf $(STAGE)
 	$(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX)
-	fpm --fpm-options-file opensource-agent.fpm \
-		-s dir -t $(PACKAGER) -a $(ARCH) \
-		-v "$$(../package-version $(PACKAGER))" \
-		-C $(STAGE) -p $(CURDIR) -f \
-		$(FPM_DIRS)
+	fpm -s dir -t $(PACKAGER) -v "$$(../package-version $(PACKAGER))" \
+		-C $(STAGE) -p $(CURDIR) -f
 
 deb:
 	$(MAKE) package PACKAGER=deb

From c7c22f7c5a6ccb0a5647fde84aea1c7125c7edc9 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 18:45:24 -0400
Subject: [PATCH 34/42] Install fpm in the builder image instead of nfpm

Replace the nfpm .deb install with ruby + fpm via gem.
---
 images/builder/Dockerfile | 19 ++++++++-----------
 1 file changed, 8 insertions(+), 11 deletions(-)

diff --git a/images/builder/Dockerfile b/images/builder/Dockerfile
index a7930698..236c4b9f 100644
--- a/images/builder/Dockerfile
+++ b/images/builder/Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 # Builds the three opensource-server Debian packages with the component
-# Makefiles + nfpm. The final stage contains only the built .deb artifacts so
+# Makefiles + fpm. The final stage contains only the built .deb artifacts so
 # the runtime images can install them with:
 #
 #   COPY --from=builder /dist/*.deb /tmp/debs/
@@ -11,14 +11,17 @@
 FROM debian:13 AS build
 
 # Build toolchain: make/git for the component Makefiles, Node.js (with npm)
-# from NodeSource for the manager build, uv for the docs build, and nfpm for
-# packaging.
+# from NodeSource for the manager build, uv for the docs build, and fpm (a Ruby
+# gem) for packaging.
 RUN apt-get update && \
     apt-get install -y --no-install-recommends \
         ca-certificates \
         curl \
         git \
         make \
+        ruby \
+        ruby-dev \
+        build-essential \
     && \
     apt-get clean && \
     rm -rf /var/lib/apt/lists/*
@@ -35,14 +38,8 @@ RUN curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/
     | tar -xzf - --strip-components=1 -C /usr/local/bin \
       uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx
 
-ARG NFPM_VERSION=2.46.3
-RUN curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_amd64.deb" \
-      -o /tmp/nfpm.deb && \
-    apt-get update && \
-    apt-get install -y /tmp/nfpm.deb && \
-    rm /tmp/nfpm.deb && \
-    apt-get clean && \
-    rm -rf /var/lib/apt/lists/*
+ARG FPM_VERSION=1.16.0
+RUN gem install --no-document fpm -v "${FPM_VERSION}"
 
 COPY . /usr/src/opensource-server
 WORKDIR /usr/src/opensource-server

From 0b0f7447cb46d1f7e4e316aa16a77fbd5a77f2e4 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 19:08:35 -0400
Subject: [PATCH 35/42] Document fpm packaging in the release pipeline

Update release-pipeline.md to describe the fpm-based packaging: .fpm
options files, the package-version composer, fpm's dir input from the
staging root, and explicit config-file marking. Correct the note that
pull-config instances are program code, not config files.
---
 .../docs/developers/release-pipeline.md       | 44 ++++++++++---------
 1 file changed, 24 insertions(+), 20 deletions(-)

diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md
index e4389643..1ddfe8dd 100644
--- a/mie-opensource-landing/docs/developers/release-pipeline.md
+++ b/mie-opensource-landing/docs/developers/release-pipeline.md
@@ -29,7 +29,7 @@ The default goal is `build`.
 | `build` | Compile the component (default goal); depends on `deps` |
 | `install` | Stage built files into `DESTDIR` at their final paths; depends on `build` |
 | `dev` | Run the development watch loop; depends on `deps` |
-| `deb` / `rpm` / `apk` | Stage and package with [nfpm](https://nfpm.goreleaser.com); depend on `install` |
+| `deb` / `rpm` / `apk` | Stage and package with [fpm](https://fpm.readthedocs.io/); depend on `install` |
 
 Variables (overridable):
 
@@ -74,34 +74,38 @@ uses these same targets: the `client` service runs `make dev-client` (the
 server runs inside the Proxmox container) and the `zensical` service runs
 `make dev`.
 
-## Packaging with nfpm
+## Packaging with fpm
 
-Each component has an `nfpm.yaml` that packages the staged tree (`type: tree`)
-plus any config files and maintainer scripts. nfpm produces deb, rpm, and apk
-from the same definition, so `make rpm` and `make apk` also work.
+Each component has a `.fpm` options file holding the static package metadata
+(name, architecture, dependencies, description, scripts, config files). The
+Makefile's `package` target stages the component into a `.pkg/buildroot` and
+runs [fpm](https://fpm.readthedocs.io/) with the dynamic options on the command
+line — output type, version (composed per format by `./package-version`), and
+the staging dir. fpm's `dir` input copies the staged tree verbatim from
+`-C .pkg/buildroot`, preserving symlinks (e.g. `node_modules/.bin/sequelize`)
+and the directory layout. The same definition produces deb, rpm, and apk, so
+`make rpm` and `make apk` also work.
 
 - `opensource-server` ships the `container-creator` and `job-runner` systemd
-  units and enables them via an nfpm `postinstall` script (`preremove` disables
-  them on real removal). The log directory is created on demand by the unit's
-  `LogsDirectory`, not shipped in the package. The logrotate drop-in is a config
-  file. The `container-creator-init` unit (which provisions a *local*
-  PostgreSQL) is **not** in the package — it is part of the manager image, since
-  the package only suggests postgresql and works with a remote database too.
-- `opensource-agent` ships the pull-config instances and cron schedule as
-  config files so admin customizations survive upgrades.
+  units and enables them via an `after-install` script (`before-remove`
+  disables them on real removal). The log directory is created on demand by the
+  unit's `LogsDirectory`, not shipped in the package. The logrotate drop-in is
+  the only config file (`--config-files`). The `container-creator-init` unit
+  (which provisions a *local* PostgreSQL) is **not** in the package — it is part
+  of the manager image, since the package only suggests postgresql and works
+  with a remote database too.
+- `opensource-agent` ships the pull-config engine, instances, cron schedule and
+  the static error pages. These are program code, not configuration (runtime
+  config comes from `/etc/environment`), so nothing is marked as a config file.
 - `opensource-docs` ships content only.
 
-Each `nfpm.yaml` declares its `/etc` files explicitly as `config` and packages
-the rest of the staged tree per top-level directory (`/opt`, `/usr`, `/var`).
-This keeps conffile tagging without any file appearing in both a `tree` and a
-`config` entry (which nfpm rejects), so `make install` and `make package` never
-fight. `make package` reuses the `DESTDIR` install in `build-root` rather than
-mutating it — run `make clean` first for a guaranteed-pristine package.
+Config files are marked explicitly with `--config-files`; `--deb-no-default-config-files`
+stops fpm/dpkg from auto-marking everything under `/etc` as a conffile.
 
 ## Container images
 
 The [`builder`](https://github.com/mieweb/opensource-server/blob/main/images/builder/Dockerfile)
-image runs `make deb` (Node.js, uv, and nfpm) to produce all three packages,
+image runs `make deb` (Node.js, uv, and fpm) to produce all three packages,
 exported as an artifact-only stage. The `docs`, `agent`, and `manager` images
 install those packages via a Docker Bake `builder` context instead of copying
 the repository. The packages are bind-mounted (no extra image layer):

From f11560e3ada2c84a0f4d9622cebce6e5da228f4f Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 19:12:15 -0400
Subject: [PATCH 36/42] remove legacy pull-config installer

---
 pull-config/install.sh | 55 ------------------------------------------
 1 file changed, 55 deletions(-)
 delete mode 100755 pull-config/install.sh

diff --git a/pull-config/install.sh b/pull-config/install.sh
deleted file mode 100755
index cee74a55..00000000
--- a/pull-config/install.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# Installation script for pull-config
-# This script copies configuration files into system directories
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-
-echo "Installing pull-config..."
-
-# Copy cron job
-echo "Installing cron job to /etc/cron.d/pull-config..."
-install -m 644 "${SCRIPT_DIR}/etc/cron.d/pull-config" /etc/cron.d/pull-config
-
-# Create instance configuration directory
-echo "Creating /etc/pull-config.d/..."
-mkdir -p /etc/pull-config.d
-
-# Copy instance scripts
-echo "Installing instance scripts to /etc/pull-config.d/..."
-for script in "${SCRIPT_DIR}/etc/pull-config.d"/*; do
-  if [[ -f "${script}" ]]; then
-    instance=$(basename "${script}")
-    echo "  - Installing ${instance} instance"
-    install -m 755 "${script}" "/etc/pull-config.d/${instance}"
-  fi
-done
-
-# Make sure the main binary is executable
-echo "Setting executable permission on /opt/opensource-server/pull-config/bin/pull-config..."
-chmod +x "${SCRIPT_DIR}/bin/pull-config"
-
-echo ""
-echo "Installation complete!"
-echo ""
-echo "Installed instances:"
-for script in /etc/pull-config.d/*; do
-  if [[ -f "${script}" && -x "${script}" ]]; then
-    echo "  - $(basename "${script}")"
-  fi
-done
-echo ""
-echo "Instance scripts can be customized in /etc/pull-config.d/"
-echo "Each instance runs independently via cron every minute using run-parts"
-echo ""
-echo "To add a new instance:"
-echo "  1. Create an executable script in /etc/pull-config.d/"
-echo "  2. Set required environment variables (CONF_FILE, CONF_URL)"
-echo "  3. Call: exec /opt/opensource-server/pull-config/bin/pull-config"
-echo ""
-echo "To test an instance manually:"
-echo "  sudo /etc/pull-config.d/nginx"
-
-

From e908ca979f2a138b287e0daaaed7566a986d20f9 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 19:16:09 -0400
Subject: [PATCH 37/42] Make help the default goal in every Makefile

Add a help target listing the available targets and variables to the
top-level and each component Makefile, and set it as .DEFAULT_GOAL so a
bare 'make' prints usage instead of building. Also drop the stale nfpm
references from the create-a-container header comment.
---
 Makefile                        | 25 +++++++++++++++++++++----
 create-a-container/Makefile     | 32 ++++++++++++++++++++++----------
 mie-opensource-landing/Makefile | 22 +++++++++++++++++++---
 pull-config/Makefile            | 19 +++++++++++++++++--
 4 files changed, 79 insertions(+), 19 deletions(-)

diff --git a/Makefile b/Makefile
index 3e8edcdd..528015b2 100644
--- a/Makefile
+++ b/Makefile
@@ -5,13 +5,13 @@
 #   mie-opensource-landing -> opensource-docs
 #   pull-config          -> opensource-agent
 #
-# Each component supports: deps, build (default), install, dev, deb/rpm/apk.
-# Variables pass straight through:
+# Each component supports: deps, build, install, dev, deb/rpm/apk. Run `make
+# help` for details. Variables pass straight through:
 #   PREFIX   vendor install prefix (default /opt/opensource-server)
 #   DESTDIR  staging root for `install` (default /)
 # The package version is derived from git by ./package-version.
 
-.DEFAULT_GOAL := build
+.DEFAULT_GOAL := help
 
 COMPONENTS := pull-config mie-opensource-landing create-a-container
 PACKAGER   ?= deb
@@ -20,7 +20,24 @@ PACKAGER   ?= deb
 MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \
             $(if $(DESTDIR),DESTDIR=$(DESTDIR),)
 
-.PHONY: deps build install dev deb rpm apk clean $(COMPONENTS)
+.PHONY: help deps build install dev deb rpm apk clean $(COMPONENTS)
+
+help:
+	@echo "opensource-server — delegates to each component's Makefile."
+	@echo ""
+	@echo "Targets (run across all components):"
+	@echo "  deps     install build/runtime dependencies"
+	@echo "  build    build all components"
+	@echo "  install  stage component files into DESTDIR (default /)"
+	@echo "  deb      build .deb packages, collected into ./dist"
+	@echo "  rpm      build .rpm packages, collected into ./dist"
+	@echo "  apk      build .apk packages, collected into ./dist"
+	@echo "  clean    remove build artifacts, staging, packages and ./dist"
+	@echo "  dev      print the per-component dev commands"
+	@echo "  help     show this message"
+	@echo ""
+	@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
+	@echo "The package version is derived from git by ./package-version."
 
 deps build install:
 	@for c in $(COMPONENTS); do \
diff --git a/create-a-container/Makefile b/create-a-container/Makefile
index a5c287b4..45c07f0f 100644
--- a/create-a-container/Makefile
+++ b/create-a-container/Makefile
@@ -2,21 +2,16 @@
 #
 # Standard component contract shared across the repository:
 #   deps     install build/runtime dependencies
-#   build    (default) compile the component; depends on deps
+#   build    compile the component; depends on deps
 #   install  stage built files into DESTDIR; depends on build
 #   dev      run the development watch loop; depends on deps
-#   deb/rpm/apk  package the staged tree with nfpm; depend on install
+#   deb/rpm/apk  package the staged tree with fpm; depend on install
 #
 # PREFIX is the vendor install prefix every runtime reference in the project
 # expects (systemd units, the manager-rendered nginx config). DESTDIR is the
-# staging root. VERSION is derived from git but can be overridden.
-#
-# Packaging stages into a dedicated .nfpm/buildroot (never a user-supplied DESTDIR)
-# and runs nfpm from the component directory; nfpm.yaml interpolates the
-# staging path via ${DESTDIR} (expand: true), so it stays DRY and works for
-# any DESTDIR.
+# staging root. Run `make help` for the full target list.
 
-.DEFAULT_GOAL := build
+.DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
@@ -36,7 +31,24 @@ APP_FILES := server.js job-runner.js package.json package-lock.json openapi.v1.y
 APP_DIRS  := bin config data middlewares migrations models node_modules \
              public routers seeders utils views
 
-.PHONY: deps build dev dev-client install deb rpm apk package clean
+.PHONY: help deps build dev dev-client install deb rpm apk package clean
+
+help:
+	@echo "create-a-container — builds the opensource-server package."
+	@echo ""
+	@echo "Targets:"
+	@echo "  deps        install dependencies (npm ci, server + client)"
+	@echo "  build       build the web client bundle"
+	@echo "  install     stage the app into DESTDIR (default /)"
+	@echo "  dev         run server + client watch (npm run dev)"
+	@echo "  dev-client  run only the client bundle watcher"
+	@echo "  deb         build the .deb package"
+	@echo "  rpm         build the .rpm package"
+	@echo "  apk         build the .apk package"
+	@echo "  clean       remove the staging dir, build output and built packages"
+	@echo "  help        show this message"
+	@echo ""
+	@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
 
 deps:
 	npm ci --omit=dev
diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile
index 7ce61a29..8da917e6 100644
--- a/mie-opensource-landing/Makefile
+++ b/mie-opensource-landing/Makefile
@@ -1,9 +1,9 @@
 # mie-opensource-landing — Makefile
 #
 # Standard component contract (see create-a-container/Makefile for details):
-#   deps / build (default) / install / dev / deb / rpm / apk
+#   deps / build / install / dev / deb / rpm / apk
 
-.DEFAULT_GOAL := build
+.DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
@@ -15,7 +15,23 @@ SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site
 # name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
-.PHONY: deps build dev install deb rpm apk package clean
+.PHONY: help deps build dev install deb rpm apk package clean
+
+help:
+	@echo "mie-opensource-landing — builds the opensource-docs package."
+	@echo ""
+	@echo "Targets:"
+	@echo "  deps     install dependencies (uv sync)"
+	@echo "  build    build the documentation site (zensical build)"
+	@echo "  install  stage the site into DESTDIR (default /)"
+	@echo "  dev      run the live-reloading docs server (zensical serve)"
+	@echo "  deb      build the .deb package"
+	@echo "  rpm      build the .rpm package"
+	@echo "  apk      build the .apk package"
+	@echo "  clean    remove the staging dir, build output and built packages"
+	@echo "  help     show this message"
+	@echo ""
+	@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
 
 deps:
 	uv sync --frozen
diff --git a/pull-config/Makefile b/pull-config/Makefile
index 0fc0fc8e..14177087 100644
--- a/pull-config/Makefile
+++ b/pull-config/Makefile
@@ -4,7 +4,7 @@
 # pull-config is plain bash, so deps/build/dev are no-ops; install stages the
 # engine, instances, cron schedule and the shared error pages.
 
-.DEFAULT_GOAL := build
+.DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
@@ -19,7 +19,22 @@ INSTALL_PROG := $(INSTALL) -m 0755
 
 ERROR_PAGES := ../error-pages
 
-.PHONY: deps build dev install deb rpm apk package clean
+.PHONY: help deps build dev install deb rpm apk package clean
+
+help:
+	@echo "pull-config — builds the opensource-agent package."
+	@echo ""
+	@echo "Targets:"
+	@echo "  deps     install dependencies (none; plain bash)"
+	@echo "  build    build the component (nothing to compile)"
+	@echo "  install  stage files into DESTDIR (default /)"
+	@echo "  deb      build the .deb package"
+	@echo "  rpm      build the .rpm package"
+	@echo "  apk      build the .apk package"
+	@echo "  clean    remove the staging dir and built packages"
+	@echo "  help     show this message"
+	@echo ""
+	@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
 
 # Plain bash; nothing to install, compile, or watch.
 deps:

From 4329e32a79a07cf571acbe44d7c605cc6a6602f3 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Sun, 14 Jun 2026 19:56:45 -0400
Subject: [PATCH 38/42] cleanup

---
 .github/workflows/build-images.yml            | 41 +-----------
 .github/workflows/release.yml                 |  3 -
 Makefile                                      | 26 +------
 compose.yml                                   |  5 --
 create-a-container/Makefile                   | 40 +----------
 create-a-container/contrib/postinstall.sh     |  3 +-
 create-a-container/contrib/preremove.sh       |  3 +-
 .../contrib/systemd/container-creator.service |  3 -
 images/agent/Dockerfile                       | 12 +---
 images/builder/Dockerfile                     |  8 +--
 images/docker-bake.hcl                        |  5 +-
 images/docs/Dockerfile                        |  4 +-
 images/manager/Dockerfile                     | 17 ++---
 images/manager/container-creator-init.service |  4 --
 mie-opensource-landing/.fpm                   |  1 -
 mie-opensource-landing/Makefile               |  9 ---
 .../docs/developers/docker-images.md          | 14 +---
 .../docs/developers/pull-config.md            |  3 +-
 .../docs/developers/release-pipeline.md       | 67 +++----------------
 package-version                               |  8 +--
 pull-config/Makefile                          | 12 ----
 21 files changed, 37 insertions(+), 251 deletions(-)

diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml
index 928a1f5f..c80584c7 100644
--- a/.github/workflows/build-images.yml
+++ b/.github/workflows/build-images.yml
@@ -2,39 +2,14 @@ name: Build and Push Images
 
 on:
   push:
-    branches:
-      - '**'
-    tags:
-      - '**'
-    paths:
-      - 'images/**'
-      - 'create-a-container/**'
-      - 'mie-opensource-landing/**'
-      - 'pull-config/**'
-      - 'error-pages/**'
-      - '**/Makefile'
-      - '**/nfpm.yaml'
-      - '.dockerignore'
   pull_request:
     types: [opened, synchronize, reopened, closed]
-    paths:
-      - 'images/**'
-      - 'create-a-container/**'
-      - 'mie-opensource-landing/**'
-      - 'pull-config/**'
-      - 'error-pages/**'
-      - '**/Makefile'
-      - '**/nfpm.yaml'
-      - '.dockerignore'
   schedule:
     # Run weekly on Sunday at 11:00 PM UTC (Sunday-Monday night depending on timezone)
     - cron: '0 23 * * 0'
   workflow_dispatch:
-  # Images are tagged :latest only from release events for non-prerelease
-  # releases. "released" additionally covers a pre-release later being
-  # promoted to a full release.
   release:
-    types: [published, released]
+    types: [published]
 
 env:
   REGISTRY: ghcr.io
@@ -50,8 +25,6 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v4
         with:
-          # Full history + tags so the builder image's `git describe` derives
-          # the package version.
           fetch-depth: 0
           persist-credentials: false
 
@@ -71,8 +44,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/base
           bake-target: base
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
@@ -87,8 +58,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/nodejs
           bake-target: nodejs
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
@@ -103,8 +72,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/docs
           bake-target: docs
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
@@ -119,8 +86,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/agent
           bake-target: agent
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
@@ -134,8 +99,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/manager
           bake-target: manager
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
@@ -149,8 +112,6 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ github.repository }}/proxmox-ve
           bake-target: proxmox-ve
-          # Disable auto-latest (fires for type=ref,event=tag); :latest is
-          # controlled solely by the explicit type=raw rule below.
           flavor: latest=false
           tags: |
             type=sha
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 616af1b3..43360594 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,9 +26,6 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v4
         with:
-          # The release tag, with full history so `git describe` (the version
-          # source) sees it. Falls back to the dispatch ref for manual runs.
-          ref: ${{ github.event.release.tag_name || github.ref }}
           fetch-depth: 0
           persist-credentials: false
 
diff --git a/Makefile b/Makefile
index 528015b2..430907f9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,16 +1,3 @@
-# opensource-server — top-level Makefile
-#
-# Delegates the standard component contract to each component's own Makefile:
-#   create-a-container   -> opensource-server
-#   mie-opensource-landing -> opensource-docs
-#   pull-config          -> opensource-agent
-#
-# Each component supports: deps, build, install, dev, deb/rpm/apk. Run `make
-# help` for details. Variables pass straight through:
-#   PREFIX   vendor install prefix (default /opt/opensource-server)
-#   DESTDIR  staging root for `install` (default /)
-# The package version is derived from git by ./package-version.
-
 .DEFAULT_GOAL := help
 
 COMPONENTS := pull-config mie-opensource-landing create-a-container
@@ -20,7 +7,7 @@ PACKAGER   ?= deb
 MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \
             $(if $(DESTDIR),DESTDIR=$(DESTDIR),)
 
-.PHONY: help deps build install dev deb rpm apk clean $(COMPONENTS)
+.PHONY: help deps build install deb rpm apk clean
 
 help:
 	@echo "opensource-server — delegates to each component's Makefile."
@@ -33,7 +20,6 @@ help:
 	@echo "  rpm      build .rpm packages, collected into ./dist"
 	@echo "  apk      build .apk packages, collected into ./dist"
 	@echo "  clean    remove build artifacts, staging, packages and ./dist"
-	@echo "  dev      print the per-component dev commands"
 	@echo "  help     show this message"
 	@echo ""
 	@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
@@ -60,16 +46,8 @@ deb rpm apk:
 	@for c in $(COMPONENTS); do \
 		echo "==> $$c: $@"; \
 		$(MAKE) -C $$c $@ $(MAKE_VARS) || exit $$?; \
-		cp -f $$c/*.$@ dist/ 2>/dev/null || true; \
+		cp -f $$c/*.$@ dist/; \
 	done
 	@echo ""
 	@echo "Packages collected in dist/:"
 	@ls -1 dist/
-
-# `make dev` isn't meaningful for the whole repo (each watcher is long-running);
-# run it per component, e.g. `make -C create-a-container dev`.
-dev:
-	@echo "Run 'dev' per component, e.g.:"
-	@echo "  make -C create-a-container dev        # server + client watch"
-	@echo "  make -C create-a-container dev-client # client watch only"
-	@echo "  make -C mie-opensource-landing dev    # docs live server"
diff --git a/compose.yml b/compose.yml
index 9790337f..429538d8 100644
--- a/compose.yml
+++ b/compose.yml
@@ -32,8 +32,6 @@ services:
   # node_modules are populated in the user's create-a-container directory. This
   # is usually handled by the manager's Dockerfile, but we're mounting over that
   # directory so we need to be sure the user has them.
-  #
-  # Mirrors create-a-container's `make deps` (the slim image has no make).
   node:
     image: node:24-trixie-slim
     volumes:
@@ -56,7 +54,6 @@ services:
   # Like zensical, this is a development convenience and not a hard dependency:
   # it does an initial build so a dist always exists, then watches for changes.
   # The server itself runs inside Proxmox, so this only runs the client watcher.
-  # Mirrors create-a-container's `make dev-client` (the slim image has no make).
   client:
     image: node:24-trixie-slim
     volumes:
@@ -84,8 +81,6 @@ services:
   # direct dependency on this because it's meant to be a development convenience
   # not a hard dependency. The Proxmox service works just fine if the docs are
   # not getting rebuilt or even if they were never built in the first place.
-  #
-  # Mirrors mie-opensource-landing's `make dev` (the uv image has no make).
   zensical:
     image: astral/uv:0.11.14-trixie-slim
     volumes:
diff --git a/create-a-container/Makefile b/create-a-container/Makefile
index 45c07f0f..0d52453b 100644
--- a/create-a-container/Makefile
+++ b/create-a-container/Makefile
@@ -1,16 +1,3 @@
-# create-a-container — Makefile
-#
-# Standard component contract shared across the repository:
-#   deps     install build/runtime dependencies
-#   build    compile the component; depends on deps
-#   install  stage built files into DESTDIR; depends on build
-#   dev      run the development watch loop; depends on deps
-#   deb/rpm/apk  package the staged tree with fpm; depend on install
-#
-# PREFIX is the vendor install prefix every runtime reference in the project
-# expects (systemd units, the manager-rendered nginx config). DESTDIR is the
-# staging root. Run `make help` for the full target list.
-
 .DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
@@ -18,8 +5,7 @@ DESTDIR ?= /
 DESTBIN  := $(DESTDIR)$(PREFIX)/create-a-container
 UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system
 
-# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
-# name as an rc options file.
+# Staging directory for packaging
 STAGE := $(CURDIR)/.pkg/buildroot
 
 INSTALL      := install
@@ -31,7 +17,7 @@ APP_FILES := server.js job-runner.js package.json package-lock.json openapi.v1.y
 APP_DIRS  := bin config data middlewares migrations models node_modules \
              public routers seeders utils views
 
-.PHONY: help deps build dev dev-client install deb rpm apk package clean
+.PHONY: help deps build dev install deb rpm apk package clean
 
 help:
 	@echo "create-a-container — builds the opensource-server package."
@@ -41,7 +27,6 @@ help:
 	@echo "  build       build the web client bundle"
 	@echo "  install     stage the app into DESTDIR (default /)"
 	@echo "  dev         run server + client watch (npm run dev)"
-	@echo "  dev-client  run only the client bundle watcher"
 	@echo "  deb         build the .deb package"
 	@echo "  rpm         build the .rpm package"
 	@echo "  apk         build the .apk package"
@@ -56,41 +41,20 @@ deps:
 
 build: deps
 	npm --prefix client run build
-	# Prune npm packaging cruft and foreign-platform / musl prebuilds; only
-	# linux-x64 glibc prebuilds are relevant for an amd64 package.
-	find node_modules \( -name '.eslintrc*' -o -name '.npmignore' -o -name '.eslintignore' \) -delete
-	for d in $$(find node_modules -type d -name prebuilds); do \
-		find "$$d" -mindepth 1 -maxdepth 1 ! -name 'linux-x64' -exec rm -rf {} +; \
-		rm -f "$$d"/linux-x64/*.musl.node; \
-	done
 
 # Full local development: server (nodemon) + client (vite watch) together.
 dev: deps
 	npm run dev
 
-# Client production-bundle watcher only. Used by the compose `client` service,
-# where the server runs inside the Proxmox container, not here.
-dev-client: deps
-	npm run client:build
-	npm run client:build:watch
-
-# Stage built files into DESTDIR. container-creator-init.service is NOT
-# installed here: provisioning a local PostgreSQL is an image/environment
-# choice (the package only suggests postgresql), so that unit lives in the
-# manager image.
 install: build
 	$(INSTALL) -d $(DESTBIN)
 	$(INSTALL_DATA) $(APP_FILES) $(DESTBIN)/
-	# cp -a preserves the symlinks in node_modules/.bin (e.g. sequelize,
-	# invoked by the systemd units) and file modes.
 	cp -a $(APP_DIRS) $(DESTBIN)/
 	$(INSTALL) -d $(DESTBIN)/client
 	cp -a client/dist $(DESTBIN)/client/
-	# systemd units (enabled by the package postinstall script)
 	$(INSTALL) -d $(UNIT_DIR)
 	$(INSTALL_DATA) contrib/systemd/container-creator.service $(UNIT_DIR)/
 	$(INSTALL_DATA) contrib/systemd/job-runner.service $(UNIT_DIR)/
-	# logrotate for the morgan access log
 	$(INSTALL) -d $(DESTDIR)/etc/logrotate.d
 	$(INSTALL_DATA) contrib/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server
 
diff --git a/create-a-container/contrib/postinstall.sh b/create-a-container/contrib/postinstall.sh
index 7071155b..e92c7acf 100755
--- a/create-a-container/contrib/postinstall.sh
+++ b/create-a-container/contrib/postinstall.sh
@@ -1,5 +1,4 @@
 #!/bin/sh
-# postinstall for opensource-server: enable the manager systemd units.
 set -e
 
 UNITS="container-creator.service job-runner.service"
@@ -8,7 +7,7 @@ UNITS="container-creator.service job-runner.service"
 command -v systemctl >/dev/null 2>&1 || exit 0
 
 # `systemctl enable` only creates static symlinks, so it works during an image
-# build too (no running systemd required) — the units come up on first boot.
+# build too
 systemctl enable $UNITS
 
 # daemon-reload and restart need a running systemd; skip them at build time.
diff --git a/create-a-container/contrib/preremove.sh b/create-a-container/contrib/preremove.sh
index e9012463..a3ebf879 100755
--- a/create-a-container/contrib/preremove.sh
+++ b/create-a-container/contrib/preremove.sh
@@ -1,5 +1,4 @@
 #!/bin/sh
-# preremove for opensource-server: stop and disable the manager systemd units.
 set -e
 
 UNITS="container-creator.service job-runner.service"
@@ -13,7 +12,7 @@ case "${1:-}" in
         ;;
 esac
 
-# Nothing to do without systemctl (non-systemd container/chroot).
+# Nothing to do without systemctl
 command -v systemctl >/dev/null 2>&1 || exit 0
 
 # Stopping needs a running systemd; disabling (symlink removal) does not.
diff --git a/create-a-container/contrib/systemd/container-creator.service b/create-a-container/contrib/systemd/container-creator.service
index fa936f3a..f1ed7eba 100644
--- a/create-a-container/contrib/systemd/container-creator.service
+++ b/create-a-container/contrib/systemd/container-creator.service
@@ -12,10 +12,7 @@ Restart=on-failure
 Environment=NODE_ENV=production
 Environment=ACCESS_LOG=/var/log/opensource-server/access.log
 EnvironmentFile=/etc/default/container-creator
-# Create and manage /var/log/opensource-server on demand instead of shipping
-# an empty directory in the package.
 LogsDirectory=opensource-server
-LogsDirectoryMode=0755
 
 [Install]
 WantedBy=multi-user.target
diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile
index bd5d0863..c46446d9 100644
--- a/images/agent/Dockerfile
+++ b/images/agent/Dockerfile
@@ -2,25 +2,17 @@
 FROM nodejs
 
 # Stage the release APT source so `apt upgrade` pulls future releases of the
-# opensource-* packages automatically. Every release (including the bootstrap
-# one) carries flat-repo metadata (an empty Packages index is enough), so
-# `apt-get update` against it succeeds throughout the build.
+# opensource-* packages automatically
 COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources
 
 # Install the agent package (and its dependencies: nginx with stream +
-# ModSecurity modules, dnsmasq, cron, ...) built by the builder image. The
-# package is bind-mounted from the builder stage rather than copied so it does
-# not add an image layer.
+# ModSecurity modules, dnsmasq, cron, ...) built by the builder image.
 RUN --mount=from=builder,source=/dist,target=/dist \
     apt-get update && \
     apt-get install -y /dist/opensource-agent_*.deb && \
     apt-get clean && \
     rm -rf /var/lib/apt/lists/*
 
-# Everything below is image-level environment configuration that the package
-# does not own because it edits conffiles belonging to other packages
-# (nginx, modsecurity-crs, dnsmasq).
-
 # configure nginx ModSecurity defaults
 RUN sed -i \
         -e 's!^#\(include /usr/share/modsecurity-crs/owasp-crs.load\)$!\1!' \
diff --git a/images/builder/Dockerfile b/images/builder/Dockerfile
index 236c4b9f..a44ce17d 100644
--- a/images/builder/Dockerfile
+++ b/images/builder/Dockerfile
@@ -11,8 +11,8 @@
 FROM debian:13 AS build
 
 # Build toolchain: make/git for the component Makefiles, Node.js (with npm)
-# from NodeSource for the manager build, uv for the docs build, and fpm (a Ruby
-# gem) for packaging.
+# from NodeSource for the manager build, uv for the docs build, and fpm for
+# packaging.
 RUN apt-get update && \
     apt-get install -y --no-install-recommends \
         ca-certificates \
@@ -44,10 +44,6 @@ RUN gem install --no-document fpm -v "${FPM_VERSION}"
 COPY . /usr/src/opensource-server
 WORKDIR /usr/src/opensource-server
 
-# git marks the COPYed tree as foreign-owned; allow it so `git describe` (the
-# version source) works.
-RUN git config --global --add safe.directory /usr/src/opensource-server
-
 # Build all three packages into ./dist.
 RUN make deb
 
diff --git a/images/docker-bake.hcl b/images/docker-bake.hcl
index 8a5e4e8b..0977e607 100644
--- a/images/docker-bake.hcl
+++ b/images/docker-bake.hcl
@@ -13,10 +13,7 @@ target "nodejs" {
     }
 }
 
-# Builds the three Debian packages consumed by the docs, agent and manager
-# images. Not part of the default group: built on demand as a dependency, and
-# exported by CI with `docker buildx bake builder --set
-# builder.output=type=local,dest=dist`.
+# Builds the Debian packages consumed by the other images.
 target "builder" {
     context = "../"
     dockerfile = "images/builder/Dockerfile"
diff --git a/images/docs/Dockerfile b/images/docs/Dockerfile
index 301c7fee..dcaa8b45 100644
--- a/images/docs/Dockerfile
+++ b/images/docs/Dockerfile
@@ -4,9 +4,7 @@ FROM base
 # Stage the release APT source so `apt upgrade` pulls future releases.
 COPY images/opensource-server.sources /etc/apt/sources.list.d/opensource-server.sources
 
-# Install nginx and the docs package built by the builder image. The package
-# ships content only; serving it is this image's concern. Bind-mounted from
-# the builder stage so it does not add an image layer.
+# Install nginx and the docs package built by the builder image
 RUN --mount=from=builder,source=/dist,target=/dist \
     apt-get update && \
     apt-get install -y nginx /dist/opensource-docs_*.deb && \
diff --git a/images/manager/Dockerfile b/images/manager/Dockerfile
index e80a6c4a..4eb624eb 100644
--- a/images/manager/Dockerfile
+++ b/images/manager/Dockerfile
@@ -8,9 +8,7 @@ ENV MANAGER_URL=http://localhost:3000
 
 # Install Postgres 18 from the PGDG repository and enable remote access only
 # for TLS encrypted connections. Debian creates a self-signed cert for this so
-# we don't have to make our own. The opensource-server package only suggests
-# postgresql (a remote database is equally valid); this image opts into a
-# local one.
+# we don't have to make our own.
 RUN apt-get update && apt-get -y install postgresql-common \
     && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \
     && apt-get -y install postgresql-18 \
@@ -21,21 +19,16 @@ RUN apt-get update && apt-get -y install postgresql-common \
     && mkdir -p /etc/systemd/system/postgresql@.service.d
 
 # Proxmox injects a service which regenerates the snakeoil cert. This systemd
-# override prevents a race condition at boot. It is environment-specific, so
-# it lives in the image rather than the package.
+# override prevents a race condition at boot.
 COPY images/manager/wait-proxmox-regenerate-snakeoil.conf /etc/systemd/system/postgresql@.service.d/
 
-# First-boot database initialization. This provisions the *local* PostgreSQL
-# database, so it is part of this image (which installs PostgreSQL), not the
-# opensource-server package — which only suggests postgresql and works with a
-# remote database too.
-COPY images/manager/container-creator-init.service /usr/lib/systemd/system/container-creator-init.service
+# First-boot database initialization.
+COPY images/manager/container-creator-init.service /etc/systemd/system/container-creator-init.service
 
 # Install the manager and docs packages built by the builder image (the agent
 # package is already present from the parent image; apt resolves the
 # opensource-server -> opensource-docs dependency), then enable the init
-# service. The package postinstall enables the manager units, and systemd
-# LogsDirectory creates /var/log/opensource-server on demand.
+# service.
 RUN --mount=from=builder,source=/dist,target=/dist \
     apt-get update && \
     apt-get install -y /dist/opensource-docs_*.deb /dist/opensource-server_*.deb && \
diff --git a/images/manager/container-creator-init.service b/images/manager/container-creator-init.service
index 23513902..8c9feac2 100644
--- a/images/manager/container-creator-init.service
+++ b/images/manager/container-creator-init.service
@@ -1,9 +1,5 @@
 [Unit]
 Description=Initialize PostgreSQL for Container Creator
-# This unit provisions a *local* PostgreSQL database for the manager. It is
-# part of the manager image (which installs PostgreSQL), not the
-# opensource-server package — the package only suggests postgresql and works
-# equally with a remote database. Hence postgresql is a hard requirement here.
 Requires=postgresql.service
 After=postgresql.service
 Before=container-creator.service
diff --git a/mie-opensource-landing/.fpm b/mie-opensource-landing/.fpm
index cb7e1519..df34938a 100644
--- a/mie-opensource-landing/.fpm
+++ b/mie-opensource-landing/.fpm
@@ -7,4 +7,3 @@
 --category doc
 --description "MIE opensource-server documentation site (static content). Ships the prebuilt site under /opt/opensource-server/mie-opensource-landing; serving it is a deployment concern."
 --deb-suggests nginx
-
diff --git a/mie-opensource-landing/Makefile b/mie-opensource-landing/Makefile
index 8da917e6..75ab5417 100644
--- a/mie-opensource-landing/Makefile
+++ b/mie-opensource-landing/Makefile
@@ -1,8 +1,3 @@
-# mie-opensource-landing — Makefile
-#
-# Standard component contract (see create-a-container/Makefile for details):
-#   deps / build / install / dev / deb / rpm / apk
-
 .DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
@@ -11,8 +6,6 @@ DESTDIR ?= /
 PKG_NAME := opensource-docs
 SITE_DEST := $(DESTDIR)$(PREFIX)/mie-opensource-landing/site
 
-# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
-# name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
 .PHONY: help deps build dev install deb rpm apk package clean
@@ -39,8 +32,6 @@ deps:
 build: deps
 	uv run --frozen zensical build
 
-# Live-reloading docs server. Honors VIRTUAL_ENV when set (compose sets
-# VIRTUAL_ENV=/opt/zensical); otherwise uses the project .venv from `deps`.
 dev: deps
 	uv run zensical serve
 
diff --git a/mie-opensource-landing/docs/developers/docker-images.md b/mie-opensource-landing/docs/developers/docker-images.md
index 18b1000a..7162f02f 100644
--- a/mie-opensource-landing/docs/developers/docker-images.md
+++ b/mie-opensource-landing/docs/developers/docker-images.md
@@ -22,17 +22,13 @@ Extends base with Node.js 24 from NodeSource. Inherits LDAP authentication.
 
 ### Agent (`agent`)
 
-Extends nodejs with the `opensource-agent` package (pull-config, nginx with
-ModSecurity/OWASP CRS, dnsmasq) and [acme.sh](https://github.com/acmesh-official/acme.sh)
-for ACME certificate management. Used as the networking layer for each site —
-handles reverse proxy, DNS, and TLS. See [Deploying Agents](../admins/deploying-agents.md).
+Extends nodejs with the `opensource-agent` package (pull-config, nginx with ModSecurity/OWASP CRS, dnsmasq) and [acme.sh](https://github.com/acmesh-official/acme.sh) for ACME certificate management. Used as the networking layer for each site — handles reverse proxy, DNS, and TLS. See [Deploying Agents](../admins/deploying-agents.md).
 
 **Registry:** `ghcr.io/mieweb/opensource-server/agent` · **Source:** [`images/agent/`](https://github.com/mieweb/opensource-server/tree/main/images/agent)
 
 ### Manager (`manager`)
 
-Extends agent with PostgreSQL 18 and the `opensource-server` + `opensource-docs`
-packages. Runs the full management application. See [Installation Guide](../admins/installation.md).
+Extends agent with PostgreSQL 18 and the `opensource-server` + `opensource-docs` packages. Runs the full management application. See [Installation Guide](../admins/installation.md).
 
 **Registry:** `ghcr.io/mieweb/opensource-server/manager` · **Source:** [`images/manager/`](https://github.com/mieweb/opensource-server/tree/main/images/manager)
 
@@ -40,11 +36,7 @@ packages. Runs the full management application. See [Installation Guide](../admi
 
 Images use **Docker Bake** (`docker buildx bake`) with [`images/docker-bake.hcl`](https://github.com/mieweb/opensource-server/blob/main/images/docker-bake.hcl) defining build order and dependencies. The `contexts` attribute ensures proper ordering (e.g., nodejs depends on base).
 
-The application payloads are **not** copied into the images. Instead, the
-`builder` target compiles the three Debian packages (`opensource-server`,
-`opensource-docs`, `opensource-agent`) and the docs/agent/manager images
-install them via a `builder` context. See [Release Pipeline](release-pipeline.md)
-for the packaging details.
+The application payloads are **not** copied into the images. Instead, the `builder` target compiles the three Debian packages (`opensource-server`, `opensource-docs`, `opensource-agent`) and the docs/agent/manager images install them via a `builder` context. See [Release Pipeline](release-pipeline.md) for the packaging details.
 
 ```
 images/
diff --git a/mie-opensource-landing/docs/developers/pull-config.md b/mie-opensource-landing/docs/developers/pull-config.md
index 3bc155c6..7620107f 100644
--- a/mie-opensource-landing/docs/developers/pull-config.md
+++ b/mie-opensource-landing/docs/developers/pull-config.md
@@ -21,8 +21,7 @@ pull-config/
 │       ├── dnsmasq-hosts
 │       ├── dnsmasq-dhcp-opts
 │       └── dnsmasq-servers
-├── Makefile                # Builds the opensource-agent package (see Release Pipeline)
-└── install.sh              # Legacy direct installer (superseded by the package)
+└── Makefile                # Builds the opensource-agent package (see Release Pipeline)
 ```
 
 ## Environment Variables
diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md
index 1ddfe8dd..6327e225 100644
--- a/mie-opensource-landing/docs/developers/release-pipeline.md
+++ b/mie-opensource-landing/docs/developers/release-pipeline.md
@@ -56,8 +56,7 @@ make -C create-a-container deb        # -> create-a-container/*.deb
 make deb
 ```
 
-The top-level `Makefile` simply forwards these targets to every component and
-collects the packages into `dist/`.
+The top-level `Makefile` simply forwards these targets to every component and collects the packages into `dist/`.
 
 ## Development
 
@@ -65,84 +64,40 @@ collects the packages into `dist/`.
 
 ```bash
 make -C create-a-container dev        # server (nodemon) + client (vite watch)
-make -C create-a-container dev-client # client bundle watcher only
 make -C mie-opensource-landing dev    # docs live server
 ```
 
-The local development stack ([`compose.yml`](https://github.com/mieweb/opensource-server/blob/main/compose.yml))
-uses these same targets: the `client` service runs `make dev-client` (the
-server runs inside the Proxmox container) and the `zensical` service runs
-`make dev`.
-
 ## Packaging with fpm
 
-Each component has a `.fpm` options file holding the static package metadata
-(name, architecture, dependencies, description, scripts, config files). The
-Makefile's `package` target stages the component into a `.pkg/buildroot` and
-runs [fpm](https://fpm.readthedocs.io/) with the dynamic options on the command
-line — output type, version (composed per format by `./package-version`), and
-the staging dir. fpm's `dir` input copies the staged tree verbatim from
-`-C .pkg/buildroot`, preserving symlinks (e.g. `node_modules/.bin/sequelize`)
-and the directory layout. The same definition produces deb, rpm, and apk, so
-`make rpm` and `make apk` also work.
-
-- `opensource-server` ships the `container-creator` and `job-runner` systemd
-  units and enables them via an `after-install` script (`before-remove`
-  disables them on real removal). The log directory is created on demand by the
-  unit's `LogsDirectory`, not shipped in the package. The logrotate drop-in is
-  the only config file (`--config-files`). The `container-creator-init` unit
-  (which provisions a *local* PostgreSQL) is **not** in the package — it is part
-  of the manager image, since the package only suggests postgresql and works
-  with a remote database too.
-- `opensource-agent` ships the pull-config engine, instances, cron schedule and
-  the static error pages. These are program code, not configuration (runtime
-  config comes from `/etc/environment`), so nothing is marked as a config file.
+Each component has a `.fpm` options file holding the static package metadata (name, architecture, dependencies, description, scripts, config files). The Makefile's `package` target stages the component into a `.pkg/buildroot` and runs [fpm](https://fpm.readthedocs.io/) with the dynamic options on the command line — output type, version (composed per format by `./package-version`), and the staging dir. fpm's `dir` input copies the staged tree verbatim from `-C .pkg/buildroot`, preserving symlinks (e.g. `node_modules/.bin/sequelize`) and the directory layout. The same definition produces deb, rpm, and apk, so `make rpm` and `make apk` also work.
+
+- `opensource-server` ships the `container-creator` and `job-runner` systemd units and enables them via an `after-install` script (`before-remove` disables them on real removal). The log directory is created on demand by the unit's `LogsDirectory`, not shipped in the package. The logrotate drop-in is the only config file. The `container-creator-init` unit (which provisions a *local* PostgreSQL) is **not** in the package — it is part of the manager image, since the package only suggests postgresql and works with a remote database too.
+- `opensource-agent` ships the pull-config engine, instances, cron schedule and the static error pages. These are program code, not configuration (runtime config comes from `/etc/environment`), so nothing is marked as a config file.
 - `opensource-docs` ships content only.
 
-Config files are marked explicitly with `--config-files`; `--deb-no-default-config-files`
-stops fpm/dpkg from auto-marking everything under `/etc` as a conffile.
+Config files are marked explicitly with `--config-files`; `--deb-no-default-config-files` stops fpm/dpkg from auto-marking everything under `/etc` as a conffile.
 
 ## Container images
 
-The [`builder`](https://github.com/mieweb/opensource-server/blob/main/images/builder/Dockerfile)
-image runs `make deb` (Node.js, uv, and fpm) to produce all three packages,
-exported as an artifact-only stage. The `docs`, `agent`, and `manager` images
-install those packages via a Docker Bake `builder` context instead of copying
-the repository. The packages are bind-mounted (no extra image layer):
+The [`builder`](https://github.com/mieweb/opensource-server/blob/main/images/builder/Dockerfile) image runs `make deb` (Node.js, uv, and fpm) to produce all three packages, exported as an artifact-only stage. The `docs`, `agent`, and `manager` images install those packages via a Docker Bake `builder` context instead of copying the repository. The packages are bind-mounted (no extra image layer):
 
 ```dockerfile
 RUN --mount=from=builder,source=/dist,target=/dist \
-    { apt-get update || true; } && \
+    apt-get update && \
     apt-get install -y /dist/opensource-agent_*.deb
 ```
 
-Each leaf image also stages the release APT source
-(`/etc/apt/sources.list.d/opensource-server.sources`) so a running container
-picks up future releases with `apt upgrade`. The source stays in place for the
-whole build; a missing release is a non-fatal `apt update` warning (hence the
-`|| true`).
+Each leaf image also stages the release APT source (`/etc/apt/sources.list.d/opensource-server.sources`) so a running container picks up future releases with `apt upgrade`. The source stays in place for the whole build which is normally not an issue since the local builder would have built a newer version than what's available in the repo.
 
 ## Releases
 
-To cut a release, **publish a GitHub release** (full or prerelease) for an
-unprefixed semver tag (e.g. `2026.6.3`, or `2026.6.3-rc1` for a prerelease).
-Publishing the release triggers
-[`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml),
-which builds the packages, generates flat APT repository metadata (`Packages`,
-`Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to
-that release. The workflow never creates or modifies the release itself — you
-choose full vs prerelease when creating it. Because GitHub serves
-`releases/latest/download/` for the newest non-prerelease release, the
-release doubles as an apt source:
+To cut a release, **publish a GitHub release** (full or prerelease) for a semver tag (e.g. `v2026.6.3`, or `v2026.6.3-rc1` for a prerelease). Publishing the release triggers [`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml), which builds the packages, generates flat APT repository metadata (`Packages`, `Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to that release. The workflow never creates or modifies the release itself — you choose full vs prerelease when creating it. Because GitHub serves `releases/latest/download/` for the newest non-prerelease release, the release doubles as an apt source:
 
 ```text
 deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./
 ```
 
-Image tagging follows the same rule: `:latest` is published only when a
-**non-prerelease** release is published, keeping the `:latest` image channel
-aligned with the `releases/latest` package channel. Pre-releases publish their
-own assets and `:X.Y.Z` image tags without moving `:latest`.
+Image tagging follows the same rule: `:latest` is published only when a **non-prerelease** release is published, keeping the `:latest` image channel aligned with the `releases/latest` package channel. Pre-releases publish their own assets and `:X.Y.Z` image tags without moving `:latest`.
 
 ## Installing and updating on a host
 
diff --git a/package-version b/package-version
index 51863d15..77b54abc 100755
--- a/package-version
+++ b/package-version
@@ -28,7 +28,7 @@ case "$packager" in
         ;;
 esac
 
-# --- parse version parts from git ------------------------------------------
+# parse version parts from git
 describe=$(git describe --tags --long --dirty 2>/dev/null || true)
 
 dirty=0
@@ -63,7 +63,7 @@ else
     hash=""
 fi
 
-# --- compose the format-specific version string ----------------------------
+# compose the format-specific version string
 case "$packager" in
     deb)
         v=$version
@@ -86,8 +86,8 @@ case "$packager" in
         fi
         ;;
     apk)
-        # apk grammar is restrictive: digits(.digits)*[_suffix[digits]]* — it
-        # cannot embed the commit hash, so snapshots use _git.
+        # apk grammar is restrictive: digits(.digits)*[_suffix[digits]]* since it
+        # can't embed the commit hash snapshots use _git.
         v=$version
         [ -n "$prerelease" ] && v="${v}_$prerelease"
         [ "$commits" != "0" ] && v="${v}_git$commits"
diff --git a/pull-config/Makefile b/pull-config/Makefile
index 14177087..e3cfd106 100644
--- a/pull-config/Makefile
+++ b/pull-config/Makefile
@@ -1,16 +1,8 @@
-# pull-config — Makefile (opensource-agent package)
-#
-# Standard component contract (see create-a-container/Makefile for details).
-# pull-config is plain bash, so deps/build/dev are no-ops; install stages the
-# engine, instances, cron schedule and the shared error pages.
-
 .DEFAULT_GOAL := help
 
 PREFIX  ?= /opt/opensource-server
 DESTDIR ?= /
 
-# Staging directory for packaging. Not named ".fpm" — fpm reads a file of that
-# name as an rc options file.
 STAGE := $(CURDIR)/.pkg/buildroot
 
 INSTALL      := install
@@ -42,16 +34,12 @@ build: deps
 dev: deps
 
 install: build
-	# pull-config engine — instances exec this absolute path.
 	$(INSTALL) -D -m 0755 bin/pull-config $(DESTDIR)$(PREFIX)/pull-config/bin/pull-config
-	# pull-config instances (program code) + cron schedule.
 	$(INSTALL) -d $(DESTDIR)/etc/pull-config.d
 	$(INSTALL_PROG) etc/pull-config.d/* $(DESTDIR)/etc/pull-config.d/
 	$(INSTALL) -D -m 0644 etc/cron.d/pull-config $(DESTDIR)/etc/cron.d/pull-config
-	# Static error pages referenced by the manager-rendered nginx config.
 	$(INSTALL) -d $(DESTDIR)$(PREFIX)/error-pages
 	$(INSTALL_DATA) $(ERROR_PAGES)/* $(DESTDIR)$(PREFIX)/error-pages/
-	# Forward-auth cache directory used by the manager-rendered nginx config.
 	$(INSTALL) -d -m 0755 $(DESTDIR)/var/cache/nginx/auth_cache
 
 PACKAGER ?= deb

From a7f29c771200e5288cc93d486059ae0f320c5eec Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Tue, 16 Jun 2026 09:11:07 -0400
Subject: [PATCH 39/42] Ignore .pkg packaging staging dir in Docker context

The Makefiles stage into .pkg/buildroot, not build-root, so the stale
**/build-root pattern let local packaging artifacts leak into the build
context.
---
 .dockerignore | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.dockerignore b/.dockerignore
index 75782aff..03113260 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -11,7 +11,7 @@
 **/__pycache__
 
 # packaging build artifacts (rebuilt inside the builder image)
-**/build-root
+**/.pkg
 /dist
 **/*.deb
 **/*.rpm

From f0882e57ce6f4e0917e7745e2431c5f65d75b43b Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Tue, 16 Jun 2026 09:11:08 -0400
Subject: [PATCH 40/42] Build images on push to main and on PRs only

Restrict the push trigger to main so feature-branch pushes don't build
images (their PR covers that), reducing CI/registry churn. No paths
filter is added, so doc-only changes still rebuild the images.
---
 .github/workflows/build-images.yml | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml
index c80584c7..67d29eb8 100644
--- a/.github/workflows/build-images.yml
+++ b/.github/workflows/build-images.yml
@@ -1,7 +1,13 @@
 name: Build and Push Images
 
 on:
+  # Build on pushes to the default branch (merges) and on every pull request
+  # update. Feature-branch pushes are covered by their PR, avoiding duplicate
+  # builds and registry churn. No paths filter, so doc-only changes still
+  # rebuild the images.
   push:
+    branches:
+      - main
   pull_request:
     types: [opened, synchronize, reopened, closed]
   schedule:

From 67b06c9acfd50572c704d56f45758b87a8bf380a Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Tue, 16 Jun 2026 09:11:08 -0400
Subject: [PATCH 41/42] Fix release-pipeline doc: help is the default goal; v
 prefix optional

Correct the Makefile contract table (default goal is help, add the help
target, drop the VERSION override row), describe ./package-version as the
version source, and state consistently that a leading v on the tag is
optional.
---
 .../docs/developers/release-pipeline.md       | 20 ++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/mie-opensource-landing/docs/developers/release-pipeline.md b/mie-opensource-landing/docs/developers/release-pipeline.md
index 6327e225..533996f9 100644
--- a/mie-opensource-landing/docs/developers/release-pipeline.md
+++ b/mie-opensource-landing/docs/developers/release-pipeline.md
@@ -21,12 +21,13 @@ serves the agent's error pages and the docs site.
 ## The component Makefile contract
 
 Each component directory has a self-contained `Makefile` with the same targets.
-The default goal is `build`.
+The default goal is `help`, which lists the available targets.
 
 | Target | Description |
 |---|---|
+| `help` | List the available targets (default goal) |
 | `deps` | Install build/runtime dependencies (`npm ci`, `uv sync`, or nothing) |
-| `build` | Compile the component (default goal); depends on `deps` |
+| `build` | Compile the component; depends on `deps` |
 | `install` | Stage built files into `DESTDIR` at their final paths; depends on `build` |
 | `dev` | Run the development watch loop; depends on `deps` |
 | `deb` / `rpm` / `apk` | Stage and package with [fpm](https://fpm.readthedocs.io/); depend on `install` |
@@ -37,13 +38,14 @@ Variables (overridable):
 |---|---|---|
 | `PREFIX` | `/opt/opensource-server` | Vendor install prefix |
 | `DESTDIR` | `/` | Staging root for `install` |
-| `VERSION` | derived from `git describe` | Package version |
 
-`VERSION` is computed inline from git tags: an exact tag `2026.6.2` is used
-as-is; commits after a tag become `2026.6.2+.g` (valid semver build
-metadata that sorts above the tag and below the next release); a prerelease tag
-`2026.6.3-rc1` sorts below the eventual `2026.6.3`. Tags are unprefixed semver
-(no leading `v`).
+The package version is derived from git by [`./package-version`](https://github.com/mieweb/opensource-server/blob/main/package-version),
+which composes a format-appropriate string per packager from
+`git describe --tags --long --dirty`: an exact tag `2026.6.3` is used as-is;
+commits after a tag become a snapshot that sorts above the tag and below the
+next release (e.g. deb `2026.6.3+.g`); a prerelease tag `2026.6.3-rc1`
+sorts below the eventual `2026.6.3`. A leading `v` on the tag is optional — it
+is stripped if present (`v2026.6.3` and `2026.6.3` are equivalent).
 
 ```bash
 # Build and stage a component anywhere:
@@ -91,7 +93,7 @@ Each leaf image also stages the release APT source (`/etc/apt/sources.list.d/ope
 
 ## Releases
 
-To cut a release, **publish a GitHub release** (full or prerelease) for a semver tag (e.g. `v2026.6.3`, or `v2026.6.3-rc1` for a prerelease). Publishing the release triggers [`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml), which builds the packages, generates flat APT repository metadata (`Packages`, `Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to that release. The workflow never creates or modifies the release itself — you choose full vs prerelease when creating it. Because GitHub serves `releases/latest/download/` for the newest non-prerelease release, the release doubles as an apt source:
+To cut a release, **publish a GitHub release** (full or prerelease) for a semver tag — the leading `v` is optional (e.g. `2026.6.3` or `v2026.6.3`, and `2026.6.3-rc1` for a prerelease). Publishing the release triggers [`release.yml`](https://github.com/mieweb/opensource-server/blob/main/.github/workflows/release.yml), which builds the packages, generates flat APT repository metadata (`Packages`, `Packages.gz`) with `dpkg-scanpackages`, and uploads the debs and metadata to that release. The workflow never creates or modifies the release itself — you choose full vs prerelease when creating it. Because GitHub serves `releases/latest/download/` for the newest non-prerelease release, the release doubles as an apt source:
 
 ```text
 deb [trusted=yes] https://github.com/mieweb/opensource-server/releases/latest/download/ ./

From b9f111238ef84afa0c517df52c922a0f76771960 Mon Sep 17 00:00:00 2001
From: Robert Gingras 
Date: Thu, 18 Jun 2026 13:42:07 -0400
Subject: [PATCH 42/42] cleanup

---
 create-a-container/compose.ldap.env | 10 ----------
 1 file changed, 10 deletions(-)
 delete mode 100644 create-a-container/compose.ldap.env

diff --git a/create-a-container/compose.ldap.env b/create-a-container/compose.ldap.env
deleted file mode 100644
index 397bf5d2..00000000
--- a/create-a-container/compose.ldap.env
+++ /dev/null
@@ -1,10 +0,0 @@
-LOG_LEVEL=debug
-DIRECTORY_BACKEND=sql
-REQUIRE_AUTH_FOR_SEARCH=false
-AUTH_BACKENDS=sql
-LDAP_COMMON_NAME=ldap
-LDAP_BASE_DN=dc=docker,dc=internal
-SQL_QUERY_ALL_USERS='SELECT "uid" AS username, "uidNumber" AS uid_number, "gidNumber" AS gid_number, "cn" AS full_name, "sn" AS last_name, "mail", "homeDirectory" AS home_directory, "userPassword" AS password, "givenName" as first_name FROM "Users"'
-SQL_QUERY_ONE_USER='SELECT "uid" AS username, "uidNumber" AS uid_number, "gidNumber" AS gid_number, "cn" AS full_name, "sn" AS last_name, "mail", "homeDirectory" AS home_directory, "userPassword" AS password, "givenName" as first_name FROM "Users" WHERE "uid" = ?'
-SQL_QUERY_ALL_GROUPS='SELECT g."cn" AS name, g."gidNumber" AS gid_number FROM "Groups" g'
-SQL_QUERY_GROUPS_BY_MEMBER='SELECT g."cn" AS name, g."gidNumber" AS gid_number FROM "Groups" g INNER JOIN "UserGroups" ug ON g."gidNumber" = ug."gidNumber" INNER JOIN "Users" u ON ug."uidNumber" = u."uidNumber" WHERE u."uid" = ?'