Carry PR build artifacts inside a tar so epoch package names survive

actions/upload-artifact rejects any path containing ':', and makepkg names
a package with an epoch `name-1:ver-rel-arch.pkg.tar.zst`. Every PR that
built such a package (cursor-cli in the sync PRs, omasnap once it gained an
epoch) failed at "Upload artifact" after a successful build, and publish
then rebuilt from scratch on merge.

The files now ride inside packages.tar for the artifact hop and come back
out with makepkg's names untouched: pacman clients and bin/publish-artifact
both require the filename to match PKGINFO, and the channels already carry
these names. publish.yml still accepts bare pre-packing artifacts until the
7-day retention drains them.

helpers/artifact-helpers.sh holds both halves; tests/artifact-helpers.sh
covers the round trip and runs with the other self-tests.
This commit is contained in:
Ryan Hughes committed 2026-09-20 15:52:02 -04:00
1 parent dc82479210
commit 124b067694
5 files changed
+94 -4

No files matched your search

+12 -2
View File
@@ -170,12 +170,22 @@ jobs:
- name: Tree hash
id: tree
run: echo "hash=$(git rev-parse "${{ github.event.pull_request.head.sha || github.sha }}:pkgbuilds/${{ matrix.package }}")" >> "$GITHUB_OUTPUT"
- name: Upload artifact
# The upload action rejects a path containing ':', which is how makepkg
# names a package with an epoch. The files ride inside packages.tar
# (helpers/artifact-helpers.sh); publish.yml unpacks it.
- name: Pack artifact
id: pack
if: always()
run: |
source helpers/artifact-helpers.sh
pack_packages build-output/edge/${{ matrix.arch }} packages.tar
tar -tvf packages.tar
- name: Upload artifact
if: always() && steps.pack.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.package }}-${{ matrix.arch }}-${{ steps.tree.outputs.hash }}
path: build-output/edge/${{ matrix.arch }}/*.pkg.tar.zst
path: packages.tar
if-no-files-found: error
retention-days: 7
+9 -2
View File
@@ -83,13 +83,17 @@ jobs:
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.
# 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.
@@ -104,7 +108,10 @@ jobs:
mkdir -p "build-output/edge/$arch"
if [[ -n "$found" ]]; then
echo "==> $label: PR artifact"
if curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -o /tmp/artifact.zip "$found" && unzip -oq /tmp/artifact.zip -d "build-output/edge/$arch"; then
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" '{package:$p, arch:$a, source:"pr-artifact"}' >> sources.jsonl
else
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"artifact-download-failed"}' >> sources.jsonl; failed=1; break
+1
View File
@@ -54,6 +54,7 @@ jobs:
./tests/partial-release.sh
./tests/published-build-plan.sh
./tests/controller.sh
./tests/artifact-helpers.sh
pacman -S --noconfirm --quiet rclone >/dev/null
./tests/publish-artifact.sh
'
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Package files cross from a PR build to publish.yml as one GitHub Actions
# artifact. actions/upload-artifact rejects any path containing ':', and a
# package with an epoch is named `name-1:ver-rel-arch.pkg.tar.zst` by
# makepkg. So the files ride inside a tar with a plain name and keep their
# own names untouched: pacman clients and bin/publish-artifact both rely on
# the filename matching PKGINFO.
# pack_packages <dir> <tar>: every *.pkg.tar.zst directly in <dir> into <tar>.
# Signatures and the scratch database next to them stay behind.
pack_packages() {
local dir=$1 out=$2 restore files=()
restore=$(shopt -p nullglob); shopt -s nullglob
files=("$dir"/*.pkg.tar.zst)
$restore
(( ${#files[@]} )) || { echo "pack_packages: no *.pkg.tar.zst in $dir" >&2; return 1; }
tar -cf "$out" -C "$dir" -- "${files[@]##*/}"
}
# unpack_packages <artifact dir> <dest>: the packages an unzipped artifact
# carried, into <dest>. Packed artifacts hold packages.tar; artifacts from
# builds before packing hold the bare files. The bare form can go once
# those artifacts have expired (7-day retention).
unpack_packages() {
local src=$1 dest=$2 restore files=()
mkdir -p "$dest"
if [[ -f "$src/packages.tar" ]]; then
tar -xf "$src/packages.tar" -C "$dest"
return
fi
restore=$(shopt -p nullglob); shopt -s nullglob
files=("$src"/*.pkg.tar.zst)
$restore
(( ${#files[@]} )) || { echo "unpack_packages: nothing to unpack in $src" >&2; return 1; }
cp -- "${files[@]}" "$dest/"
}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Self-test for helpers/artifact-helpers.sh: package files survive the
# artifact hop between build-pr.yml and publish.yml with makepkg's names
# intact, including the colon an epoch puts in them.
set -euo pipefail
ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
source "$ROOT/helpers/artifact-helpers.sh"
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
pass() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; exit 1; }
EPOCH='cursor-cli-1:2026.09.18.1.9a7762b-1-x86_64.pkg.tar.zst'
PLAIN='beta-1.0-1-x86_64.pkg.tar.zst'
mkdir -p "$T/built" "$T/artifact" "$T/out"
echo epoch > "$T/built/$EPOCH"
echo plain > "$T/built/$PLAIN"
echo sig > "$T/built/$PLAIN.sig"
echo db > "$T/built/omarchy.db.tar.zst"
pack_packages "$T/built" "$T/artifact/packages.tar" || fail "pack"
[[ "$(tar -tf "$T/artifact/packages.tar" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " ]] \
&& pass "packages.tar holds the packages only, no signature or database" || fail "tar contents: $(tar -tf "$T/artifact/packages.tar" | tr '\n' ' ')"
[[ "$(ls "$T/artifact")" == "packages.tar" ]] && pass "the artifact path carries no colon" || fail "artifact listing"
unpack_packages "$T/artifact" "$T/out" || fail "unpack"
[[ "$(ls "$T/out" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " && "$(cat "$T/out/$EPOCH")" == epoch ]] \
&& pass "epoch filename and bytes survive the round trip" || fail "round trip: $(ls "$T/out" | tr '\n' ' ')"
# An artifact uploaded before packing existed: bare package files.
mkdir -p "$T/old" "$T/out2"; cp "$T/built"/*.pkg.tar.zst "$T/old/"
unpack_packages "$T/old" "$T/out2" && [[ "$(ls "$T/out2" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " ]] \
&& pass "a bare pre-packing artifact still unpacks" || fail "bare artifact"
mkdir -p "$T/empty"
if pack_packages "$T/empty" "$T/x.tar" 2>/dev/null; then fail "packing an empty build dir should fail"; else pass "empty build dir refused"; fi
if unpack_packages "$T/empty" "$T/out3" 2>/dev/null; then fail "an empty artifact should fail"; else pass "empty artifact refused"; fi