The workflow only ever uploaded to the GitHub release — the Gitea release for the same version (the plain, unsuffixed vX.Y.Z tag build-app.yml's Linux job creates, which already holds the .deb/.rpm/.AppImage) never got it, so it looked missing to anyone checking releases on Gitea instead of GitHub. New step mirrors build-app.yml's own Gitea upload step exactly: same get-or-create-by-tag, delete-existing-asset, upload-as-octet-stream shape, same REGISTRY_TOKEN secret. Verified the read side (release lookup, asset listing) against the real v0.4.16 release before writing this — resolves to the correct release id and correctly finds no existing asset yet.
369 lines
18 KiB
YAML
369 lines
18 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 attaches
|
|
# the resulting `.pkg.tar.zst` — installable by hand with `pacman -U` — to
|
|
# *both* the GitHub release it was built from and the corresponding Gitea
|
|
# release (the plain, unsuffixed `vX.Y.Z` tag build-app.yml's Linux job
|
|
# creates; the `-win`/`-mac` suffixed Gitea releases are a different tag and
|
|
# don't get this asset). 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
|
|
GITEA_URL: ${{ gitea.server_url }}
|
|
REPO: ${{ gitea.repository }}
|
|
|
|
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} on GitHub"
|
|
|
|
- name: Attach the package to the Gitea release
|
|
env:
|
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
TAG: ${{ steps.resolve.outputs.tag }}
|
|
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Same get-or-create-by-tag, delete-existing-asset,
|
|
# upload-as-octet-stream shape build-app.yml's own Gitea upload
|
|
# step already uses — this is expected to always hit the "reuse"
|
|
# branch, since build-app.yml's Linux job already created this
|
|
# exact release for this exact tag; the create fallback is here
|
|
# only so this doesn't hard-depend on that ordering.
|
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
|
case "${HTTP_CODE}" in
|
|
200)
|
|
echo "Release ${TAG} already exists on Gitea, reusing"
|
|
;;
|
|
404)
|
|
echo "Creating release ${TAG} on Gitea"
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\"}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
|
;;
|
|
*)
|
|
echo "Unexpected ${HTTP_CODE} looking up release ${TAG} on Gitea:" >&2
|
|
cat release.json >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json')).get('id',''))")
|
|
if [ -z "${RELEASE_ID}" ]; then
|
|
echo "No Gitea release id for ${TAG}; refusing to upload into nothing:" >&2
|
|
cat release.json >&2
|
|
exit 1
|
|
fi
|
|
|
|
EXISTING_ID=$(curl -sS \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${PKG_FILE}")
|
|
if [ -n "${EXISTING_ID}" ]; then
|
|
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
|
curl -fsS -X DELETE \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
|
fi
|
|
|
|
curl -fsS --http1.1 \
|
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
|
--max-time 600 \
|
|
-X POST \
|
|
-H "Authorization: token ${TOKEN}" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@rendered/${PKG_FILE}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${PKG_FILE}"
|
|
|
|
echo "Attached ${PKG_FILE} to ${TAG} on Gitea"
|