From 124b067694ae15edcaf4a03c1677630321facb01 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 20 Sep 2026 15:52:02 -0400 Subject: [PATCH] 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. --- .github/workflows/build-pr.yml | 14 +++++++++++-- .github/workflows/publish.yml | 11 +++++++++-- .github/workflows/test.yml | 1 + helpers/artifact-helpers.sh | 36 ++++++++++++++++++++++++++++++++++ tests/artifact-helpers.sh | 36 ++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 helpers/artifact-helpers.sh create mode 100755 tests/artifact-helpers.sh diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 7adbfcc3..b1f051ed 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 75f236fa..a61642db 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,13 +83,17 @@ jobs: cat plan.txt # Fetch each package's PR artifact into build-output/edge//, 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acf37047..b1bf54cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 ' diff --git a/helpers/artifact-helpers.sh b/helpers/artifact-helpers.sh new file mode 100644 index 00000000..1747f572 --- /dev/null +++ b/helpers/artifact-helpers.sh @@ -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 : every *.pkg.tar.zst directly in into . +# 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 : the packages an unzipped artifact +# carried, into . 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/" +} diff --git a/tests/artifact-helpers.sh b/tests/artifact-helpers.sh new file mode 100755 index 00000000..a0a2fee2 --- /dev/null +++ b/tests/artifact-helpers.sh @@ -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