Files
omarchy-pkgs/.github/workflows/publish.yml
T
Emir BeganovićandRyan Hughes 5d9783b85e Publish pulls the tested builder image instead of building it on every run (#850)
A builder droplet starts with no images, so publish.yml built
omarchy-pkg-builder from the Dockerfile each time: about 100 s of
pacstrap, keyring setup and toolchain install, to run gpg, repo-add,
bsdtar and rclone for about 14 s.

builder-images.yml already publishes the tested image for the current
build inputs to ghcr.io/omacom/omarchy-pkg-builder under bin/builder-image
key. Pull that, check its org.omarchy.builder.key label, and tag it as the
local name the rest of the step uses.

Build as before when no image carries the key, which is what a merge
that changes build/ sees until the refresh it triggered has finished.

Co-authored-by: Ryan Hughes <ryan@heyoodle.com>
2026-10-08 14:57:21 -04:00

451 lines
24 KiB
YAML

name: Publish merged packages
# On every push to master: for each package directory the push touched and
# each architecture it supports, find the PR build artifact for exactly that
# tree (label = <pkg>-<arch>-<treehash>), or build it now when there is
# none, then publish that one artifact into every channel the package ships
# to. One build, one file, several databases: a filename means one set of
# bytes everywhere, and channels are views over a shared pool.
#
# Secrets live in the "publish" environment, restricted to master:
# GPG_PRIVATE_KEY, GPG_PASSPHRASE the channel signing key
# R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT
# OMARCHY_PUBLISH_PREFIX (environment variable, not secret) points a proof
# run at a scratch prefix inside the live bucket; empty means the real
# channel paths.
on:
push:
branches: [master]
paths: ["pkgbuilds/**"]
workflow_dispatch:
inputs:
packages:
description: "Space-separated package directories to publish from master"
required: true
# Merges serialize. Two publishes into one channel at once would race on
# the database; queued is fine, cancelled is not.
concurrency:
group: publish
cancel-in-progress: false
jobs:
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.list.outputs.matrix }}
count: ${{ steps.list.outputs.count }}
rebuild: ${{ steps.list.outputs.rebuild }}
rebuild_count: ${{ steps.list.outputs.rebuild_count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: list
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [[ -n "${{ github.event.inputs.packages }}" ]]; then
names="${{ github.event.inputs.packages }}"
else
names=$(git diff --name-only "${{ github.event.before }}..${{ github.sha }}" -- pkgbuilds \
| awk -F/ '$1=="pkgbuilds" && NF>2 {print $2}' | sort -u)
fi
matrix=$(printf '%s\n' $names | bin/build-matrix)
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "count=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT"
jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix"
# Reuse or rebuild, decided per entry and said out loud. An aarch64
# tree with no build artifact (PR artifacts last 7 days; a dispatch
# may name any package) goes to the rebuild job, which builds it
# natively on GitHub's arm64 runner. x86_64 builds inside the
# publish job on the droplet, as before.
rebuild=()
echo "| package | arch | tree | decision |" >> "$GITHUB_STEP_SUMMARY"
echo "| --- | --- | --- | --- |" >> "$GITHUB_STEP_SUMMARY"
while read -r entry; do
package=$(jq -r .package <<<"$entry"); arch=$(jq -r .arch <<<"$entry")
hash=$(git rev-parse "HEAD:pkgbuilds/$package")
label="$package-$arch-$hash"
found=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/artifacts?name=$label&per_page=5" \
| jq -r '[.artifacts[] | select(.expired|not)] | sort_by(.created_at) | last | select(. != null) | "run \(.workflow_run.id), expires \(.expires_at)"')
if [[ -n "$found" ]]; then
decision="reuse the build artifact ($found)"
elif [[ $arch == aarch64 ]]; then
decision="no build artifact: rebuild natively on ubuntu-24.04-arm"
rebuild+=("$entry")
else
decision="no build artifact: build in the publish job on the self-hosted builder"
fi
echo "==> $label: $decision"
echo "| $package | $arch | ${hash:0:12} | $decision |" >> "$GITHUB_STEP_SUMMARY"
done < <(jq -c '.include[]' <<<"$matrix")
echo "rebuild=$(printf '%s\n' "${rebuild[@]}" | jq -sc '{include: .}')" >> "$GITHUB_OUTPUT"
echo "rebuild_count=${#rebuild[@]}" >> "$GITHUB_OUTPUT"
# The aarch64 half of "build it now when there is none". It builds exactly
# as build-pr.yml's aarch64 path does (same runner, same builder image,
# same bin/build call) and uploads under the same label, so the publish
# job collects this run's artifact the way it collects a PR's. No secret
# reaches this runner; signing and upload stay on the self-hosted builder.
rebuild:
needs: changes
if: needs.changes.outputs.rebuild_count != '0'
runs-on: ubuntu-24.04-arm
timeout-minutes: 180
permissions:
contents: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.changes.outputs.rebuild) }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# The same check the publish job makes before building: a re-run for a
# package the channel already holds at master's version builds
# nothing, and uploads nothing that could shadow the published file.
- name: Build ${{ matrix.package }} (${{ matrix.arch }}, native)
id: build
env:
CONTAINER_ENGINE: docker
run: |
set -euo pipefail
plan=$(bin/build --dry-run --mirror edge --arch "${{ matrix.arch }}" --package "${{ matrix.package }}" 2>&1 | grep -E '^==> Plan complete' || true)
if [[ -n "$plan" && -z "$(sed -E 's/.*would build: *//' <<<"$plan" | tr -d '[:space:]')" ]]; then
echo "==> ${{ matrix.package }} (${{ matrix.arch }}): already published at master's version, nothing to build"
echo "built=false" >> "$GITHUB_OUTPUT"
exit 0
fi
bin/build --mirror edge --arch "${{ matrix.arch }}" --package "${{ matrix.package }}"
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Pack artifact
if: steps.build.outputs.built == 'true'
id: pack
run: |
source helpers/artifact-helpers.sh
pack_packages build-output/edge/${{ matrix.arch }} packages.tar
tar -tvf packages.tar
echo "label=${{ matrix.package }}-${{ matrix.arch }}-$(git rev-parse "HEAD:pkgbuilds/${{ matrix.package }}")" >> "$GITHUB_OUTPUT"
- name: Upload artifact
if: steps.build.outputs.built == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.pack.outputs.label }}
path: packages.tar
if-no-files-found: error
retention-days: 7
# One job for the whole merge. It collects every PR artifact for the
# merged tree (building only what has none; aarch64 comes from the
# rebuild job above), then walks each channel and
# architecture slot exactly once: pull that database, add every package
# that belongs in it, upload. Six slots, six round trips, however many
# packages the merge carried. One process is the only writer, so there
# is no race between packages; the run-level concurrency group above
# keeps one merge from overlapping the next.
# It waits for the rebuild job and runs whatever that job's result: a
# failed rebuild leaves its package without an artifact, and the collect
# step below records that and stops before any publish.
publish:
needs: [changes, rebuild]
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.count != '0' }}
runs-on: [self-hosted, omarchy-builder]
environment: publish
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Every matrix entry, as a file the shell steps can loop over:
# package arch channels publish_arches
- name: Plan
run: |
jq -r '.include[] | "\(.package) \(.arch) \(.channels|gsub(" ";",")) \(.publish_arches|gsub(" ";","))"' \
<<'EOF_MATRIX' > plan.txt
${{ needs.changes.outputs.matrix }}
EOF_MATRIX
cat plan.txt
# Fetch each package's PR artifact into build-output/edge/<arch>/, or
# build it when no artifact exists for exactly this tree. An artifact
# carries its package files inside packages.tar (see build-pr.yml and
# helpers/artifact-helpers.sh: the upload action rejects the colon in
# an epoch filename).
- name: Collect artifacts
env:
GH_TOKEN: ${{ github.token }}
CONTAINER_ENGINE: docker
run: |
set -uo pipefail
source helpers/artifact-helpers.sh
# sources.jsonl: where each package's files came from, or that the
# build failed. A failed build ends the run before any publish, and
# the record says so instead of the report job finding nothing.
: > sources.jsonl
failed=0
while read -r package arch channels publish_arches; do
hash=$(git rev-parse "HEAD:pkgbuilds/$package")
label="$package-$arch-$hash"
found=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/artifacts?name=$label&per_page=5" \
| jq -r '[.artifacts[] | select(.expired|not)] | sort_by(.created_at) | last | select(. != null) | "\(.archive_download_url) \(.workflow_run.id)"')
read -r found from_run <<<"$found" || true
mkdir -p "build-output/edge/$arch"
if [[ -n "$found" ]]; then
if [[ $from_run == "${{ github.run_id }}" ]]; then
kind=native-rebuild
echo "==> $label: artifact from this run's native $arch rebuild"
else
kind=pr-artifact
echo "==> $label: reusing the build artifact from run $from_run"
fi
rm -rf /tmp/artifact; mkdir -p /tmp/artifact
if curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -o /tmp/artifact.zip "$found" \
&& unzip -oq /tmp/artifact.zip -d /tmp/artifact \
&& unpack_packages /tmp/artifact "build-output/edge/$arch"; then
jq -nc --arg p "$package" --arg a "$arch" --arg s "$kind" '{package:$p, arch:$a, source:$s}' >> sources.jsonl
else
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"artifact-download-failed"}' >> sources.jsonl; failed=1; break
fi
else
# bin/build plans against the public channel first. If the
# channel already holds master's version there is nothing to
# build and nothing to publish: a re-run for a package that
# turned out to be fine. Record it and move on.
plan=$(CONTAINER_ENGINE=docker bin/build --dry-run --mirror edge --arch "$arch" --package "$package" 2>&1 | grep -E '^==> Plan complete' || true)
# "Packages that would build:" followed by nothing means none.
if [[ -n "$plan" && -z "$(sed -E 's/.*would build: *//' <<<"$plan" | tr -d '[:space:]')" ]]; then
echo "==> $label: already published at master's version, nothing to do"
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"already-published"}' >> sources.jsonl
continue
fi
# aarch64 never builds here: this droplet is x86 and would
# emulate it. No artifact means the native rebuild failed (see
# the rebuild job), or an artifact expired between planning
# and now (re-run all jobs).
if [[ $arch == aarch64 ]]; then
echo "::error::$label: no artifact from the native rebuild; not building aarch64 under emulation"
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"native-build-failed"}' >> sources.jsonl; failed=1; break
fi
echo "==> $label: no artifact for this tree, building"
if OMARCHY_KEEP_BUILD_WORKSPACE=1 bin/build --mirror edge --arch "$arch" --package "$package"; then
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"built"}' >> sources.jsonl
else
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"build-failed"}' >> sources.jsonl; failed=1; break
fi
fi
done < plan.txt
ls -1 build-output/edge/*/*.pkg.tar.zst 2>/dev/null || true
if (( failed )); then
# Write the record now; the publish step will not run.
jq -n --arg sha "${{ github.sha }}" --arg run "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg prefix "${OMARCHY_PUBLISH_PREFIX:-}" --arg event "${{ github.event_name }}" \
--slurpfile sources sources.jsonl --slurpfile plan <(jq -c '.include[]' <<<'${{ needs.changes.outputs.matrix }}' | jq -sc .) \
'{time: (now|todate), commit:$sha, run:$run, event:$event, target: (if $prefix=="" then "live" else $prefix end), plan:$plan[0], sources:$sources, slots:[]}' \
> publish-record.json
cat publish-record.json
exit 1
fi
- name: Publish
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
RCLONE_CONFIG_R2_TYPE: s3
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
# The token is scoped to the bucket; it may not CreateBucket, and
# rclone's existence check is a CreateBucket in disguise.
RCLONE_CONFIG_R2_NO_CHECK_BUCKET: "true"
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
OMARCHY_PUBLISH_PREFIX: ${{ vars.OMARCHY_PUBLISH_PREFIX }}
# repo-add, gpg and bsdtar are Arch tools; run the publish inside the
# builder image (host-native, edge) with the workspace mounted.
run: |
set -euo pipefail
if ! compgen -G "build-output/edge/*/*.pkg.tar.zst" >/dev/null; then
echo "Nothing to publish: every requested package is already published at master's version."
jq -n --arg sha "${{ github.sha }}" --arg run "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg prefix "${OMARCHY_PUBLISH_PREFIX:-}" --arg event "${{ github.event_name }}" \
--slurpfile sources sources.jsonl --slurpfile plan <(jq -c '.include[]' <<<'${{ needs.changes.outputs.matrix }}' | jq -sc .) \
'{time: (now|todate), commit:$sha, run:$run, event:$event, target: (if $prefix=="" then "live" else $prefix end), plan:$plan[0], sources:$sources, slots:[]}' \
> publish-record.json
cat publish-record.json
exit 0
fi
# A builder droplet starts with no images, so building this one here
# cost every publish about 100 s (and 20 s more to start a container
# from it) for the 14 s of signing and upload it is needed for.
# builder-images.yml already publishes the tested image for exactly
# these build inputs under their key; pull that. Build only when no
# image carries the key: a merge that changed build/ publishes
# before the refresh it triggered has finished.
builder=omarchy-pkg-builder:latest-x86_64-edge
if ! docker image inspect "$builder" >/dev/null 2>&1; then
key=$(bin/builder-image key --arch x86_64 --mirror edge)
published="ghcr.io/omacom/omarchy-pkg-builder:$key"
if docker pull --quiet "$published" &&
[[ $(docker image inspect "$published" --format '{{index .Config.Labels "org.omarchy.builder.key"}}') == "$key" ]]; then
docker tag "$published" "$builder"
echo "==> Builder image: pulled $published"
else
echo "==> Builder image: none published for $key, building it"
docker buildx build --load -t "$builder" --build-arg MIRROR=edge build
fi
fi
# Group the merge's files by the (channel, architecture) slot each
# belongs to. A package's files live under build-output/edge/<built
# arch>/ and are named <pkgname>-<ver>-<arch|any>.pkg.tar.zst; a
# split package's outputs share the pkgbase's directory, so match
# on the artifact list rather than the name.
# pkgbase is read inside the builder image: the Ubuntu host has no
# bsdtar. One container call maps every file to its pkgbase.
docker run --rm -v "$PWD:/w:ro" -w /w omarchy-pkg-builder:latest-x86_64-edge bash -c '
for f in build-output/edge/*/*.pkg.tar.zst; do
printf "%s %s\n" "$f" "$(bsdtar -xOf "$f" .PKGINFO | awk -F" = " "\$1==\"pkgbase\"{print \$2}")"
done' > pkgbase.txt
declare -A slot_files=()
while read -r package arch channels publish_arches; do
for f in build-output/edge/"$arch"/*.pkg.tar.zst; do
# Only files this package produced (its PKGINFO pkgbase).
[[ $(awk -v f="$f" '$1==f{print $2}' pkgbase.txt) == "$package" ]] || continue
for mirror in ${channels//,/ }; do
for parch in ${publish_arches//,/ }; do
slot_files["$mirror/$parch"]+="$f "
done
done
done
done < plan.txt
# Deterministic slot order: edge before rc before stable, x86_64
# before aarch64, so a failure leaves the earlier rings consistent.
# Every slot's outcome goes into publish-record.json for the report
# job: what was published, where, from which artifact, and whether
# the slot succeeded. A failing slot stops the loop (set -e) but the
# record still shows everything before it landed.
: > slots.jsonl
record_slot() { jq -nc --arg m "$1" --arg a "$2" --arg st "$3" --arg files "$4" \
'{mirror:$m, arch:$a, status:$st, packages:[$files | split(" ") | .[] | select(length>0) | sub(".*/";"") | sub("\\.pkg\\.tar\\.zst$";"")]}' >> slots.jsonl; }
status=0
for mirror in edge rc stable; do
for parch in x86_64 aarch64; do
files=${slot_files["$mirror/$parch"]:-}
[[ -n "$files" ]] || continue
echo "==> $mirror/$parch: $files"
if docker run --rm \
-e GPG_PRIVATE_KEY -e GPG_PASSPHRASE -e OMARCHY_PUBLISH_PREFIX \
-e RCLONE_CONFIG_R2_TYPE -e RCLONE_CONFIG_R2_PROVIDER -e RCLONE_CONFIG_R2_ENDPOINT -e RCLONE_CONFIG_R2_NO_CHECK_BUCKET \
-e RCLONE_CONFIG_R2_ACCESS_KEY_ID -e RCLONE_CONFIG_R2_SECRET_ACCESS_KEY \
-v "$PWD:/w:ro" -w /w \
omarchy-pkg-builder:latest-x86_64-edge \
bin/publish-artifact --remote R2:omarchy-pkgs --mirror "$mirror" --arch "$parch" $files; then
record_slot "$mirror" "$parch" published "$files"
else
record_slot "$mirror" "$parch" failed "$files"
status=1
break 2
fi
done
done
jq -n --arg sha "${{ github.sha }}" --arg run "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg prefix "${OMARCHY_PUBLISH_PREFIX:-}" --arg event "${{ github.event_name }}" \
--slurpfile slots slots.jsonl --slurpfile sources sources.jsonl --slurpfile plan <(jq -c '.include[]' <<<'${{ needs.changes.outputs.matrix }}' | jq -sc .) \
'{time: (now|todate), commit:$sha, run:$run, event:$event, target: (if $prefix=="" then "live" else $prefix end), plan:$plan[0], sources:$sources, slots:$slots}' \
> publish-record.json
cat publish-record.json
exit $status
- name: Keep the publish record
if: always()
uses: actions/upload-artifact@v4
with:
name: publish-record-${{ github.run_id }}
path: publish-record.json
retention-days: 90
# Tell people what happened. A comment on the merged PR (found by the
# merge commit, so squash and rebase merges work too) and a line appended
# to a running JSON log in the bucket, next to the packages it describes,
# so the history is public and can be rendered later.
report:
needs: [changes, publish]
if: always() && needs.publish.result != 'skipped'
runs-on: ubuntu-latest
environment: publish
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/download-artifact@v4
with:
name: publish-record-${{ github.run_id }}
- name: Render
id: render
run: |
jq -r --arg outcome "${{ needs.publish.result }}" '
def pkgs: [.packages[] | select(test("-debug-")|not)] | join(", ");
def src: if .source=="pr-artifact" then "PR artifact" elif .source=="native-rebuild" then "rebuilt natively on arm64" elif .source=="built" then "built here" elif .source=="already-published" then "already published, skipped" else "**" + .source + "**" end;
"### Publish " + (if $outcome=="success" then "succeeded" else "FAILED" end) +
" → **" + .target + "**" + (if .target!="live" then " (proof prefix, not live)" else "" end),
"",
"Packages: " + ([.sources[] | .package + " (" + .arch + ", " + src + ")"] | join("; ")),
"",
(if (.slots|length) > 0 then (.slots[] | "- `" + .mirror + "/" + .arch + "`: " + (if .status=="published" then "" else "**" + .status + "** " end) + pkgs)
elif ([.sources[] | select(.source=="already-published")] | length) == (.sources|length) then "_Nothing to publish: already at master'"'"'s version everywhere._"
else "_Nothing was published._" end),
"",
(if ([.sources[] | select(.source|test("failed"))] | length) > 0 then "_A build failed, so no channel was touched._\n"
elif (.plan|length) > (.slots|length) then "_Some planned slots did not run because an earlier slot failed._\n" else "" end),
"Commit " + .commit[0:7] + " · [run](" + .run + ")"
' publish-record.json > comment.md
cat comment.md
- name: Append to the publish log in the bucket
env:
RCLONE_CONFIG_R2_TYPE: s3
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
RCLONE_CONFIG_R2_NO_CHECK_BUCKET: "true"
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
run: |
curl -fsSL https://downloads.rclone.org/rclone-current-linux-amd64.zip -o rclone.zip && unzip -jq rclone.zip '*/rclone' && chmod +x rclone
# One JSON object per line, newest last. Served at
# https://pkgs.omarchy.org/publish-log.jsonl
./rclone copy R2:omarchy-pkgs/publish-log.jsonl . --s3-no-head 2>/dev/null || : > publish-log.jsonl
jq -c . publish-record.json >> publish-log.jsonl
./rclone copyto publish-log.jsonl R2:omarchy-pkgs/publish-log.jsonl --s3-no-head
echo "log now has $(wc -l < publish-log.jsonl) entries"
- name: Comment on the merged PR
# Only for a push: the merge commit names its PR. A dispatch runs
# from master's head, whose PR merged something else entirely, so
# commenting there would attach this run's report to the wrong PR.
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
pr=$(gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" --jq '.[0].number // empty')
if [[ -n "$pr" ]]; then
gh pr comment "$pr" -R "${{ github.repository }}" --body-file comment.md
echo "commented on #$pr"
else
echo "no PR for ${{ github.sha }} (manual dispatch?); skipping PR comment"
fi
result:
needs: [changes, publish]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "changes result: ${{ needs.changes.result }}, publish result: ${{ needs.publish.result }}"
[[ "${{ needs.changes.result }}" == "success" ]]
[[ "${{ needs.publish.result }}" == "success" || "${{ needs.publish.result }}" == "skipped" ]]