Skip to content

Release artifacts

Release artifacts #59

Workflow file for this run

name: Release artifacts
# Triggered manually or by pushing a `vX.Y.Z` tag.
# Builds the backend & frontend Docker images for both linux/amd64 and
# linux/arm64 (RPi 4/5), exports them as `.tar.gz` files, and uploads
# them as artifacts. The maintainer then attaches the artifacts to a
# GitHub Release manually — we deliberately don't push to a registry
# from CI so a misclick on tag doesn't ship a broken build.
#
# Why tar.gz instead of `docker push`?
# - PiTun installs are typically air-gapped (RPi behind a captive portal,
# factory reset, etc.). Loadable tarballs match that workflow.
# - No registry credentials needed in CI; release stays under the
# maintainer's control end-to-end.
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
version:
description: 'Version tag to use in artifact filenames (e.g. v1.2.3)'
required: true
default: 'v0.0.0-dev'
permissions:
# `contents: write` is required for the auto-publish step at the end
# to create / update a GitHub Release and attach the built tarballs
# as Release assets. Without it the run can build everything but
# can't expose them on the Releases page.
contents: write
# `actions: read` is required by actions/download-artifact@v4 (the
# combine job below pulls every artifact uploaded by this run).
actions: read
jobs:
# Frontend dist is architecture-independent — single build, attached
# to the release once. RPi nginx will serve it just fine.
frontend:
name: Frontend dist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
# `npm ci` for reproducible release builds — see ci.yml for the
# `--legacy-peer-deps` rationale.
- name: Install dependencies (npm ci, locked)
working-directory: frontend
run: npm ci --legacy-peer-deps --no-audit --no-fund
- run: npm run build
working-directory: frontend
- name: Package dist
env:
VERSION: ${{ github.ref_name || inputs.version }}
run: |
tar -czf "pitun-frontend-${VERSION}.tar.gz" -C frontend/dist .
ls -lh pitun-frontend-*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pitun-frontend
path: pitun-frontend-*.tar.gz
retention-days: 14
# Backend image must be built per-architecture because the Python wheels
# (uvloop, httptools, watchfiles, etc.) are native binaries.
#
# Build on NATIVE runners per arch — `ubuntu-24.04-arm` for arm64,
# `ubuntu-24.04` for amd64 — instead of cross-building amd64 →
# arm64 via QEMU on a single amd64 runner. v1.3.0-beta.1 / .2 went
# out cross-built and the resulting arm64 wheel binaries SIGILL'd on
# an RPi 4 (Cortex-A72): same uvloop==0.22.1, identical pip freeze,
# identical Python 3.11.15, but different .so md5 vs the v1.2.4 build
# — `import uvicorn` exited 132 (illegal instruction). Native runners
# eliminate the cross-build path entirely.
#
# GitHub now provides free public ARM runners (announced 2025-01); the
# `ubuntu-24.04-arm` label maps to a 4-core Cobalt 100 (Neoverse N2)
# runner, which is binary-compatible with RPi 4 / 5.
backend:
name: Backend image (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-24.04
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# No QEMU needed any more — runner is native arch. Buildx still
# required for the load: true output, but since target == host
# platform, no emulation runs under the hood.
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Pull the version into an env var BEFORE shell sees it. Direct
# `${{ ... }}` interpolation into a `run:` block is a script-
# injection vector if the input is collaborator-controllable
# (workflow_dispatch). With `env:`, GitHub passes the value to
# the shell as a string, no template-time substitution.
- name: Resolve version
id: ver
env:
INPUT_VERSION: ${{ github.ref_name || inputs.version }}
run: |
echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
# Build for the target arch and load into the local docker daemon
# so we can `docker save` it into a tarball.
- name: Build & export image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./backend
file: ./backend/Dockerfile
target: production
platforms: ${{ matrix.platform }}
load: true
tags: pitun-backend:${{ steps.ver.outputs.version }}
cache-from: type=gha,scope=backend-${{ matrix.arch }}
cache-to: type=gha,scope=backend-${{ matrix.arch }},mode=max
- name: Save image to tarball
env:
VERSION: ${{ steps.ver.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
docker save "pitun-backend:${VERSION}" \
| gzip > "pitun-backend-${VERSION}-${ARCH}.tar.gz"
ls -lh pitun-backend-*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pitun-backend-${{ matrix.arch }}
path: pitun-backend-*.tar.gz
retention-days: 14
# Naive sidecar image — same native-runner story as backend.
# naive doesn't have Python wheels (it's a Caddy + naive_forwardproxy
# binary), so it's less likely to hit instruction-set issues, but
# cross-build via QEMU has been a reliable source of weird subtleties
# (file corruption, mtime drift) — keep it consistent with backend.
naive:
name: Naive sidecar image (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-24.04
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# See backend job above for why we use `env:` here.
- name: Resolve version
id: ver
env:
INPUT_VERSION: ${{ github.ref_name || inputs.version }}
run: |
echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
- name: Build & export image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./docker/naive
file: ./docker/naive/Dockerfile
platforms: ${{ matrix.platform }}
load: true
tags: pitun-naive:${{ steps.ver.outputs.version }}
cache-from: type=gha,scope=naive-${{ matrix.arch }}
cache-to: type=gha,scope=naive-${{ matrix.arch }},mode=max
- name: Save image to tarball
env:
VERSION: ${{ steps.ver.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
docker save "pitun-naive:${VERSION}" \
| gzip > "pitun-naive-${VERSION}-${ARCH}.tar.gz"
ls -lh pitun-naive-*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pitun-naive-${{ matrix.arch }}
path: pitun-naive-*.tar.gz
retention-days: 14
# Bundle everything into a single artifact for convenient download.
bundle:
name: Combine artifacts
needs: [frontend, backend, naive]
runs-on: ubuntu-latest
steps:
# Needed so the release-notes step can read CHANGELOG.md at this tag.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
# Restrict to our own artifacts. `build-push-action` also
# uploads opaque internal artifacts named `<owner>~<repo>~XXXX.dockerbuild`
# that we don't want to pull. `pattern: 'pitun-*'` matches the
# five we actually publish: pitun-frontend,
# pitun-{backend,naive}-{amd64,arm64}.
pattern: 'pitun-*'
path: artifacts/
- name: List downloaded artifacts
run: ls -la artifacts/ && find artifacts/ -type f -exec ls -lh {} \;
- name: Flatten into single dir
run: |
mkdir -p release
find artifacts/ -name '*.tar.gz' -exec cp {} release/ \;
ls -lh release/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pitun-release-bundle
path: release/*
retention-days: 14
# Auto-publish to the GitHub Releases page so install.sh can
# discover assets via the public `/releases/latest` API. Only
# runs on a real tag push (`v*.*.*`); a manual `workflow_dispatch`
# against an arbitrary version string would skip this step,
# since the inputs.version may not correspond to a real tag.
#
# Pre-release detection: any tag containing a hyphen-suffix
# (`-beta.1`, `-rc.2`, `-alpha.0`, etc., per semver 2.0) is
# marked `prerelease: true` and `make_latest: false`. This
# keeps the GitHub `/releases/latest` API pointing at the most
# recent STABLE release, so users running `curl … | sudo bash`
# without an explicit `--version` flag never auto-upgrade onto
# an in-flight beta. Beta testers opt in by running the
# installer with `--version vX.Y.Z-beta.N`.
- name: Detect pre-release tag
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: prerelease
run: |
if [[ "${{ github.ref_name }}" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ github.ref_name }} contains a hyphen suffix → publishing as pre-release."
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "Tag ${{ github.ref_name }} → publishing as stable release (Latest)."
fi
# Pull the human-written section for this exact tag out of
# CHANGELOG.md (from `## v<tag>` to the next `## v…` header) so the
# Releases page shows real notes, not just the auto compare-link.
# Empty file if the tag isn't in CHANGELOG — `generate_release_notes`
# still supplies the compare link, so nothing breaks.
- name: Extract changelog section for this tag
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
run: |
# Literal-prefix match via index() (no dynamic regex) so this
# works under mawk on the runner as well as gawk. Header format
# is `## <tag> — <date>`, so the tag is always followed by a space.
awk -v hdr="## ${{ github.ref_name }} " '
index($0, hdr) == 1 { f = 1; print; next }
f && /^## v/ { exit }
f { print }
' CHANGELOG.md > RELEASE_NOTES.md
echo "---- release notes for ${{ github.ref_name }} ----"
cat RELEASE_NOTES.md
- name: Create / update GitHub Release with assets
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
# CHANGELOG section first; generate_release_notes appends the
# auto compare-link / PR list below it.
body_path: RELEASE_NOTES.md
generate_release_notes: true
fail_on_unmatched_files: true
files: release/*
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
# `make_latest: legacy` (default) gives Latest only to
# non-prerelease tags. Be explicit so this stays correct
# if a future action version changes the default.
make_latest: ${{ steps.prerelease.outputs.is_prerelease == 'false' && 'true' || 'false' }}