From ad328b725da5d4b3b187408ce42ca6799a7a0bac Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 20 Sep 2026 15:36:18 -0400 Subject: [PATCH 1/2] Build and test daily package builder images --- .github/workflows/builder-images.yml | 82 +++++++++++++++++ .github/workflows/test.yml | 3 + README.md | 37 ++++++++ bin/builder-image | 66 ++++++++++++++ tests/builder-image.cjs | 130 +++++++++++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 .github/workflows/builder-images.yml create mode 100755 bin/builder-image create mode 100644 tests/builder-image.cjs diff --git a/.github/workflows/builder-images.yml b/.github/workflows/builder-images.yml new file mode 100644 index 0000000..6452813 --- /dev/null +++ b/.github/workflows/builder-images.yml @@ -0,0 +1,82 @@ +name: Refresh builder images + +on: + schedule: + - cron: '23 4 * * *' + push: + branches: [master] + paths: + - build/** + - bin/builder-image + - helpers/paths.sh + - helpers/docker-helpers.sh + - tests/build-isolation.sh + - .github/workflows/builder-images.yml + workflow_dispatch: + +# Complete each refresh before another can replace its tested image tags. +concurrency: + group: builder-images + cancel-in-progress: false + +permissions: + contents: read + +jobs: + refresh: + if: github.repository == 'omacom/omarchy-pkgs' && github.ref == 'refs/heads/master' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + env: + CONTAINER_ENGINE: docker + REGISTRY_IMAGE: ghcr.io/omacom/omarchy-pkg-builder + CANDIDATE_IMAGE: omarchy-builder-candidate:${{ matrix.arch }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build a fresh environment + run: bin/builder-image build --arch "${{ matrix.arch }}" --mirror edge --tag "$CANDIDATE_IMAGE" --fresh + - name: Test isolated package builds + env: + TEST_BUILDER_IMAGE: ${{ env.CANDIDATE_IMAGE }} + run: tests/build-isolation.sh + - name: Publish tested image + env: + GH_TOKEN: ${{ github.token }} + GH_ACTOR: ${{ github.actor }} + DOCKER_CONFIG: ${{ runner.temp }}/builder-registry-auth + run: | + set -euo pipefail + mkdir -p "$DOCKER_CONFIG" + trap 'rm -rf "$DOCKER_CONFIG"' EXIT + printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GH_ACTOR" --password-stdin + key=$(bin/builder-image key --arch "${{ matrix.arch }}" --mirror edge) + version="$REGISTRY_IMAGE:$key-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + docker tag "$CANDIDATE_IMAGE" "$version" + docker push "$version" + # GHCR creates new packages private. Do not advertise an image to + # fork PRs until it is public. This is a one-time package setting. + anonymous_config=$(mktemp -d "$RUNNER_TEMP/builder-anonymous.XXXXXX") + if ! DOCKER_CONFIG="$anonymous_config" docker manifest inspect "$version" >/dev/null; then + rm -rf "$anonymous_config" + echo "::error::Make the omacom/omarchy-pkg-builder GHCR package public, then rerun this job. The previous matching image remains selected." + exit 1 + fi + rm -rf "$anonymous_config" + docker tag "$CANDIDATE_IMAGE" "$REGISTRY_IMAGE:$key" + docker push "$REGISTRY_IMAGE:$key" + digest=$(docker image inspect "$version" --format '{{index .RepoDigests 0}}') + printf '### Builder image (%s)\n\nInput key: `%s`\n\nImage: `%s`\n' \ + "${{ matrix.arch }}" "$key" "$digest" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acf3704..4cb0800 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,9 @@ jobs: - name: Test PR workflow approval run: node --test tests/pr-workflow-approval.cjs + - name: Test builder images + run: node --test tests/builder-image.cjs + # An Arch container for vercmp: version ordering has to be decided by # the same comparator pacman uses on users' machines. - name: Run self-tests diff --git a/README.md b/README.md index c29aae8..5089dd7 100644 --- a/README.md +++ b/README.md @@ -825,6 +825,43 @@ using real containers and pacman transactions. It uses the prepared builder image, or an image named by `TEST_BUILDER_IMAGE`; CI builds the small fixture image in `tests/build-isolation.Dockerfile`. +### Daily builder images + +`Refresh builder images` builds fresh `edge` environments daily at 04:23 UTC, +when their inputs change on `master`, and on manual dispatch. x86_64 and +aarch64 build on native GitHub-hosted runners, without occupying the DO +package-builder pool. Each candidate must pass `tests/build-isolation.sh`, +including real package builds, before publication to +`ghcr.io/omacom/omarchy-pkg-builder`. Only `master` in this repository can +publish; PR workflows cannot replace the shared images. + +The compatibility tag contains the architecture, mirror, and a hash of the +entire `build/` context, including executable bits and symlink targets but +excluding checkout timestamps and ownership. This deliberately invalidates +images when mounted build scripts change too. `v1` identifies the image build +contract; change it if the invocation or compatibility rules change. Each +successful refresh also gets a run-specific tag for diagnosis and rollback. +A failed build, isolation test, or push leaves the previous compatible image +selected. Scheduled builds use `--pull --no-cache` so unchanged Dockerfiles +still pick up fresh Arch packages. + +To build and test a candidate locally: + +```bash +bin/builder-image key --arch x86_64 --mirror edge +bin/builder-image build --arch x86_64 --mirror edge --tag builder-candidate:test --fresh +CONTAINER_ENGINE=docker TEST_BUILDER_IMAGE=builder-candidate:test tests/build-isolation.sh +``` + +The workflow uses its repository `GITHUB_TOKEN` with `packages: write`; no +registry PAT is needed. **First publication needs one package setting:** GHCR +creates the package private. In the `omacom/omarchy-pkg-builder` package +settings, change visibility to **Public**, then rerun the failed refresh job. +The workflow checks anonymous registry access before advancing the compatible +tag, so fork PRs will not be directed to an image they cannot pull. Subsequent +refreshes preserve that package visibility. This change only produces images; +package jobs keep their existing behavior until image consumption is enabled. + ## Version Management Packages are only rebuilt if: diff --git a/bin/builder-image b/bin/builder-image new file mode 100755 index 0000000..f23f64b --- /dev/null +++ b/bin/builder-image @@ -0,0 +1,66 @@ +#!/bin/bash +# Build a reusable package environment from this checkout's own inputs. +set -euo pipefail + +BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") +source "$BUILD_ROOT/helpers/paths.sh" + +usage() { + echo "Usage: bin/builder-image {key|build} [--arch x86_64|aarch64] [--mirror edge|rc|stable] [--tag IMAGE] [--fresh]" +} + +command=${1:-} +[[ $# -eq 0 ]] || shift +tag="" +fresh=false +while (( $# )); do + case "$1" in + --arch) ARCH=${2:?Missing architecture}; shift 2 ;; + --mirror) MIRROR=${2:?Missing mirror}; shift 2 ;; + --tag) tag=${2:?Missing image tag}; shift 2 ;; + --fresh) fresh=true; shift ;; + *) usage >&2; exit 1 ;; + esac +done +require_valid_arch "$ARCH" +validate_mirror "$MIRROR" || { echo "Invalid mirror: $MIRROR" >&2; exit 1; } +case "$command" in key|build) ;; *) usage >&2; exit 1 ;; esac +if [[ $command == key && ( -n $tag || $fresh == true ) ]]; then + usage >&2 + exit 1 +fi + +# Include the whole build context, conservatively including mounted build +# scripts too. Normalize timestamps and ownership so fresh checkouts agree; +# retain file contents, names, executable bits and symlink targets. Bump v1 +# if the image build invocation or this compatibility contract changes. +hash=$(tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \ + --format=gnu -cf - -C "$BUILD_DIR" . | sha256sum | cut -d' ' -f1) +key="v1-$ARCH-$MIRROR-$hash" +if [[ $command == key ]]; then + echo "$key" + exit 0 +fi + +source "$BUILD_ROOT/helpers/message-helpers.sh" +source "$BUILD_ROOT/helpers/docker-helpers.sh" +check_engine +platform=$(get_platform_arg "$ARCH") +tag=${tag:-omarchy-pkg-builder:latest-$ARCH-$MIRROR} +revision=$(git -C "$BUILD_ROOT" rev-parse HEAD 2>/dev/null || echo unknown) +args=("$platform" --build-arg "MIRROR=$MIRROR" + --label "org.omarchy.builder.key=$key" + --label "org.opencontainers.image.source=https://github.com/omacom/omarchy-pkgs" + --label "org.opencontainers.image.revision=$revision" + --label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + --tag "$tag" --file "$BUILD_DIR/Dockerfile") +if [[ $fresh == true ]]; then + # A daily build must refresh Arch even when its Dockerfile has not changed. + args+=(--no-cache) + if [[ $CONTAINER_ENGINE == docker ]]; then args+=(--pull); else args+=(--pull=always); fi +fi +if [[ $CONTAINER_ENGINE == docker ]]; then + docker buildx build --load "${args[@]}" "$BUILD_DIR" +else + podman build "${args[@]}" "$BUILD_DIR" +fi diff --git a/tests/builder-image.cjs b/tests/builder-image.cjs new file mode 100644 index 0000000..c20f306 --- /dev/null +++ b/tests/builder-image.cjs @@ -0,0 +1,130 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { test } = require('node:test'); + +const root = join(__dirname, '..'); +const workflow = readFileSync(join(root, '.github/workflows/builder-images.yml'), 'utf8'); + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'builder-image-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(join(directory, 'bin')); + mkdirSync(join(directory, 'build')); + cpSync(join(root, 'helpers'), join(directory, 'helpers'), { recursive: true }); + cpSync(join(root, 'bin/builder-image'), join(directory, 'bin/builder-image')); + writeFileSync(join(directory, 'build/Dockerfile'), 'FROM scratch\nCOPY input /input\n'); + writeFileSync(join(directory, 'build/input'), 'original input\n'); + const engine = join(directory, 'engine'); + mkdirSync(engine); + const log = join(directory, 'engine.jsonl'); + writeFileSync(join(engine, 'docker'), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.ENGINE_LOG, JSON.stringify(args) + '\\n'); +if (args[0] === 'manifest' && process.env.PRIVATE_IMAGE === '1') process.exit(1); +if (args[0] === 'push' && process.env.PUSH_FAIL === '1') process.exit(1); +if (args[0] === 'image' && args[1] === 'inspect') console.log('ghcr.io/omacom/omarchy-pkg-builder@sha256:' + 'a'.repeat(64)); +`); + chmodSync(join(engine, 'docker'), 0o755); + const env = { + ...process.env, PATH: `${engine}:${process.env.PATH}`, CONTAINER_ENGINE: 'docker', + ENGINE_LOG: log, ARCH: 'x86_64', MIRROR: 'edge', + }; + const run = (args, extraEnv = {}) => spawnSync(join(directory, 'bin/builder-image'), args, { + cwd: directory, env: { ...env, ...extraEnv }, encoding: 'utf8', + }); + const key = (...args) => { + const result = run(['key', ...args]); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + return { directory, env, run, key, calls }; +} + +test('image keys are stable across checkout location and timestamp changes', t => { + const a = fixture(t); + const b = fixture(t); + const key = a.key(); + assert.match(key, /^v1-x86_64-edge-[a-f0-9]{64}$/); + utimesSync(join(b.directory, 'build/input'), new Date(0), new Date(0)); + assert.equal(b.key(), key); +}); + +test('image keys separate architectures, mirrors, content, modes and symlink targets', t => { + const f = fixture(t); + const keys = new Set([f.key(), f.key('--arch', 'aarch64'), f.key('--mirror', 'rc'), f.key('--mirror', 'stable')]); + writeFileSync(join(f.directory, 'build/input'), 'new input\n'); + keys.add(f.key()); + chmodSync(join(f.directory, 'build/input'), 0o755); + keys.add(f.key()); + symlinkSync('input', join(f.directory, 'build/link')); + keys.add(f.key()); + rmSync(join(f.directory, 'build/link')); + symlinkSync('Dockerfile', join(f.directory, 'build/link')); + keys.add(f.key()); + assert.equal(keys.size, 8); + mkdirSync(join(f.directory, 'pkgbuilds/example'), { recursive: true }); + const key = f.key(); + writeFileSync(join(f.directory, 'pkgbuilds/example/PKGBUILD'), 'pkgver=2\n'); + assert.equal(f.key(), key, 'package changes must not invalidate the build environment'); +}); + +test('fresh builds refresh package layers and record their compatibility key', t => { + const f = fixture(t); + const key = f.key('--arch', 'aarch64'); + const result = f.run(['build', '--arch', 'aarch64', '--tag', 'candidate:test', '--fresh']); + assert.equal(result.status, 0, result.stderr); + const build = f.calls().find(args => args[0] === 'buildx'); + assert.ok(build.includes('--no-cache')); + assert.ok(build.includes('--pull')); + assert.ok(build.includes('--load')); + assert.ok(build.includes('--platform=linux/arm64')); + assert.ok(build.includes(`org.omarchy.builder.key=${key}`)); + assert.ok(build.includes('candidate:test')); +}); + +test('invalid targets fail before starting an image build', t => { + const f = fixture(t); + for (const args of [['key', '--arch', 'invalid'], ['build', '--mirror', 'invalid'], ['key', '--fresh']]) { + assert.notEqual(f.run(args).status, 0); + } +}); + +function publish(f, extraEnv = {}) { + const script = workflow.split(' - name: Publish tested image\n')[1].split(' run: |\n')[1] + .replaceAll('${{ matrix.arch }}', 'x86_64'); + return spawnSync('bash', ['-e', '-o', 'pipefail', '-c', script], { + cwd: f.directory, encoding: 'utf8', env: { + ...f.env, REGISTRY_IMAGE: 'ghcr.io/omacom/omarchy-pkg-builder', CANDIDATE_IMAGE: 'candidate:test', + GH_TOKEN: 'fixture', GH_ACTOR: 'fixture', DOCKER_CONFIG: join(f.directory, 'auth'), + RUNNER_TEMP: f.directory, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', + GITHUB_STEP_SUMMARY: join(f.directory, 'summary'), ...extraEnv, + }, + }); +} + +test('a public tested image gets a version tag before the compatible-image tag advances', t => { + const f = fixture(t); + const key = f.key(); + const result = publish(f); + assert.equal(result.status, 0, result.stderr); + const calls = f.calls(); + assert.deepEqual(calls.filter(args => args[0] === 'push').map(args => args[1]), [ + `ghcr.io/omacom/omarchy-pkg-builder:${key}-123-1`, `ghcr.io/omacom/omarchy-pkg-builder:${key}`, + ]); + assert.ok(calls.findIndex(args => args[0] === 'manifest') < calls.findLastIndex(args => args[0] === 'push')); +}); + +test('a private image or failed push never replaces the previous compatible-image tag', t => { + for (const extraEnv of [{ PRIVATE_IMAGE: '1' }, { PUSH_FAIL: '1' }]) { + const f = fixture(t); + const key = f.key(); + const result = publish(f, extraEnv); + assert.notEqual(result.status, 0); + assert.equal(f.calls().some(args => args[0] === 'push' && args[1] === `ghcr.io/omacom/omarchy-pkg-builder:${key}`), false); + } +}); From 30af71af24be4a15f1857d9f51857279de0ab6a6 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 20 Sep 2026 15:41:27 -0400 Subject: [PATCH 2/2] Validate proposed builder images on both native architectures --- .github/workflows/builder-images.yml | 38 +++++++++++++++++++++++++++- README.md | 2 ++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/builder-images.yml b/.github/workflows/builder-images.yml index 6452813..db2bb08 100644 --- a/.github/workflows/builder-images.yml +++ b/.github/workflows/builder-images.yml @@ -13,16 +13,52 @@ on: - tests/build-isolation.sh - .github/workflows/builder-images.yml workflow_dispatch: + pull_request: + paths: + - build/** + - bin/builder-image + - helpers/paths.sh + - helpers/docker-helpers.sh + - tests/build-isolation.sh + - .github/workflows/builder-images.yml # Complete each refresh before another can replace its tested image tags. concurrency: - group: builder-images + group: builder-images-${{ github.event.pull_request.number || 'master' }} cancel-in-progress: false permissions: contents: read jobs: + # Exercise proposed image changes on native runners with a read-only token. + # Publishing is a separate master-only job with its own write permission. + validate: + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + CONTAINER_ENGINE: docker + CANDIDATE_IMAGE: omarchy-builder-candidate:${{ matrix.arch }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build a fresh environment + run: bin/builder-image build --arch "${{ matrix.arch }}" --mirror edge --tag "$CANDIDATE_IMAGE" --fresh + - name: Test isolated package builds + env: + TEST_BUILDER_IMAGE: ${{ env.CANDIDATE_IMAGE }} + run: tests/build-isolation.sh + refresh: if: github.repository == 'omacom/omarchy-pkgs' && github.ref == 'refs/heads/master' strategy: diff --git a/README.md b/README.md index 5089dd7..8823595 100644 --- a/README.md +++ b/README.md @@ -834,6 +834,8 @@ package-builder pool. Each candidate must pass `tests/build-isolation.sh`, including real package builds, before publication to `ghcr.io/omacom/omarchy-pkg-builder`. Only `master` in this repository can publish; PR workflows cannot replace the shared images. +PRs that change image inputs also build and test both candidates on native +runners, with a read-only token and no registry publication. The compatibility tag contains the architecture, mirror, and a hash of the entire `build/` context, including executable bits and symlink targets but