feat(publish): rebuild releases nightly and let stable follow the tested one
CI / security-codeql (push) Blocked by required conditions
CI / test-unit (push) Blocked by required conditions
CI / test-integration (push) Blocked by required conditions
CI / test-env-virtual (push) Blocked by required conditions
CI / test-env-nix (push) Blocked by required conditions
CI / test-e2e (push) Blocked by required conditions
CI / test-virgin-user (push) Blocked by required conditions
CI / test-virgin-root (push) Blocked by required conditions
CI / lint-shell (push) Blocked by required conditions
CI / lint-python (push) Blocked by required conditions
CI / lint-docker (push) Blocked by required conditions
CodeQL Advanced / Check security (none, actions) (push) Blocked by required conditions
CodeQL Advanced / Check security (none, python) (push) Blocked by required conditions
Test Units / test-unit (push) Blocked by required conditions
Test Code Integration / test-integration (push) Blocked by required conditions
Test OS Containers / test-env-virtual (arch) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (centos) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (debian) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (fedora) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (ubuntu) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Blocked by required conditions
Test End-To-End / test-e2e (arch) (push) Blocked by required conditions
Test End-To-End / test-e2e (centos) (push) Blocked by required conditions
Test End-To-End / test-e2e (debian) (push) Blocked by required conditions
Test End-To-End / test-e2e (fedora) (push) Blocked by required conditions
Test End-To-End / test-e2e (ubuntu) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (arch) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (centos) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (debian) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (fedora) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (ubuntu) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (arch) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (centos) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (debian) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (fedora) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (ubuntu) (push) Blocked by required conditions
ShellCheck / lint-shell (push) Blocked by required conditions
Ruff (Python code sniffer) / lint-python (push) Blocked by required conditions
Docker Linter / Lint Dockerfile (push) Blocked by required conditions

Every image build now carries <version> and the immutable
<version>.<unix-ts>; latest goes to the newest release on main, each
prefix (2, 2.3) to the newest release starting with it, and stable to the
release the git tag stable marks, which "Mark stable commit" sets only
after all tests passed.

publish-containers.yml runs daily at 03:00 UTC and on workflow_dispatch.
A rebuild publishes the newest release and, when the git tag stable sits
on an older one, that release too, so both get base-image security
updates. compute-publish-container-info.sh emits the release list with
each release's moving tags; image.sh takes them as --aliases instead of
--stable.

A platform build whose installed package set equals the published
<version> pushes nothing, and a merge whose image index equals the
published one only moves the tags not yet on it, without a new stamp.
Only vX.Y.Z tags count as releases; v2.4.0-rc1 no longer moves 2 or 2.4.

The build scripts come from the release commit: releases tagged before
this change keep the old scripts until a newer release replaces them.

tests/integration: 22 tests for release choice, aliases, the stable
rebuild, the push skip and the triggers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-04 18:38:42 +01:00
1 parent 08738d812e
commit c2f0a56699
10 files changed
+748 -130

No files matched your search

