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"