Publishing to the AUR needs a maintainer AUR account and its SSH key registered as a secret here, neither of which exists yet. Rather than leave the workflow permanently failing at that last step, it now stops short of AUR and instead uploads the built .pkg.tar.zst to the same GitHub release it built from, as a plain downloadable asset (`pacman -U` to install). The AUR-push step is still in this file's git history if that setup happens later. Renamed publish-aur-package.yml -> publish-arch-package.yml to match. The render/validate steps are unchanged; new here is capturing the exact built package filename from inside the build container (makepkg is the only thing that actually knows it) and an upload step that follows the same create-or-reuse-release, strip-upload_url, POST-octet-stream pattern build-app.yml and backfill-releases.yml already use for GitHub assets, plus a delete-existing-asset-first step so a re-dispatch for an already-packaged version replaces rather than 422s. Verified with a real Docker run end to end: rendered a real PKGBUILD, built a real (synthetic) .deb through makepkg + namcap in an archlinux container, confirmed the container exits 0, and confirmed the exact package filename it captures (triple-c-bin-<version>-1-x86_64.pkg.tar.zst) round-trips out via docker cp intact.
299 lines
15 KiB
YAML
299 lines
15 KiB
YAML
name: Publish Arch Package
|
|
|
|
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
|
# given release, or the latest one if none is given, and attaches the built
|
|
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
|
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
|
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
|
# as an automatic trigger: this repo's releases are assembled by
|
|
# build-app.yml across three separate platform jobs, and there is no single
|
|
# automatic event that fires only once everything (including the Linux .deb
|
|
# this workflow needs) is actually uploaded. A human deciding "this release
|
|
# is ready, go package it" is the correct trigger, the same reasoning
|
|
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
|
#
|
|
# ## What this does and does not do
|
|
#
|
|
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
|
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
|
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
|
|
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
|
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
|
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
|
# a placeholder version, and the workflow never starts from or writes to it.
|
|
#
|
|
# ## Not published to the AUR (yet)
|
|
#
|
|
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
|
# needs a maintainer AUR account and its SSH key registered as a secret here
|
|
# — both manual, one-time steps neither this workflow nor anyone but a
|
|
# maintainer can do. Until that setup happens, a downloadable release asset
|
|
# gets the same package to users without it. The AUR push step is still in
|
|
# this file's git history (see the commit that added this comment) if that
|
|
# setup is ever done and it's worth reinstating.
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: >-
|
|
Release version to package, without a leading "v" (e.g. "0.4.14").
|
|
Leave empty to use the latest published GitHub release.
|
|
required: false
|
|
|
|
env:
|
|
GITHUB_REPO: shadowdao/triple-c
|
|
|
|
jobs:
|
|
publish:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Resolve version and find the Linux asset
|
|
id: resolve
|
|
env:
|
|
VERSION_INPUT: ${{ inputs.version }}
|
|
GH_PAT: ${{ secrets.GH_PAT }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Authenticated when the secret is available (it is, everywhere
|
|
# else in this repo's workflows) to avoid the unauthenticated
|
|
# 60-requests/hour-per-IP cap; still works without it, just at that
|
|
# lower limit, since this hits nothing but a public repo's public
|
|
# releases.
|
|
AUTH=()
|
|
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
|
|
|
if [ -z "${VERSION_INPUT}" ]; then
|
|
echo "No version given — resolving the latest GitHub release"
|
|
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
|
else
|
|
echo "Using requested version ${VERSION_INPUT}"
|
|
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
|
fi
|
|
|
|
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
|
VERSION="${TAG#v}"
|
|
echo "Resolved to ${TAG}"
|
|
|
|
# Discovered from the real release, not assumed: Tauri names the
|
|
# asset after `productName` verbatim ("Triple-C"), not the
|
|
# lowercase Cargo binary name, and asset naming is exactly the kind
|
|
# of thing that silently drifts if a future Tauri upgrade changes
|
|
# bundler defaults — a hardcoded pattern here would then 404
|
|
# forever until someone noticed. `head -1` guards against a release
|
|
# somehow carrying more than one matching asset, which would
|
|
# otherwise pass the emptiness check below and then break the
|
|
# download step with two URLs on one line.
|
|
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
|
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
|
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
|
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
|
exit 1
|
|
fi
|
|
echo "Found asset: ${DEB_NAME}"
|
|
|
|
# For attaching the built package to this same release later —
|
|
# every release object carries its own `upload_url` regardless of
|
|
# whether it was just created or (as here) already existed, and
|
|
# the `{?name,label}` URI-template suffix has to come off before
|
|
# this is usable as a plain URL to POST to.
|
|
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
|
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
|
|
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
|
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
|
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
|
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Download the release asset and compute real checksums
|
|
id: checksums
|
|
env:
|
|
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
|
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
TAG: ${{ steps.resolve.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
|
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
|
|
|
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Render PKGBUILD
|
|
id: render
|
|
env:
|
|
VERSION: ${{ steps.resolve.outputs.version }}
|
|
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
|
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p rendered
|
|
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
|
cd rendered
|
|
|
|
# Plain string replacement throughout, not sed — the source URL
|
|
# contains slashes and the repo name does too, and getting a sed
|
|
# delimiter choice AND its escaping right for that is exactly the
|
|
# kind of thing that looks correct, passes review, and breaks the
|
|
# next time someone touches it. `re.sub` with `count=1` and an
|
|
# exact `.format`-free literal match is boring and that's the
|
|
# point: every substitution below fails loudly (an assertion /
|
|
# the checks after) rather than silently no-op'ing if the
|
|
# template's shape ever drifts from what this expects.
|
|
#
|
|
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
|
# same upstream version (a dependency bump, say) is what pkgrel is
|
|
# for, and this workflow always republishes the current PKGBUILD
|
|
# verbatim rather than incrementing anything, so 1 is always
|
|
# correct for what this workflow does. It is NOT correct for a
|
|
# dependency-only fix republished at the *same* pkgver: pkgrel
|
|
# would be forced back to 1, and no existing installation sees an
|
|
# upgrade. That case needs a manual pkgrel bump in the template
|
|
# before dispatching, which this workflow has no input for.
|
|
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
|
import re, sys
|
|
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
|
|
|
with open("PKGBUILD") as f:
|
|
text = f.read()
|
|
|
|
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
|
assert n == 1, "pkgver=... line not found"
|
|
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
|
assert n == 1, "pkgrel=... line not found"
|
|
|
|
# Built with a "$" variable and plain "+" concatenation rather than
|
|
# an f-string's double-brace escape for a literal brace: writing
|
|
# this as an f-string put a dollar sign directly against two open
|
|
# braces, right here in this workflow's own YAML text — and this
|
|
# runner's own expression templating scans a run: block for that
|
|
# exact two-character opening sequence and tries to evaluate
|
|
# whatever sits inside as one of ITS OWN expressions (a step
|
|
# output, a secret, ...) before the shell ever sees this script.
|
|
# "pkgver" isn't one of those, so that lookup failed and silently
|
|
# emptied this whole step rather than raising anything here.
|
|
# Spelling the dollar sign out of a variable instead means this
|
|
# file's own text never contains that trigger sequence.
|
|
DOLLAR = "$"
|
|
old_source = (
|
|
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
|
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
|
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
|
)
|
|
new_source = (
|
|
f'source=("{deb_name}::'
|
|
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
|
)
|
|
assert old_source in text, "source=() line does not match the expected template shape"
|
|
text = text.replace(old_source, new_source, 1)
|
|
|
|
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
|
assert old_sums in text, "sha256sums=() placeholders not found"
|
|
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
|
|
|
with open("PKGBUILD", "w") as f:
|
|
f.write(text)
|
|
PY
|
|
|
|
grep -q "pkgver=${VERSION}$" PKGBUILD
|
|
! grep -q "SKIP" PKGBUILD
|
|
|
|
- name: Validate with makepkg and namcap
|
|
id: build
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
|
# obvious way to write this, and was the first draft — but on a
|
|
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
|
# job's own container, which the daemon's host cannot resolve; the
|
|
# mount would silently attach an empty directory instead of failing
|
|
# loudly. `docker cp` moves real bytes across that boundary
|
|
# regardless of where the daemon actually lives, which is what
|
|
# makes this work under both a bind-mount-capable runner and a
|
|
# containerized one.
|
|
docker pull archlinux:latest
|
|
CID=$(docker create -w /work archlinux:latest bash -c '
|
|
set -euo pipefail
|
|
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
|
useradd -m builder
|
|
chown -R builder:builder /work
|
|
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
|
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
|
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
|
# Named once here, inside the container, rather than guessed
|
|
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
|
# host after the fact — makepkg is the one place that actually
|
|
# knows its own output name, and `!debug` already guarantees
|
|
# this glob can only ever match the one real package (no
|
|
# -debug split package gets produced).
|
|
basename /work/*.pkg.tar.* > /work/.pkgfile
|
|
echo "--- namcap ---"
|
|
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
|
echo "$NAMCAP_OUT"
|
|
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
|
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
|
# uses more than one line shape for its two rule families, and
|
|
# namcap itself exits 0 regardless of what it reports, so this
|
|
# grep is the only thing standing between an E: and a green job.
|
|
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
|
echo "namcap reported an error — see above" >&2
|
|
exit 1
|
|
fi
|
|
')
|
|
mkdir -p rendered
|
|
docker cp rendered/. "${CID}:/work"
|
|
# `docker start -a` streams output and its exit code is the
|
|
# container's own — the same failure this would have hit with a
|
|
# bind mount still fails the job the same way.
|
|
docker start -a "${CID}"
|
|
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
|
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
|
PKG_FILE=$(cat rendered/.pkgfile)
|
|
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
|
docker rm -f "${CID}" >/dev/null
|
|
|
|
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Attach the package to the GitHub release
|
|
env:
|
|
GH_PAT: ${{ secrets.GH_PAT }}
|
|
TAG: ${{ steps.resolve.outputs.tag }}
|
|
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
|
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
|
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${GH_PAT}" ]; then
|
|
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# A manual re-dispatch for a version that's already been packaged
|
|
# would otherwise hit GitHub's 422 "already_exists" here instead
|
|
# of just replacing the stale build with this one.
|
|
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
|
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
|
if [ -n "$EXISTING_ID" ]; then
|
|
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
|
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
|
fi
|
|
|
|
curl -fsS -X POST \
|
|
-H "Authorization: Bearer ${GH_PAT}" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@rendered/${PKG_FILE}" \
|
|
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
|
> /dev/null
|
|
|
|
echo "Attached ${PKG_FILE} to ${TAG}"
|