From a04c5aec12f0534f47bb9b0327ffd072b7c77e77 Mon Sep 17 00:00:00 2001 From: repo-worker Date: Sun, 5 Jul 2026 16:49:48 +0300 Subject: [PATCH] Add ci-fleet-publish plugin image + multi-arch quartet flow (oleks/ci-scripts#14, #15) --- .woodpecker/build.yaml | 38 ++++++ .woodpecker/manifest.yaml | 35 ++++++ Dockerfile | 12 ++ ci/local.sh | 101 ++++++++++++++++ entrypoint.sh | 205 ++++++++++++++++++++++++++++++++ manifests/ci-fleet-publish.yaml | 6 + 6 files changed, 397 insertions(+) create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/manifest.yaml create mode 100644 Dockerfile create mode 100755 ci/local.sh create mode 100755 entrypoint.sh create mode 100644 manifests/ci-fleet-publish.yaml diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..3e0ba93 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,38 @@ +# GENERATED by oleks/ci-scripts — do not hand-edit. +# Source manifest: manifests/ci-fleet-publish.yaml +# Regenerate: python3 generator/render.py manifests/ci-fleet-publish.yaml --out /.woodpecker + +matrix: + ARCH: + - amd64 + - arm64 + +when: + - event: tag + ref: "refs/tags/v*" + +labels: + arch: ${ARCH} + +# Thin wrapper (cluster #196, emmett#44): CI runs the SAME ci/local.sh a +# developer runs, so CI and local can't drift. CI-specific bits are env +# overrides only: BUILDKIT_ADDR -> the in-cluster NATIVE per-arch buildkit +# (no QEMU), PUBLISH=1 -> actually push. Output is unchanged: a per-arch +# :- image, later merged into :+:latest by manifest.yaml. +steps: + - name: build-and-push + image: git.oleks.space/oleks/nix-ci:latest + environment: + REGISTRY_TOKEN: + from_secret: registry_token + BUILDKIT_ADDR: "tcp://buildkit-${ARCH}.infra.svc.cluster.local:1234" + PUBLISH: "1" + commands: + - echo "▸ arch=$(uname -m)" + - ci/local.sh --arch "${ARCH}" + backend_options: + kubernetes: + labels: + commit-tag: "${CI_COMMIT_TAG}" + pipeline-number: "${CI_PIPELINE_NUMBER}" + build-arch: "${ARCH}" diff --git a/.woodpecker/manifest.yaml b/.woodpecker/manifest.yaml new file mode 100644 index 0000000..ab2cbc6 --- /dev/null +++ b/.woodpecker/manifest.yaml @@ -0,0 +1,35 @@ +# GENERATED by oleks/ci-scripts — do not hand-edit. +# Source manifest: manifests/ci-fleet-publish.yaml +# Regenerate: python3 generator/render.py manifests/ci-fleet-publish.yaml --out /.woodpecker + +when: + - event: tag + ref: "refs/tags/v*" + +depends_on: + - build + +# oleks/ci-scripts#9: run even if a per-arch matrix leg failed. Without +# `runs_on`, Woodpecker skips a dependent workflow outright once any leg of +# `build` fails, so a lone arm64 success would silently produce no image at +# all. `ci/local.sh --manifest` itself must probe the registry for which +# per-arch tags actually landed (not assume a fixed arch list, and not rely +# on step/pipeline success status) and build the manifest from whatever +# exists — a partial manifest beats none. +runs_on: + - success + - failure + +# Thin wrapper (cluster #196, emmett#44): the SAME ci/local.sh composes the +# per-arch tags into the multi-arch : + :latest manifest list. +# PUBLISH=1 is the only CI-specific override (local default is dry-run). +steps: + - name: manifest + image: git.oleks.space/oleks/nix-ci:latest + environment: + REGISTRY_TOKEN: + from_secret: registry_token + PUBLISH: "1" + commands: + - echo "▸ arch=$(uname -m)" + - ci/local.sh --manifest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e7241e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# ci-fleet-publish plugin image (oleks/ci-scripts#14). +# +# Base: the existing nixos-ci toolchain image (nix, docker-buildx, +# attic-client, nixos-ci-entrypoint already baked in — see +# ~/projects/nix-customs/nixos-ci). This image adds only the plugin +# entrypoint on top. +FROM git.oleks.space/oleks/nix-ci:latest + +COPY entrypoint.sh /usr/local/bin/ci-fleet-publish-entrypoint +RUN chmod +x /usr/local/bin/ci-fleet-publish-entrypoint + +ENTRYPOINT ["/usr/local/bin/ci-fleet-publish-entrypoint"] diff --git a/ci/local.sh b/ci/local.sh new file mode 100755 index 0000000..0a3bb23 --- /dev/null +++ b/ci/local.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Dev-parity build script for the ci-fleet-publish plugin image itself +# (oleks/ci-scripts#14, #15). CI runs this SAME script (cluster #196, +# emmett#44 convention) so CI and local can't drift; the only CI-specific +# overrides are BUILDKIT_ADDR (native per-arch remote buildkit) and +# PUBLISH=1 (local default is dry-run / --load). +set -euo pipefail + +IMAGE="git.oleks.space/oleks/ci-fleet-publish" +PUBLISH="${PUBLISH:-0}" +VERSION="${CI_COMMIT_TAG:-dev}" +ARCHES=(amd64 arm64) + +echo "▸ arch=$(uname -m)" + +registry_login() { + if [ -n "${REGISTRY_TOKEN:-}" ]; then + echo "${REGISTRY_TOKEN}" | docker login git.oleks.space -u oleks --password-stdin + fi +} + +ensure_builder() { + if [ -n "${BUILDKIT_ADDR:-}" ]; then + if ! docker buildx inspect ci-fleet-publish >/dev/null 2>&1; then + docker buildx create --name ci-fleet-publish --driver remote "${BUILDKIT_ADDR}" + fi + docker buildx use ci-fleet-publish + fi +} + +usage() { + echo "usage: $0 --arch ARCH | --manifest" >&2 + exit 1 +} + +mode="" +arch="" +while [ $# -gt 0 ]; do + case "$1" in + --arch) + arch="$2" + mode="build" + shift 2 + ;; + --manifest) + mode="manifest" + shift + ;; + *) + usage + ;; + esac +done + +case "${mode}" in + build) + : "${arch:?--arch required}" + registry_login + ensure_builder + tag="${VERSION}-${arch}" + push_flag="--load" + if [ "${PUBLISH}" = "1" ]; then + push_flag="--push" + fi + echo "▸ building ${IMAGE}:${tag} for linux/${arch}" + docker buildx build --platform "linux/${arch}" "${push_flag}" -t "${IMAGE}:${tag}" . + ;; + manifest) + registry_login + major="${VERSION%%.*}" + dest_tags=("${VERSION}" "${major}" "latest") + found=() + for a in "${ARCHES[@]}"; do + ref="${IMAGE}:${VERSION}-${a}" + echo "▸ probing ${ref}" + if DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect "${ref}" >/dev/null 2>&1; then + echo "▸ found ${ref}" + found+=("${ref}") + else + echo "▸ missing ${ref} (oleks/ci-scripts#9: skip this arch, not the whole manifest)" + fi + done + if [ "${#found[@]}" -eq 0 ]; then + echo "no per-arch tags landed for ${IMAGE}:${VERSION}; failing manifest step" >&2 + exit 1 + fi + if [ "${PUBLISH}" != "1" ]; then + echo "▸ dry-run: would create manifest ${dest_tags[*]} from ${found[*]}" + exit 0 + fi + for dest in "${dest_tags[@]}"; do + dest_ref="${IMAGE}:${dest}" + echo "▸ creating manifest ${dest_ref} from ${#found[@]}/${#ARCHES[@]} arches" + docker manifest create "${dest_ref}" "${found[@]}" + docker manifest push "${dest_ref}" + done + ;; + *) + usage + ;; +esac diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..ead21d4 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ci-fleet-publish entrypoint (oleks/ci-scripts#14, #15). +# +# Woodpecker plugin entrypoint: reads PLUGIN_* settings (Woodpecker maps a +# step's `settings:` map onto PLUGIN_ env vars) and PLUGIN_FLOW +# selects the behavior: +# +# publish - (default) run PLUGIN_RUN after the shared setup +# (arch banner, resolv.conf/nix.conf shims, attic +# cache) — the single-step shape from the wiki's thin +# pipeline contract. +# buildx-build - buildx build+push a single per-arch tag for the +# current node's arch (one Woodpecker matrix leg per +# arch) — half of the "buildx-multiarch" quartet shape. +# buildx-manifest - probe the registry for which per-arch tags actually +# landed and create a manifest from whatever exists +# (oleks/ci-scripts#9: fail only if NONE landed, never +# skip on partial success) — the other half. +# +# Deviation from Spec/Fleet Publish Plugin System (oleks/ci-scripts#15): +# the spec names one flow value `buildx-multiarch` for the whole quartet +# shape, but a single flow value can't distinguish "build my arch's tag" +# from "consolidate the manifest" — those are two different Woodpecker +# steps (build matrix + a `depends_on: build, runs_on: [success, failure]` +# manifest step) using the SAME plugin image. Split into `buildx-build` / +# `buildx-manifest` instead; wiki updated to match. +# +# See wiki Spec/Modern-CI-Target and Spec/Fleet Publish Plugin System. +set -euo pipefail + +echo "▸ arch=$(uname -m)" + +: "${PLUGIN_FLOW:=publish}" + +# --- resolv.conf shim (s390x cross-build DNS fix, oleks/ci-scripts#4) --- +if [ -n "${PLUGIN_NAMESERVER:-}" ]; then + echo "nameserver ${PLUGIN_NAMESERVER}" >/etc/resolv.conf + echo "options ndots:1" >>/etc/resolv.conf +fi + +# --- nix.conf shims --- +if [ "${PLUGIN_MAX_JOBS:-}" = "true" ]; then + echo "max-jobs = 1" >>/etc/nix/nix.conf +fi +if [ -n "${PLUGIN_CORES:-}" ]; then + echo "cores = ${PLUGIN_CORES}" >>/etc/nix/nix.conf +fi +echo "sandbox = false" >>/etc/nix/nix.conf + +normalize_arch() { + case "$1" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + *) echo "$1" ;; + esac +} + +render_tag() { + # render_tag SCHEME VERSION ARCH + local scheme="$1" version="$2" arch="$3" + scheme="${scheme//\{version\}/$version}" + scheme="${scheme//\{arch\}/$arch}" + echo "$scheme" +} + +# --- attic watch-store lifecycle: explicit non-masking shutdown, never `|| true` --- +ATTIC_PID="" + +attic_start() { + if [ "${PLUGIN_CACHE:-true}" != "true" ] || [ -z "${ATTIC_TOKEN:-}" ]; then + return 0 + fi + local server="${PLUGIN_ATTIC_SERVER:-https://nix-cache-upload.oleks.space}" + local cache="${PLUGIN_ATTIC_CACHE:-attic-infra-cache-k3s-1}" + attic login ci "$server" "$ATTIC_TOKEN" + attic watch-store "$cache" & + ATTIC_PID=$! + sleep 2 + echo "▸ attic watch-store started for ${cache} (pid ${ATTIC_PID})" +} + +attic_stop() { + # attic_stop RUN_STATUS — folds a non-zero shutdown into the exit code + # only if the run itself succeeded (a failing run's status always wins). + local run_status="$1" + if [ -z "$ATTIC_PID" ]; then + return 0 + fi + if ! kill "$ATTIC_PID" 2>/dev/null; then + echo "▸ attic watch-store already exited before shutdown" >&2 + return 0 + fi + if wait "$ATTIC_PID"; then + echo "▸ attic watch-store exited cleanly after shutdown signal" + return 0 + fi + local status=$? + if [ "$status" -eq 143 ] || [ "$status" -eq 137 ]; then + echo "▸ attic watch-store terminated by shutdown signal (exit ${status}, expected)" + return 0 + fi + echo "▸ attic watch-store exited non-zero (${status}) after shutdown signal" >&2 + if [ "$run_status" -eq 0 ]; then + return "$status" + fi + return 0 +} + +publish_flow() { + : "${PLUGIN_RUN:?PLUGIN_RUN is required when PLUGIN_FLOW=publish}" + # Delegate netrc/redis setup to nixos-ci-entrypoint, but WITHOUT + # ATTIC_TOKEN: this script already owns the attic lifecycle above, so + # nixos-ci-entrypoint must not start a second watch-store. + env -u ATTIC_TOKEN nixos-ci-entrypoint bash -lc "$PLUGIN_RUN" +} + +buildx_build_flow() { + : "${PLUGIN_IMAGE_NAME:?PLUGIN_IMAGE_NAME is required for buildx-build flow}" + local arch + arch=$(normalize_arch "$(uname -m)") + # Note: default assigned in a separate statement, not + # ${PLUGIN_TAG_SCHEME:-{version}-{arch}} — bash's brace-matching for the + # default word gets confused by literal `{...}` braces in the fallback + # and silently corrupts the rendered tag. + local tag_scheme="{version}-{arch}" + if [ -n "${PLUGIN_TAG_SCHEME:-}" ]; then + tag_scheme="${PLUGIN_TAG_SCHEME}" + fi + local version="${CI_COMMIT_TAG:-${PLUGIN_VERSION:-latest}}" + local tag + tag=$(render_tag "$tag_scheme" "$version" "$arch") + local context="${PLUGIN_CONTEXT:-.}" + echo "▸ building ${PLUGIN_IMAGE_NAME}:${tag} for linux/${arch} (context ${context})" + docker buildx build --push --platform "linux/${arch}" -t "${PLUGIN_IMAGE_NAME}:${tag}" "${context}" +} + +buildx_manifest_flow() { + : "${PLUGIN_IMAGE_NAME:?PLUGIN_IMAGE_NAME is required for buildx-manifest flow}" + : "${PLUGIN_PLATFORMS:?PLUGIN_PLATFORMS is required for buildx-manifest flow (comma-separated arches to probe)}" + local tag_scheme="{version}-{arch}" + if [ -n "${PLUGIN_TAG_SCHEME:-}" ]; then + tag_scheme="${PLUGIN_TAG_SCHEME}" + fi + local version="${CI_COMMIT_TAG:-${PLUGIN_VERSION:-latest}}" + local dest_tags="${PLUGIN_DEST_TAGS:-${version},latest}" + + IFS=',' read -ra platforms <<<"$PLUGIN_PLATFORMS" + local found=() + local arch tag ref + for arch in "${platforms[@]}"; do + arch="$(echo "$arch" | xargs)" + tag=$(render_tag "$tag_scheme" "$version" "$arch") + ref="${PLUGIN_IMAGE_NAME}:${tag}" + echo "▸ probing ${ref}" + if DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect "$ref" >/dev/null 2>&1; then + echo "▸ found ${ref}" + found+=("$ref") + else + echo "▸ missing ${ref} (oleks/ci-scripts#9: skipping this arch, not failing the manifest)" + fi + done + + if [ "${#found[@]}" -eq 0 ]; then + echo "no per-arch tags landed for ${PLUGIN_IMAGE_NAME}:${version}; failing manifest step" >&2 + return 1 + fi + + local dests=() + IFS=',' read -ra dests <<<"$dest_tags" + local dest dest_ref + for dest in "${dests[@]}"; do + dest="$(echo "$dest" | xargs)" + dest_ref="${PLUGIN_IMAGE_NAME}:${dest}" + echo "▸ creating manifest ${dest_ref} from ${#found[@]}/${#platforms[@]} arches" + docker manifest create "$dest_ref" "${found[@]}" + docker manifest push "$dest_ref" + done +} + +attic_start + +run_status=0 +case "$PLUGIN_FLOW" in + publish) + publish_flow || run_status=$? + ;; + buildx-build) + buildx_build_flow || run_status=$? + ;; + buildx-manifest) + buildx_manifest_flow || run_status=$? + ;; + *) + echo "unknown PLUGIN_FLOW: ${PLUGIN_FLOW} (expected publish|buildx-build|buildx-manifest)" >&2 + run_status=1 + ;; +esac + +attic_status=0 +attic_stop "$run_status" || attic_status=$? +if [ "$attic_status" -ne 0 ] && [ "$run_status" -eq 0 ]; then + run_status="$attic_status" +fi + +exit "$run_status" diff --git a/manifests/ci-fleet-publish.yaml b/manifests/ci-fleet-publish.yaml new file mode 100644 index 0000000..64e9f09 --- /dev/null +++ b/manifests/ci-fleet-publish.yaml @@ -0,0 +1,6 @@ +repo: ci-fleet-publish +kind: matrix +image: git.oleks.space/oleks/nix-ci +script: ci/local.sh +arches: [amd64, arm64] +buildkit_prefix: buildkit