Commit 37fea96

HPCesia <me@hpcesia.com>
2026-10-18 10:01:25
Publish images from CI when the flake changes
Assisted-by: crush:deepseek-v4,1-flash
1 parent 34bb845
Changed files (2)
.forgejo
workflows
scripts
.forgejo/workflows/publish.yml
@@ -0,0 +1,53 @@
+name: Build and publish images
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - flake.nix
+      - flake.lock
+      - package.nix
+      - scripts/**
+      - .forgejo/workflows/publish.yml
+  workflow_dispatch:
+
+concurrency:
+  group: publish
+  cancel-in-progress: false
+
+jobs:
+  publish:
+    runs-on: nixos-latest-high
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+      - name: Setup Nix
+        run: |
+          set -euo pipefail
+          echo 'extra-platforms = aarch64-linux' >> /etc/nix/nix.conf
+          echo "substituters = https://mirrors.ustc.edu.cn/nix-channels/store?priority=39 \
+          https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store?priority=39 \
+          https://cache.nixos.org?priority=40" >> /etc/nix/nix.conf
+      - name: Log in to the registries
+        env:
+          DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
+          DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+          GOI_USER: ${{ vars.GOI_USER }}
+          GOI_TOKEN: ${{ secrets.GOI_TOKEN }}
+        run: |
+          set -euo pipefail
+          install -d -m 700 /run/ci
+          echo "Login to Docker Hub..."
+          echo "$DOCKERHUB_TOKEN" |
+            podman login --compat-auth-file /run/ci/config.json \
+              --username "$DOCKERHUB_USER" --password-stdin docker.io
+          echo "Login to Gate Of Infinity..."
+          echo "$GOI_TOKEN" |
+            podman login --compat-auth-file /run/ci/config.json \
+              --username "$GOI_USER" --password-stdin git.trin.one
+      - name: Build and publish
+        env:
+          REGISTRY_AUTH_FILE: /run/ci/config.json
+          DOCKER_CONFIG: /run/ci
+        run: ./scripts/publish.sh
scripts/publish.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+#
+# Build and publish one multi-arch index per registry, plus per-arch tags.
+# The caller must point REGISTRY_AUTH_FILE and DOCKER_CONFIG at the same file.
+#
+# Environment:
+#   REGISTRIES  space separated "registry/repository" list
+#   DRY_RUN     1 to print commands instead of running them
+set -euo pipefail
+
+log() { printf 'publish: %s\n' "$*" >&2; }
+die() { printf 'publish: error: %s\n' "$*" >&2; exit 1; }
+
+run() {
+  if [ "${DRY_RUN:-0}" = 1 ]; then
+    printf 'publish: would run:' >&2
+    printf ' %s' "$@" >&2
+    printf '\n' >&2
+  else
+    "$@"
+  fi
+}
+
+repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+flake=$repo_root
+
+registries=${REGISTRIES:-"docker.io/hpcesia/nix-act-image git.trin.one/goi/nix-act-image"}
+read -r -a registries <<<"$registries"
+[ "${#registries[@]}" -gt 0 ] || die "no registries to publish to"
+
+systems=(x86_64-linux aarch64-linux)
+channels=(version latest)
+declare -A architecture=([x86_64-linux]=amd64 [aarch64-linux]=arm64)
+declare -A package_of=([version]=default [latest]=latest)
+
+declare -A image_tag index_tag image_json
+for channel in "${channels[@]}"; do
+  for system in "${systems[@]}"; do
+    image_tag[$channel/$system]=$(nix eval --raw "$flake#packages.$system.${package_of[$channel]}.imageTag")
+  done
+done
+for channel in "${channels[@]}"; do
+  per_arch=${image_tag["$channel/${systems[0]}"]}
+  index_tag[$channel]=${per_arch%-"${systems[0]}"}
+  [ "${index_tag[$channel]}" != "$per_arch" ] ||
+    die "$channel tag $per_arch does not end in -${systems[0]}, cannot derive the index tag"
+  for system in "${systems[@]}"; do
+    per_arch=${image_tag["$channel/$system"]}
+    [ "${per_arch%-"$system"}" = "${index_tag[$channel]}" ] ||
+      die "$channel tag $per_arch does not match index tag ${index_tag[$channel]}"
+  done
+done
+
+log "resolving skopeo and crane from the flake inputs"
+tool() {
+  # crane installs into an output called "crane".
+  nix build --no-link --json --inputs-from "$flake" "$1" |
+    jq -r '.[0].outputs | .out // (to_entries[0].value)'
+}
+skopeo=$(tool 'nix2container#packages.x86_64-linux.skopeo-nix2container')/bin/skopeo
+crane=$(tool 'nixpkgs#crane')/bin/crane
+
+log "building images for: ${systems[*]}"
+installables=()
+for system in "${systems[@]}"; do
+  installables+=("$flake#packages.$system.default")
+done
+nix build --no-link "${installables[@]}"
+for system in "${systems[@]}"; do
+  image_json[$system]=$(nix eval --raw "$flake#packages.$system.default.outPath")
+done
+
+# Check architecture field before reaches registry.
+for system in "${systems[@]}"; do
+  actual=$("$skopeo" --insecure-policy inspect "nix:${image_json[$system]}" | jq -r '.Architecture')
+  [ "$actual" = "${architecture[$system]}" ] ||
+    die "image for $system reports architecture $actual, expected ${architecture[$system]}"
+  log "image for $system is ${architecture[$system]}"
+done
+
+for registry in "${registries[@]}"; do
+  for channel in "${channels[@]}"; do
+    for system in "${systems[@]}"; do
+      ref=$registry:${image_tag["$channel/$system"]}
+      log "pushing $ref"
+      run "$skopeo" --insecure-policy copy "nix:${image_json[$system]}" "docker://$ref"
+    done
+  done
+
+  for channel in "${channels[@]}"; do
+    manifests=()
+    for system in "${systems[@]}"; do
+      manifests+=(-m "$registry:${image_tag["$channel/$system"]}")
+    done
+    log "publishing $registry:${index_tag[$channel]}"
+    run "$crane" index append "${manifests[@]}" -t "$registry:${index_tag[$channel]}"
+  done
+done
+
+if [ "${DRY_RUN:-0}" = 1 ]; then
+  log "dry run, skipping the index checks"
+  exit 0
+fi
+
+for registry in "${registries[@]}"; do
+  for channel in "${channels[@]}"; do
+    platforms=$("$crane" manifest "$registry:${index_tag[$channel]}" |
+      jq -r '.manifests[].platform | "\(.os)/\(.architecture)"' | sort | paste -sd, -)
+    [ "$platforms" = "linux/amd64,linux/arm64" ] ||
+      die "$registry:${index_tag[$channel]} contains [$platforms], expected [linux/amd64,linux/arm64]"
+    log "$registry:${index_tag[$channel]} serves linux/amd64,linux/arm64"
+  done
+done