Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 160 additions & 4 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ on:
- '.github/workflows/docker-build.yml'
schedule:
# Monday 04:17 UTC, two hours before the weekly image scan in security.yml. Rebuilds
# :dev so the scan sees this week's Debian security fixes instead of whatever was
# current at the last push to backend/.
# :dev and the latest release (:latest and :stable) so the scan sees this week's Debian
# security fixes instead of whatever was current at the last push or release.
- cron: '17 4 * * 1'
workflow_dispatch:
inputs:
rebuild_release:
description: 'Rebuild the latest v* release with current Debian packages instead of building the selected ref'
type: boolean
default: false

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
if: ${{ !inputs.rebuild_release }}
runs-on: ubuntu-24.04
permissions:
contents: read
Expand Down Expand Up @@ -109,5 +115,155 @@ jobs:
GIT_TAG=${{ steps.version.outputs.git_tag }}
GIT_COMMIT=${{ steps.version.outputs.git_commit }}
APT_REFRESH=${{ steps.version.outputs.apt_refresh }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Own cache scope, so the release rebuild below does not evict the layers of dev.
cache-from: type=gha,scope=dev
cache-to: type=gha,mode=max,scope=dev

# :latest is otherwise only built when a v* tag is pushed, so between releases it keeps
# the Debian packages of release day and the weekly image scan goes red on fixes that
# were published since. Rebuild the newest release from its own tag every week: same
# code, same version string, current packages. Nothing from dev ends up in :latest, and
# nothing is pushed before the rebuilt image has passed a smoke test and the Trivy gate.
rebuild-release:
if: ${{ github.event_name == 'schedule' || inputs.rebuild_release }}
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true

- name: Find the latest release
id: release
# Final releases only (v1.9.1, not v1.10.0-beta.1), highest version first.
run: |
tag=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
if [[ -z "$tag" ]]; then
echo "No release tag found" >&2
exit 1
fi
version="${tag#v}"
{
echo "tag=$tag"
echo "version=$version"
echo "minor=${version%.*}"
echo "apt_refresh=$(date -u +%F)"
} >> "$GITHUB_OUTPUT"
echo "Rebuilding $tag"

# The exceptions file is policy, not release content: take today's copy from the
# branch this workflow runs on before the checkout below swaps the tree for the tag.
- name: Keep the current Trivy exceptions
run: cp .trivyignore.yaml "$RUNNER_TEMP/trivyignore.yaml"

- name: Check out the release
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: git checkout --quiet "refs/tags/$RELEASE_TAG"

- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
driver: docker-container

- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Nothing is pushed until the rebuilt image has proven itself. Self-hosters pull
# :latest automatically, so a broken or suspicious upstream change has to stop here,
# not two hours later in the weekly scan. Build the runner's platform first and load
# it locally: that is the image the checks below run against.
- name: Build the candidate image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./backend
platforms: linux/amd64
load: true
push: false
tags: spacepad-release:candidate
build-args: |
GIT_TAG=${{ steps.release.outputs.version }}
GIT_COMMIT=
APT_REFRESH=${{ steps.release.outputs.apt_refresh }}
cache-from: type=gha,scope=release
cache-to: type=gha,mode=max,scope=release

# Boots the image the way a self-hoster runs it and asks Laravel's health route,
# which opens the database and writes to the cache. A container that cannot start,
# misses an extension or fails to boot the framework answers 500 or nothing at all.
- name: Smoke test the candidate
run: |
docker run -d --name candidate -p 8080:8080 \
-e APP_KEY="base64:$(openssl rand -base64 32)" \
-e APP_ENV=production \
-e DB_CONNECTION=sqlite \
-e DB_DATABASE=/var/www/html/storage/database.sqlite \
-e CACHE_STORE=array \
-e SESSION_DRIVER=array \
-e QUEUE_CONNECTION=sync \
-e LOG_CHANNEL=stderr \
spacepad-release:candidate

code=000
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/health || true)
[[ "$code" == "200" ]] && break
sleep 2
done

docker exec candidate php -m | grep -qiE '^opentelemetry$' || { echo "opentelemetry extension missing"; code=missing-ext; }
docker exec candidate php -m | grep -qiE '^protobuf$' || { echo "protobuf extension missing"; code=missing-ext; }

if [[ "$code" != "200" ]]; then
echo "Smoke test failed (health: $code)"
docker logs candidate 2>&1 | tail -n 50
exit 1
fi
echo "Smoke test passed"
docker rm -f candidate > /dev/null

- name: Scan the candidate (block on fixable CRITICAL/HIGH)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
image-ref: spacepad-release:candidate
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '1'
format: table
trivyignores: ${{ runner.temp }}/trivyignore.yaml

# Only the floating tags. 1.9.1 and 1.9 keep the bytes of their release, so whoever
# pins a version gets exactly what was released; whoever wants this week's fixes
# follows :latest or :stable. Every layer for linux/amd64 comes from the cache the
# candidate build just wrote, so this pushes the image that was tested.
- name: Push the release image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./backend
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ steps.release.outputs.version }}
build-args: |
GIT_TAG=${{ steps.release.outputs.version }}
GIT_COMMIT=
APT_REFRESH=${{ steps.release.outputs.apt_refresh }}
cache-from: type=gha,scope=release
cache-to: type=gha,mode=max,scope=release
5 changes: 5 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ RUN echo "apt refresh: ${APT_REFRESH}" \
&& apt-get upgrade -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
RUN install-php-extensions opentelemetry protobuf
# The base image ships a full compiler toolchain (gcc, g++, make, binutils, libc6-dev,
# linux-libc-dev). Nothing needs it at runtime, and linux-libc-dev alone adds some fifty
# kernel CVEs to every weekly image scan. Drop it once the extensions above are built.
RUN apt-get purge -y --auto-remove gcc g++ make linux-libc-dev libc6-dev binutils cpp pkg-config \
&& rm -rf /var/lib/apt/lists/*

# CVE-2026-109618 / CVE-2026-109619 (php2shell) are fixed in PHP 8.4.27. The tag above floats,
# so fail the build rather than ship an older PHP from a stale registry mirror or layer cache.
Expand Down
Loading