Files
Spoolman/.github/workflows/ci.yml
T
Donkie ff7170f0f7 Build on Node 24, which is not end-of-life
Every workflow pinned node-version '20'. Node 20 reached end-of-life on
2026-04-30, so for three months CI has been building the artifacts that
go into released images on a runtime that no longer gets security fixes.
Vite 8 also floors at ^20.19, which setup-node's '20' happens to satisfy
today only because it resolves to the latest 20.x -- a floor we were
clearing by accident is not one worth keeping.

24 rather than 22: it is Active LTS until 2026-10-20 and supported
through 2028-04-30, so this pin outlives the 22 line by a year. Node 26
does not ship until October.

The legacy client declared engines "20.x", which would have made every
npm ci in that package print EBADENGINE against the new runner. It is
widened to the floor of the Vite 7 it already runs on, so it now
describes what the package actually supports instead of naming a single
line.

Verified locally on Node 24.13.0: lefthook ci green, both clients
install clean and build, client_v2's unit tests and the
tests_frontend_v2 Playwright suite pass. tests_frontend needs the built
image so it is left to CI; its only dependency declares node >=18.

The setup-node pin itself is untouched and still on v4.0.3, whose action
runtime is the other deprecated Node 20 in the logs -- GitHub already
force-runs it on 24. That is an action upgrade, and a separate change.
2026-07-31 09:00:16 +02:00

766 lines
23 KiB
YAML