+21
View File
@@ -30,3 +30,24 @@ resolve_platforms() {
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
package_set_cmd() {
local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in
arch|manjaro) echo 'pacman -Q' ;;
debian|ubuntu) echo 'dpkg-query -W' ;;
fedora|centos) echo 'rpm -qa | sort' ;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
version_prefixes() {
local -a parts
IFS=. read -ra parts <<< "$1"
local acc="${parts[0]}" i
echo "${acc}"
for ((i = 1; i < ${#parts[@]}; i++)); do
acc+=".${parts[i]}"
echo "${acc}"
done
}
+103 -39
View File
@@ -12,12 +12,13 @@ NO_CACHE=0
MISSING_ONLY=0
IMAGE_TAG="" # local image name or base tag (without registry)
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
PUBLISH=0 # if 1 -> push <version>, <version>.<unix-ts> and the --aliases, plus arch aliases
REGISTRY="" # e.g. ghcr.io
OWNER="" # e.g. github org/user
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
VERSION="" # X.Y.Z (required for --publish)
IS_STABLE="false" # "true" -> publish stable tags
ALIASES="" # moving tags besides <version>, e.g. "latest 2 2.4 stable"
ALIASES_SET=0 # 1 once --aliases was given, also when empty
PLATFORM="" # e.g. linux/arm64 -> build and push only this platform
MERGE=0 # if 1 -> join the per-platform tags of --version under the publish tags
DEFAULT_DISTRO="arch"
@@ -35,17 +36,23 @@ Build options:
Publish options:
--push Push the built image (uses docker buildx build --push)
--publish Publish semantic tags (latest, <version>, optional stable) + arch aliases
--publish Publish <version>, <version>.<unix-ts> and every --aliases tag, plus arch
aliases
--registry <reg> Registry (e.g. ghcr.io)
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
--repo-prefix <name> Image base name (default: pkgmgr)
--version <X.Y.Z> Version for --publish
--stable <true|false> Whether to publish :stable tags (default: false)
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>
--merge Join the <version>-<arch> tags of all platforms under the publish tags
--aliases "<tags>" Moving tags besides <version>, space separated, may be empty
(e.g. "latest 2 2.4 stable")
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>,
skipped when its package set equals the published <version>
--merge Join the <version>-<arch> tags of all platforms under the publish tags;
when <version> already joins exactly these images, only the aliases
that do not are moved and no <version>.<unix-ts> is added
Notes:
- --publish implies --push and requires --registry, --owner, and --version.
- --publish without --platform requires --aliases.
- --platform requires --push; --merge requires --publish and builds nothing.
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
EOF
@@ -82,9 +89,10 @@ while [[ $# -gt 0 ]]; do
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
shift 2
;;
--stable)
IS_STABLE="${2:-}"
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
--aliases)
[[ $# -ge 2 ]] || { echo "ERROR: --aliases requires a value, empty for none"; exit 2; }
ALIASES="$2"
ALIASES_SET=1
shift 2
;;
--platform)
@@ -126,6 +134,8 @@ if [[ "${PUBLISH}" == "1" ]]; then
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; }
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
[[ -n "${PLATFORM}" || "${ALIASES_SET}" == "1" ]] \
|| { echo "ERROR: --publish without --platform requires --aliases"; exit 2; }
fi
if [[ -n "${PLATFORM}" ]]; then
@@ -140,6 +150,7 @@ if [[ "${MERGE}" == "1" ]]; then
fi
DISTRO_TAG_BASE="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
STAMP="$(date -u +%s)"
# Guard: --push without --publish requires fully-qualified --tag
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
@@ -162,7 +173,8 @@ if [[ "${PUBLISH}" == "1" ]]; then
echo "registry = ${REGISTRY}"
echo "owner = ${OWNER}"
echo "version = ${VERSION}"
echo "stable = ${IS_STABLE}"
echo "stamp = ${STAMP}"
echo "aliases = ${ALIASES}"
fi
echo "------------------------------------------------------------"
@@ -173,48 +185,97 @@ if [[ "${NO_CACHE}" == "1" ]]; then
build_args+=(--no-cache)
fi
compute_publish_tags() {
local distro_tag_base="${DISTRO_TAG_BASE}"
local alias_tag_base=""
publish_bases() {
echo "${DISTRO_TAG_BASE}"
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
echo "${REGISTRY}/${OWNER}/${REPO_PREFIX}"
fi
}
local tags=()
tags+=("${distro_tag_base}:latest")
tags+=("${distro_tag_base}:${VERSION}")
moving_tags() {
local base alias
local -a aliases
read -ra aliases <<< "${ALIASES}"
while read -r base; do
echo "${base}:${VERSION}"
for alias in "${aliases[@]}"; do
echo "${base}:${alias}"
done
done < <(publish_bases)
}
if [[ "${IS_STABLE}" == "true" ]]; then
tags+=("${distro_tag_base}:stable")
fi
stamp_tags() {
local base
while read -r base; do
echo "${base}:${VERSION}.${STAMP}"
done < <(publish_bases)
}
if [[ -n "${alias_tag_base}" ]]; then
tags+=("${alias_tag_base}:latest")
tags+=("${alias_tag_base}:${VERSION}")
if [[ "${IS_STABLE}" == "true" ]]; then
tags+=("${alias_tag_base}:stable")
fi
fi
raw_index() {
docker buildx imagetools inspect --raw "$1" 2>/dev/null || true
}
printf '%s\n' "${tags[@]}"
digest_of() {
docker buildx imagetools inspect --format '{{json .Manifest.Digest}}' "$1"
}
if [[ "${MERGE}" == "1" ]]; then
merge_args=(docker buildx imagetools create)
while IFS= read -r t; do
merge_args+=(-t "$t")
done < <(compute_publish_tags)
sources=()
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
for p in "${platforms[@]}"; do
merge_args+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
sources+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
done
echo "[build] Running: ${merge_args[*]}"
"${merge_args[@]}"
docker buildx imagetools inspect "${DISTRO_TAG_BASE}:${VERSION}"
mapfile -t moving < <(moving_tags)
planned="$(docker buildx imagetools create --dry-run -t "${DISTRO_TAG_BASE}:${VERSION}" "${sources[@]}")"
tags=()
if [[ "$(raw_index "${DISTRO_TAG_BASE}:${VERSION}")" == "${planned}" ]]; then
for t in "${moving[@]}"; do
if [[ "$(raw_index "${t}")" != "${planned}" ]]; then
tags+=("${t}")
fi
done
else
mapfile -t stamps < <(stamp_tags)
tags=("${moving[@]}" "${stamps[@]}")
fi
if ((${#tags[@]} == 0)); then
echo "[build] ${PKGMGR_DISTRO} ${VERSION}: every tag already joins these platform images, nothing to push."
exit 0
fi
create_args=()
for t in "${tags[@]}"; do
create_args+=(-t "$t")
done
echo "[build] Running: docker buildx imagetools create ${create_args[*]} ${sources[*]}"
docker buildx imagetools create "${create_args[@]}" "${sources[@]}"
version_digest="$(digest_of "${DISTRO_TAG_BASE}:${VERSION}")"
for t in "${moving[@]}"; do
if [[ "$(digest_of "${t}")" != "${version_digest}" ]]; then
echo "[build] ❌ ${PKGMGR_DISTRO}: :${VERSION} and ${t} point at different images." >&2
echo "[build] Whoever pinned ${t##*:} stopped receiving what ${VERSION} gets." >&2
exit 1
fi
done
echo "[build] ✅ ${PKGMGR_DISTRO}: every tag of ${VERSION} == ${version_digest}"
elif [[ "${PUSH}" == "1" ]]; then
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
probe="${REPO_PREFIX}-${PKGMGR_DISTRO}:probe"
docker buildx build --load --platform "${PLATFORM}" -t "${probe}" "${build_args[@]}" .
package_set="$(package_set_cmd "${PKGMGR_DISTRO}")"
built_set="$(docker run --rm --platform "${PLATFORM}" --entrypoint bash "${probe}" -lc "${package_set}" | sha256sum)"
published_set="$(docker run --rm --pull always --platform "${PLATFORM}" --entrypoint bash "${DISTRO_TAG_BASE}:${VERSION}" -lc "${package_set}" 2>/dev/null | sha256sum || true)"
if [[ "${built_set}" == "${published_set}" ]]; then
echo "[build] ${PKGMGR_DISTRO} ${PLATFORM} unchanged since the published ${VERSION}, nothing to push."
exit 0
fi
fi
bx_args=(docker buildx build --push)
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
@@ -222,7 +283,10 @@ elif [[ "${PUSH}" == "1" ]]; then
elif [[ "${PUBLISH}" == "1" ]]; then
while IFS= read -r t; do
bx_args+=(-t "$t")
done < <(compute_publish_tags)
done < <(moving_tags)
while IFS= read -r t; do
bx_args+=(-t "$t")
done < <(stamp_tags)
else
bx_args+=(-t "${IMAGE_TAG}")
fi
+4 -4
View File
@@ -6,10 +6,10 @@ set -euo pipefail
# Required env:
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
# VERSION (e.g. 1.2.3)
# ALIASES moving tags besides VERSION, space separated, may be empty (e.g. "latest 1 1.2 stable")
#
# Optional env:
# REGISTRY (default: ghcr.io)
# IS_STABLE (default: false)
# DISTROS (default: "arch debian ubuntu fedora centos")
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -18,16 +18,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/base.sh"
REGISTRY="${REGISTRY:-ghcr.io}"
IS_STABLE="${IS_STABLE:-false}"
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
: "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}"
: "${ALIASES?Environment variable ALIASES must be set, empty for none (e.g. \"latest 1 1.2 stable\")}"
echo "[publish] REGISTRY=${REGISTRY}"
echo "[publish] OWNER=${OWNER}"
echo "[publish] VERSION=${VERSION}"
echo "[publish] IS_STABLE=${IS_STABLE}"
echo "[publish] ALIASES=${ALIASES}"
echo "[publish] DISTROS=${DISTROS}"
for d in ${DISTROS}; do
@@ -51,7 +51,7 @@ for d in ${DISTROS}; do
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--aliases "${ALIASES}" \
--merge
done
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
WORKFLOW_RUN_SHA="${WORKFLOW_RUN_SHA:?WORKFLOW_RUN_SHA must be set}"
git checkout -f "${WORKFLOW_RUN_SHA}"
git fetch --tags --force
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
@@ -1,23 +1,91 @@
#!/usr/bin/env bash
# Param: WORKFLOW_RUN_SHA commit the "Mark stable commit" run tagged; empty on a scheduled or
# manual rebuild, which publishes the newest vX.Y.Z release on origin/main
# and the release the git tag stable marks
# Output: releases=<JSON list of {"sha", "version", "aliases"}> in GITHUB_OUTPUT; aliases are the
# moving tags a release takes besides <version> and <version>.<unix-ts>
set -euo pipefail
SHA="$(git rev-parse HEAD)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
if [[ -z "${V_TAG}" ]]; then
echo "No version tag found for ${SHA}. Skipping publish."
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
# shellcheck source=scripts/build/base.sh
source "${SCRIPT_DIR}/../../build/base.sh"
: "${WORKFLOW_RUN_SHA?WORKFLOW_RUN_SHA must be set, empty for a rebuild}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
RELEASE_TAG='^v[0-9]+\.[0-9]+\.[0-9]+$'
release_at() {
local listed tag
listed="$(git tag --points-at "$1" --sort=-v:refname)"
tag="$(grep -m1 -E "${RELEASE_TAG}" <<< "${listed}" || true)"
echo "${tag#v}"
}
newest_of() {
local prefix="$1" version
for version in "${RELEASES[@]}"; do
if [[ "${version}." == "${prefix}."* ]]; then
echo "${version}"
return 0
fi
done
}
aliases_of() {
local version="$1" prefix
local -a aliases=()
if [[ "${version}" == "${RELEASES[0]}" ]]; then
aliases+=(latest)
fi
while read -r prefix; do
if [[ "${prefix}" != "${version}" && "$(newest_of "${prefix}")" == "${version}" ]]; then
aliases+=("${prefix}")
fi
done < <(version_prefixes "${version}")
if [[ "${version}" == "${STABLE}" ]]; then
aliases+=(stable)
fi
echo "${aliases[*]}"
}
listed="$(git tag --merged origin/main --sort=-v:refname)"
mapfile -t tags < <(grep -E "${RELEASE_TAG}" <<< "${listed}")
RELEASES=("${tags[@]#v}")
STABLE=""
stable_sha="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' || true)"
if [[ -n "${stable_sha}" ]]; then
STABLE="$(release_at "${stable_sha}")"
fi
VERSION="${V_TAG#v}"
targets=()
if [[ -n "${WORKFLOW_RUN_SHA}" ]]; then
version="$(release_at "${WORKFLOW_RUN_SHA}")"
if ! git merge-base --is-ancestor "${WORKFLOW_RUN_SHA}" origin/main; then
echo "${WORKFLOW_RUN_SHA} is not on origin/main. Skipping publish."
elif [[ -z "${version}" ]]; then
echo "No vX.Y.Z tag found for ${WORKFLOW_RUN_SHA}. Skipping publish."
else
targets+=("${version}")
fi
else
((${#RELEASES[@]} > 0)) || { echo "No vX.Y.Z tag is reachable from origin/main." >&2; exit 1; }
targets+=("${RELEASES[0]}")
if [[ -z "${STABLE}" ]]; then
echo "The git tag stable marks no vX.Y.Z release; the stable image stays where it is."
elif [[ "${STABLE}" != "${RELEASES[0]}" ]]; then
targets+=("${STABLE}")
fi
fi
STABLE_SHA="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' 2>/dev/null || true)"
IS_STABLE=false
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
json=""
for version in "${targets[@]}"; do
sha="$(git rev-parse "v${version}^{commit}")"
moving="$(aliases_of "${version}")"
echo "Publishing ${version} at ${sha}, moving: ${moving}"
json+="${json:+,}{\"sha\":\"${sha}\",\"version\":\"${version}\",\"aliases\":\"${moving}\"}"
done
{
echo "should_publish=true"
echo "version=${VERSION}"
echo "is_stable=${IS_STABLE}"
} >> "$GITHUB_OUTPUT"
echo "releases=[${json}]" >> "${GITHUB_OUTPUT}"
@@ -3,7 +3,7 @@ set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}"
: "${ALIASES?ALIASES must be set, empty for none}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
bash scripts/build/image.sh \
@@ -11,5 +11,5 @@ bash scripts/build/image.sh \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--aliases "${ALIASES}" \
--merge