Commit e211307

HPCesia <me@hpcesia.com>
2026-10-18 15:20:36
Refactor publish CI script
1 parent 8095276
Changed files (5)
.forgejo/workflows/publish.yml
@@ -8,7 +8,7 @@ on:
       - flake.nix
       - flake.lock
       - package.nix
-      - scripts/**
+      - publish.nix
       - .forgejo/workflows/publish.yml
   workflow_dispatch:
 
@@ -49,4 +49,4 @@ jobs:
       - name: Build and publish
         env:
           DOCKER_CONFIG: /run/ci
-        run: ./scripts/publish.sh
+        run: nix run .#publish
scripts/publish.sh
@@ -1,145 +0,0 @@
-#!/usr/bin/env bash
-#
-# Build and publish one multi-arch index per registry, plus per-arch tags.
-# The caller must point DOCKER_CONFIG at a directory holding a config.json
-# with the registry credentials.
-#
-# Environment:
-#   REGISTRIES          space separated "registry/repository" list
-#   CHUNKED_REGISTRIES  space separated registry hosts whose CDN rejects
-#                       large request bodies, so their blobs are sent in
-#                       chunks
-#   UPLOAD_CHUNK_SIZE   bytes per chunked upload request
-#   UPLOAD_CHUNK_MAX    blob size above which an upload is chunked
-#   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"
-
-chunked_registries=${CHUNKED_REGISTRIES:-"git.trin.one"}
-read -r -a chunked_registries <<<"$chunked_registries"
-chunk_size=${UPLOAD_CHUNK_SIZE:-$((20 * 1024 * 1024))}
-chunk_max=${UPLOAD_CHUNK_MAX:-$((8 * 1024 * 1024))}
-
-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, crane and regctl from the flake inputs"
-tool() {
-  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
-regctl=$(tool 'nixpkgs#regclient')/bin/regctl
-
-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
-
-# Reject a wrong architecture label before it reaches the 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
-
-workdir=$(mktemp -d)
-trap 'rm -rf "$workdir"' EXIT
-
-# regctl chunks only when the host is configured for it
-if [ "${#chunked_registries[@]}" -gt 0 ]; then
-  export REGCTL_CONFIG=$workdir/regctl.json
-  for registry in "${chunked_registries[@]}"; do
-    log "chunking uploads to $registry at $chunk_size bytes"
-    run "$regctl" registry set "$registry" \
-      --blob-chunk "$chunk_size" --blob-max "$chunk_max" --skip-check
-  done
-fi
-
-# regctl cannot read the nix2container image file,
-# stages each architecture in an OCI layout first.
-declare -A layout
-for system in "${systems[@]}"; do
-  layout[$system]=$workdir/oci-$system
-  run "$skopeo" --insecure-policy copy "nix:${image_json[$system]}" "oci:${layout[$system]}:image"
-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 "$regctl" image copy "ocidir://${layout[$system]}:image" "$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
flake.nix
@@ -29,6 +29,10 @@
           latest = self'.packages.default.override {
             tag = "latest-${system}";
           };
+          publish = pkgs.callPackage ./publish.nix {
+            self = inputs.self;
+            skopeo = inputs'.nix2container.packages.skopeo-nix2container;
+          };
         };
       };
     });
publish.nix
@@ -0,0 +1,119 @@
+{
+  lib,
+  pkgs,
+  self,
+  skopeo,
+}: let
+  systems = [
+    "x86_64-linux"
+    "aarch64-linux"
+  ];
+
+  channels = {
+    version = "default";
+    latest = "latest";
+  };
+
+  registries = [
+    "docker.io/hpcesia/nix-act-image"
+    "git.trin.one/goi/nix-act-image"
+  ];
+
+  # git.trin.one's CDN rejects large request bodies, so its blobs go up in
+  # chunks.
+  chunkedHosts = ["git.trin.one"];
+  chunkSize = 64 * 1024 * 1024;
+  chunkMax = 8 * 1024 * 1024;
+
+  channelNames = lib.attrNames channels;
+
+  imageOf = system: self.packages.${system}.default;
+  perArchTag = channel: system: self.packages.${system}.${channels.${channel}}.imageTag;
+
+  images = lib.genAttrs systems imageOf;
+
+  perArch = lib.listToAttrs (
+    lib.concatMap (
+      channel:
+        map (system: lib.nameValuePair "${channel}/${system}" (perArchTag channel system)) systems
+    )
+    channelNames
+  );
+
+  indexTag = channel: let
+    stripped = map (system: lib.removeSuffix "-${system}" (perArchTag channel system)) systems;
+  in
+    assert lib.all (tag: tag == lib.head stripped) stripped;
+      lib.head stripped;
+
+  indexTags = lib.genAttrs channelNames indexTag;
+
+  pairs = attrs:
+    lib.concatStringsSep " " (lib.mapAttrsToList (name: value: "[${name}]=${value}") attrs);
+
+  data = ''
+    systems=(${lib.concatStringsSep " " systems})
+    channels=(${lib.concatStringsSep " " channelNames})
+    registries=(${lib.concatStringsSep " " registries})
+    chunked=(${lib.concatStringsSep " " chunkedHosts})
+    declare -A images=(${pairs images})
+    declare -A per_arch=(${pairs perArch})
+    declare -A index_tag=(${pairs indexTags})
+  '';
+
+  text = ''
+    ${data}
+
+    log() { printf 'publish: %s\n' "$*" >&2; }
+    run() {
+      if [ "''${DRY_RUN:-0}" = 1 ]; then
+        printf 'publish: would run: %s\n' "$*" >&2
+      else
+        "$@"
+      fi
+    }
+
+    workdir=$(mktemp -d)
+    trap 'rm -rf "$workdir"' EXIT
+
+    if [ "''${#chunked[@]}" -gt 0 ]; then
+      export REGCTL_CONFIG=$workdir/regctl.json
+      for host in "''${chunked[@]}"; do
+        log "chunking uploads to $host at ${toString chunkSize} bytes"
+        run regctl registry set "$host" \
+          --blob-chunk ${toString chunkSize} --blob-max ${toString chunkMax} \
+          --skip-check
+      done
+    fi
+
+    # regctl cannot read the nix: transport, so each arch is staged as an OCI
+    # layout first.
+    for system in "''${systems[@]}"; do
+      run skopeo --insecure-policy copy \
+        "nix:''${images[$system]}" "oci:$workdir/$system:image"
+    done
+
+    for registry in "''${registries[@]}"; do
+      for channel in "''${channels[@]}"; do
+        refs=()
+        for system in "''${systems[@]}"; do
+          ref="$registry:''${per_arch[$channel/$system]}"
+          log "pushing $ref"
+          run regctl image copy "ocidir://$workdir/$system:image" "$ref"
+          refs+=(--ref "$ref")
+        done
+        log "publishing $registry:''${index_tag[$channel]}"
+        run regctl index create "$registry:''${index_tag[$channel]}" "''${refs[@]}"
+      done
+    done
+  '';
+in
+  pkgs.writeShellApplication {
+    name = "publish";
+    runtimeInputs = [
+      pkgs.coreutils
+      pkgs.regclient
+      skopeo
+    ];
+    inherit text;
+  }
README.md
@@ -37,8 +37,11 @@ podman run -it docker.io/hpcesia/nix-act-image:latest
 ```
 
 ```sh
-# Push image
-nix run .#packages.x86_64-linux.default.copyToRegistry
+# Publish the indexes and per-arch tags to every registry
+nix run .#publish
+
+# Print the commands instead of running them
+DRY_RUN=1 nix run --impure .#publish
 ```
 
 ## Acknowledgements