name: CI
on:
push:
branches:
- master
tags:
- "v*"
pull_request:
branches:
- master
permissions:
contents: read
packages: read
# A new push to a PR makes the previous run's result irrelevant, so cancel it and
# let the new one have the runners. Gated on the event: the push triggers are
# master and v* tags, and a cancelled tag run is a half-published release.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
GHCR_IMAGE: donkie/spoolman
jobs:
#
# Linting
#
style:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node
uses: actions/setup-node@v4.0.3
with:
node-version: '24'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.26"
- name: "Set up Python"
uses: actions/setup-python@v6
with:
python-version-file: "pyproject.toml"
- name: Install lefthook
run: uv add --dev lefthook
- name: Install client dependencies
run: |
cd client
npm ci
# lefthook's `ci` hook lints/formats/type-checks client_v2 too, via
# hardcoded ./node_modules/.bin/* paths, so its deps must be present.
- name: Install client_v2 dependencies
run: |
cd client_v2
npm ci
- name: Run pre-commit
run: uv run lefthook run ci
#
# Build the Spoolman client for baking into the docker images
# This is done on native hardware to speed things up, vite is very slow on emulated hardware
#
build-client:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node
uses: actions/setup-node@v4.0.3
with:
node-version: '24'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.26"
- name: "Set up Python"
uses: actions/setup-python@v6
with:
python-version-file: "pyproject.toml"
- name: Install client dependencies
run: |
cd client
npm ci
- name: Build
run: |
cd client
rm -f .env && echo "VITE_APIURL=/api/v1" > .env.production
npm run build
# Build the new Svelte client (client_v2), served by default at runtime.
# No VITE_APIURL is set: the client resolves the API relative to its base path.
- name: Install client_v2 dependencies
run: |
cd client_v2
npm ci
- name: Build client_v2
run: |
cd client_v2
npm run build
- name: Write build info
run: |
echo "GIT_COMMIT=$(git rev-parse --short HEAD)" > build.txt
echo "BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> build.txt
# Remove client sources and keep only the built output for each client
- name: Clean
run: |
mv client/dist dist
rm -rf client
mkdir client
mv dist client/dist
mv client_v2/build build_v2
rm -rf client_v2
mkdir client_v2
mv build_v2 client_v2/build
rm -rf .git
rm -rf .venv
- name: Fix permissions
run: |
chmod +x scripts/*.sh
- name: Upload client Spoolman artifact
uses: actions/upload-artifact@v4.4.0
with:
name: client
path: client/dist
- name: Upload client_v2 Spoolman artifact
uses: actions/upload-artifact@v4.4.0
with:
name: client_v2
path: client_v2/build
- name: Upload full Spoolman artifact
uses: actions/upload-artifact@v4.4.0
with:
include-hidden-files: true
name: spoolman
path: .
#
# Build native image for integration tests
#
build-amd64:
needs: [build-client]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download client
uses: actions/download-artifact@v4.1.7
with:
name: client
path: client/dist
- name: Download client_v2
uses: actions/download-artifact@v4.1.7
with:
name: client_v2
path: client_v2/build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Spoolman
if: ${{ github.event_name != 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
tags: ${{ env.GHCR_IMAGE }}:test
outputs: type=docker,dest=/tmp/spoolman.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-amd64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-amd64,mode=max
# Separate build for pull requests since PRs dont have access to store cache
- name: Build Spoolman (PR)
if: ${{ github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
tags: ${{ env.GHCR_IMAGE }}:test
outputs: type=docker,dest=/tmp/spoolman.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-amd64
- name: Upload artifact
uses: actions/upload-artifact@v4.4.0
with:
name: spoolman-image
path: /tmp/spoolman.tar
#
# Build tester image for integration tests
#
build-tester:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Spoolman Tester
if: ${{ github.event_name != 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: ./tests_integration
tags: ${{ env.GHCR_IMAGE }}-tester:latest
outputs: type=docker,dest=/tmp/spoolman-tester.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-tester
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-tester,mode=max
# Separate build for pull requests since PRs dont have access to store cache
- name: Build Spoolman Tester (PR)
if: ${{ github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: ./tests_integration
tags: ${{ env.GHCR_IMAGE }}-tester:latest
outputs: type=docker,dest=/tmp/spoolman-tester.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-tester
- name: Upload artifact
uses: actions/upload-artifact@v4.4.0
with:
name: spoolman-tester-image
path: /tmp/spoolman-tester.tar
#
# Perform integration tests
#
test:
needs: [build-amd64, build-tester]
strategy:
matrix:
dbtype: ["postgres", "sqlite", "mariadb", "cockroachdb"]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download built images
uses: actions/download-artifact@v4.1.7
with:
pattern: spoolman*-image
path: /tmp
- name: Load built images
run: |
docker load --input /tmp/spoolman-image/spoolman.tar
docker load --input /tmp/spoolman-tester-image/spoolman-tester.tar
- name: Perform integration tests
uses: hoverkraft-tech/compose-action@v2.0.1
with:
compose-file: "./tests_integration/docker-compose-${{ matrix.dbtype }}.yml"
up-flags: "--abort-on-container-exit"
down-flags: "--volumes"
#
# Perform frontend (browser) integration tests against the real image
#
test-frontend:
needs: [build-amd64]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download built image
uses: actions/download-artifact@v4.1.7
with:
name: spoolman-image
path: /tmp/spoolman-image
- name: Load built image
run: docker load --input /tmp/spoolman-image/spoolman.tar
- name: Install node
uses: actions/setup-node@v4.0.3
with:
node-version: '24'
- name: Install Playwright
run: |
cd tests_frontend
npm ci
npx playwright install --with-deps chromium
- name: Start Spoolman
run: docker compose -f tests_frontend/docker-compose.yml up -d --wait
- name: Run frontend integration tests
run: |
cd tests_frontend
npx playwright test
- name: Dump Spoolman logs on failure
if: failure()
run: docker compose -f tests_frontend/docker-compose.yml logs spoolman
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4.4.0
with:
name: playwright-report
path: tests_frontend/playwright-report
retention-days: 7
- name: Stop Spoolman
if: always()
run: docker compose -f tests_frontend/docker-compose.yml down -v
#
# Perform frontend (browser) integration tests for client_v2 against the real image
#
test-frontend-v2:
needs: [build-amd64]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download built image
uses: actions/download-artifact@v4.1.7
with:
name: spoolman-image
path: /tmp/spoolman-image
- name: Load built image
run: docker load --input /tmp/spoolman-image/spoolman.tar
- name: Install node
uses: actions/setup-node@v4.0.3
with:
node-version: '24'
- name: Install Playwright
run: |
cd tests_frontend_v2
npm ci
npx playwright install --with-deps chromium
- name: Start Spoolman
run: docker compose -f tests_frontend_v2/docker-compose.yml up -d --wait
- name: Run client_v2 frontend integration tests
run: |
cd tests_frontend_v2
npx playwright test
- name: Dump Spoolman logs on failure
if: failure()
run: docker compose -f tests_frontend_v2/docker-compose.yml logs spoolman
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4.4.0
with:
name: playwright-report-v2
path: tests_frontend_v2/playwright-report
retention-days: 7
- name: Stop Spoolman
if: always()
run: docker compose -f tests_frontend_v2/docker-compose.yml down -v
#
# Build arm64 image
# Also exported to a tar so test-multiarch-smoke can boot it under QEMU.
# On pull requests we can't push to buildcache (forks don't have registry
# write access), so cache-to is skipped and we only read from it.
#
build-arm64:
needs: [build-client]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download client
uses: actions/download-artifact@v4.1.7
with:
name: client
path: client/dist
- name: Download client_v2
uses: actions/download-artifact@v4.1.7
with:
name: client_v2
path: client_v2/build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and cache Docker image
if: ${{ github.event_name != 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
platforms: linux/arm64
tags: ${{ env.GHCR_IMAGE }}:test-arm64
outputs: type=docker,dest=/tmp/spoolman-arm64.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64,mode=max
# Separate build for pull requests since PRs dont have access to store cache
- name: Build and cache Docker image (PR)
if: ${{ github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
platforms: linux/arm64
tags: ${{ env.GHCR_IMAGE }}:test-arm64
outputs: type=docker,dest=/tmp/spoolman-arm64.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
- name: Upload arm64 image artifact
uses: actions/upload-artifact@v4.4.0
with:
name: spoolman-image-arm64
path: /tmp/spoolman-arm64.tar
#
# Build arm/v7 image
# Also exported to a tar so test-multiarch-smoke can boot it under QEMU.
# On pull requests we can't push to buildcache (forks don't have registry
# write access), so cache-to is skipped and we only read from it.
#
build-armv7:
needs: [build-client]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download client
uses: actions/download-artifact@v4.1.7
with:
name: client
path: client/dist
- name: Download client_v2
uses: actions/download-artifact@v4.1.7
with:
name: client_v2
path: client_v2/build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and cache Docker image
if: ${{ github.event_name != 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
platforms: linux/arm/v7
tags: ${{ env.GHCR_IMAGE }}:test-armv7
outputs: type=docker,dest=/tmp/spoolman-armv7.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7,mode=max
# Separate build for pull requests since PRs dont have access to store cache
- name: Build and cache Docker image (PR)
if: ${{ github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6.7.0
with:
context: .
platforms: linux/arm/v7
tags: ${{ env.GHCR_IMAGE }}:test-armv7
outputs: type=docker,dest=/tmp/spoolman-armv7.tar
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
- name: Upload armv7 image artifact
uses: actions/upload-artifact@v4.4.0
with:
name: spoolman-image-armv7
path: /tmp/spoolman-armv7.tar
#
# Smoke test the arm64 and armv7 images under QEMU emulation: boot the
# container with no config (defaults to SQLite, no external DB needed) and
# confirm /api/v1/health comes up. Emulated boot (interpreter start +
# alembic migrations) is slow, hence the generous retry budget.
#
test-multiarch-smoke:
needs: [build-arm64, build-armv7]
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
platform: linux/arm64
artifact: spoolman-image-arm64
tarball: spoolman-arm64.tar
- arch: armv7
platform: linux/arm/v7
artifact: spoolman-image-armv7
tarball: spoolman-armv7.tar
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Download built image
uses: actions/download-artifact@v4.1.7
with:
name: ${{ matrix.artifact }}
path: /tmp/spoolman-image
- name: Load built image
run: docker load --input /tmp/spoolman-image/${{ matrix.tarball }}
- name: Start Spoolman (${{ matrix.arch }}, emulated via QEMU)
run: |
docker run -d --name spoolman-smoke \
--platform ${{ matrix.platform }} \
-p 8000:8000 \
${{ env.GHCR_IMAGE }}:test-${{ matrix.arch }}
- name: Wait for /health endpoint
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost:8000/api/v1/health; then
echo
echo "Spoolman (${{ matrix.arch }}) is healthy"
exit 0
fi
sleep 5
done
echo "Spoolman (${{ matrix.arch }}) did not become healthy in time" >&2
exit 1
- name: Dump Spoolman logs on failure
if: failure()
run: docker logs spoolman-smoke
- name: Stop Spoolman
if: always()
run: docker rm -f spoolman-smoke
#
# Release images GHCR and Docker Hub if tests pass
# Don't run this for pull requests
#
publish-images:
if: ${{ github.event_name != 'pull_request' }}
needs:
[
test,
test-frontend,
test-frontend-v2,
test-multiarch-smoke,
build-amd64,
build-arm64,
build-armv7,
style,
build-client,
]
runs-on: ubuntu-latest
permissions:
packages: write
# Lets cosign fetch an OIDC token from Github and sign without a private key.
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download client
uses: actions/download-artifact@v4.1.7
with:
name: client
path: client/dist
- name: Download client_v2
uses: actions/download-artifact@v4.1.7
with:
name: client_v2
path: client_v2/build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for GHCR
id: meta-ghcr
uses: docker/metadata-action@v5.5.1
with:
images: |
ghcr.io/${{ env.GHCR_IMAGE }}
tags: |
type=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Extract metadata (tags, labels) for Docker Hub
id: meta-dh
uses: docker/metadata-action@v5.5.1
with:
images: |
${{ vars.REMOTE_REGISTRY_URL }}/${{ vars.REMOTE_REGISTRY_NAMESPACE }}/${{ vars.REMOTE_REGISTRY_REPOSITORY }}
tags: |
type=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Log in to DockerHub
uses: docker/login-action@v3.3.0
with:
registry: ${{ vars.REMOTE_REGISTRY_URL }}
username: ${{ secrets.REMOTE_REGISTRY_USERNAME }}
password: ${{ secrets.REMOTE_REGISTRY_PASSWORD }}
- name: Store git commit and build date
run: |
echo "GIT_COMMIT=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
echo "BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_ENV"
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
with:
# Installer v4+ verifies cosign v3 downloads via the Sigstore bundle
# format; pin an explicit, current cosign release for reproducibility.
cosign-release: v3.1.1
- name: Build and push Docker images
id: build
uses: docker/build-push-action@v6.7.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
${{ steps.meta-ghcr.outputs.tags }}
${{ steps.meta-dh.outputs.tags }}
labels: ${{ steps.meta-ghcr.outputs.labels }}
build-args: |
GIT_COMMIT
BUILD_DATE
cache-from: |
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-amd64
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-arm64
type=registry,ref=ghcr.io/${{ env.GHCR_IMAGE }}:buildcache-armv7
# Sign the manifest by digest rather than by tag: all the tags above point
# at the same digest, and the same digest is valid in both registries, so
# signing every tag would just repeat the same signature.
- name: Sign the published images
env:
DIGEST: ${{ steps.build.outputs.digest }}
TAGS: |
${{ steps.meta-ghcr.outputs.tags }}
${{ steps.meta-dh.outputs.tags }}
run: |
echo "${TAGS}" | while read -r tag; do
[ -n "${tag}" ] || continue
echo "${tag%:*}"
done | sort --unique | while read -r repository; do
echo "Signing ${repository}@${DIGEST}"
cosign sign --yes "${repository}@${DIGEST}"
done
#
# Create Github Release if CI was triggered by a tag
#
publish-release:
if: ${{ (github.event_name != 'pull_request') && startsWith(github.event.ref, 'refs/tags/v') }}
needs: [publish-images]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download built spoolman
uses: actions/download-artifact@v4.1.7
with:
name: spoolman
path: /tmp/spoolman
- name: Generate release info
run: |
cd /tmp/spoolman
echo '{
"project_name": "Spoolman",
"project_owner": "Donkie",
"version": "${{ github.ref_name }}"
}' > release_info.json
- name: Zip
run: |
cd /tmp/spoolman
zip -r spoolman.zip .
- name: Create release and upload build
uses: softprops/action-gh-release@v1
id: create-release
with:
draft: true
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
files: /tmp/spoolman/spoolman.zip
token: ${{ secrets.GITHUB_TOKEN }}
body: "⚠️ TODO ⚠️"