Every pull request now builds the package directories it touches on ephemeral DigitalOcean droplets, and every merge to master publishes the resulting artifacts into the channels each package belongs to. The repository host's timers become the fallback rather than the pipeline. Build (.github/workflows/build-pr.yml) One job per package per architecture, always against edge. The artifact is labelled with the package directory's git tree hash. Tooling (bin/, helpers/, build/) is checked out from the base branch; the PR supplies only pkgbuilds/, so a PR can change what is built, never how. Builds run only for trusted authors: collaborators, .github/VOUCHED.td, or a PR carrying the build-approved label. A single required check, result, aggregates the matrix. Publish (.github/workflows/publish.yml, bin/publish-artifact) One job per merge. It collects the PR artifacts for the merged tree, builds anything that has none, then walks each channel/architecture slot once: pull that database, repo-add every package that belongs in it, upload packages, signatures, then the database. A published filename is immutable; identical bytes under an existing name only gain a database entry, different bytes are refused. Fast-ring packages reach edge, rc and stable in the same run from the same file. Matrix (bin/build-matrix) Package x architecture, with the channels the artifact ships to, decided by package_builds_for_mirror so CI and the host agree. arch=any packages build once and land in every architecture database. Builder (build/build.sh, bin/build, build/Dockerfile) With no local published tree, plan against and resolve from the public channel over HTTPS. PACKAGER is set to Omarchy. rclone is in the image. Runners (ci/) A controller droplet polls GitHub with curl and creates one g5 droplet per queued job from cloud-init, deleting them when off or over-age. Builders carry QEMU with credential support for aarch64. Operator SSH keys come from GitHub. tests/controller.sh and tests/publish-artifact.sh cover the decisions against fixtures and real makepkg output. Tests run on pull requests only; branch protection requires result, self-tests and build-isolation with up-to-date branches.
180 lines
8.2 KiB
YAML
180 lines
8.2 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 }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- id: list
|
|
run: |
|
|
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"
|
|
|
|
# One job for the whole merge. It collects every PR artifact for the
|
|
# merged tree (building only what has none), 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.
|
|
publish:
|
|
needs: changes
|
|
if: 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.
|
|
- name: Collect artifacts
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
CONTAINER_ENGINE: docker
|
|
run: |
|
|
set -euo pipefail
|
|
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 | .archive_download_url // empty')
|
|
mkdir -p "build-output/edge/$arch"
|
|
if [[ -n "$found" ]]; then
|
|
echo "==> $label: PR artifact"
|
|
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -o /tmp/artifact.zip "$found"
|
|
unzip -oq /tmp/artifact.zip -d "build-output/edge/$arch"
|
|
else
|
|
echo "==> $label: no artifact for this tree, building"
|
|
OMARCHY_KEEP_BUILD_WORKSPACE=1 bin/build --mirror edge --arch "$arch" --package "$package"
|
|
fi
|
|
done < plan.txt
|
|
ls -1 build-output/edge/*/*.pkg.tar.zst
|
|
|
|
- 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
|
|
docker image inspect omarchy-pkg-builder:latest-x86_64-edge >/dev/null 2>&1 \
|
|
|| docker buildx build --load -t omarchy-pkg-builder:latest-x86_64-edge --build-arg MIRROR=edge build
|
|
|
|
# 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.
|
|
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"
|
|
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
|
|
done
|
|
done
|
|
|
|
result:
|
|
needs: [changes, publish]
|
|
if: always()
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: |
|
|
echo "publish result: ${{ needs.publish.result }}"
|
|
[[ "${{ needs.publish.result }}" == "success" || "${{ needs.publish.result }}" == "skipped" ]]
|