Build and publish container / build (pull_request) Successful in 4m4s
Runs take fifteen to eighteen minutes, and the log shows why: the image is built twice, in full. The test stage is built by the runner's docker daemon. The runtime stage was then built by docker/build-push-action, which runs under a buildx builder that setup-buildx-action creates in its own container with its own cache. The two share nothing, so the second build spent seventy-six seconds booting buildkit and then installed ffmpeg and the package all over again -- around a hundred and ten seconds for the apk and another hundred for pip, neither of which produced anything the first build had not already made. The comment above the test step claimed those layers were shared, which is what made this look reasonable. buildx earns that overhead when producing several architectures. This produces linux/amd64 only, by an explicit decision recorded in the workflow, so it earns nothing here. Use plain docker build against the same daemon that ran the tests, and push with docker push. The runtime stage is a strict prefix of the test stage, so every layer is a cache hit: measured at 1.3 seconds locally. Identical to the change made in music-curator, whose workflow this one was copied from.
211 lines
8.0 KiB
YAML
211 lines
8.0 KiB
YAML
name: Build and publish container
|
|
|
|
on:
|
|
# On merge to main, only build/release when image-affecting files change;
|
|
# CI-config and docs changes do not produce a new image. pyproject.toml is
|
|
# deliberately absent: the release step below commits to it, and that commit
|
|
# must not start another run.
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- "Dockerfile"
|
|
- ".dockerignore"
|
|
- "music_mirror.py"
|
|
# Pull requests always run (tests and the image build are the checks); no
|
|
# path filter.
|
|
pull_request:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
# A newer run cancels an older in-flight run in the same group (keyed by ref),
|
|
# so a fresh merge to main supersedes the previous build and only the latest
|
|
# release is produced. Each pull request likewise supersedes only its own
|
|
# earlier runs.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
packages: write
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
with:
|
|
# Full history and tags are required to derive the next version
|
|
# from the conventional-commit messages since the last release.
|
|
fetch-depth: 0
|
|
|
|
# The suite runs inside the image, against the ffmpeg that ships, rather
|
|
# than against whatever the runner happens to provide. A failing test
|
|
# fails the build. The runtime stage below is built from the same daemon
|
|
# afterwards, so its layers are already in cache.
|
|
- name: Run the test suite inside the image
|
|
run: docker build --target test -t music-mirror:test .
|
|
|
|
- name: Determine registry host
|
|
run: echo "REGISTRY=${GITHUB_SERVER_URL#*://}" >> "$GITHUB_ENV"
|
|
|
|
# Derive the release version from conventional commits since the last
|
|
# v* tag: feat -> minor, fix/perf -> patch, ! or BREAKING CHANGE -> major.
|
|
# Anything else (chore, ci, docs, build) produces no release; those builds
|
|
# are published under a sha-<short> tag only.
|
|
- name: Compute version and image tags
|
|
id: version
|
|
run: |
|
|
set -euo pipefail
|
|
image="${REGISTRY}/${GITHUB_REPOSITORY,,}"
|
|
|
|
last_tag="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
|
|
if [ -n "$last_tag" ]; then
|
|
range="${last_tag}..HEAD"
|
|
base="${last_tag#v}"
|
|
else
|
|
range=""
|
|
base="0.0.0"
|
|
fi
|
|
|
|
subjects="$(git log ${range} --format='%s')"
|
|
bodies="$(git log ${range} --format='%B')"
|
|
|
|
bump="none"
|
|
if printf '%s\n' "$bodies" | grep -qiE 'BREAKING[ -]CHANGE' \
|
|
|| printf '%s\n' "$subjects" | grep -qE '^[a-z]+([(][^)]*[)])?!:'; then
|
|
bump="major"
|
|
elif printf '%s\n' "$subjects" | grep -qE '^feat([(][^)]*[)])?:'; then
|
|
bump="minor"
|
|
elif printf '%s\n' "$subjects" | grep -qE '^(fix|perf)([(][^)]*[)])?:'; then
|
|
bump="patch"
|
|
fi
|
|
|
|
major="${base%%.*}"
|
|
rest="${base#*.}"
|
|
minor="${rest%%.*}"
|
|
patch="${rest##*.}"
|
|
|
|
# workflow_dispatch releases too, so a release can be cut without a
|
|
# code change -- the push filter above ignores workflow and doc edits.
|
|
# Restricted to main: a manual run elsewhere must not tag a commit
|
|
# that is not on the default branch.
|
|
release="false"
|
|
if [ "${GITHUB_EVENT_NAME}" != "pull_request" ] \
|
|
&& [ "${GITHUB_REF_NAME}" = "main" ] \
|
|
&& [ "$bump" != "none" ]; then
|
|
release="true"
|
|
case "$bump" in
|
|
major) major=$((major + 1)); minor=0; patch=0 ;;
|
|
minor) minor=$((minor + 1)); patch=0 ;;
|
|
patch) patch=$((patch + 1)) ;;
|
|
esac
|
|
version="${major}.${minor}.${patch}"
|
|
{
|
|
echo "tags<<__EOT__"
|
|
echo "${image}:${version}"
|
|
echo "${image}:${major}.${minor}"
|
|
echo "${image}:${major}"
|
|
echo "${image}:latest"
|
|
echo "__EOT__"
|
|
} >> "$GITHUB_OUTPUT"
|
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
|
else
|
|
short="$(git rev-parse --short HEAD)"
|
|
{
|
|
echo "tags<<__EOT__"
|
|
echo "${image}:sha-${short}"
|
|
echo "__EOT__"
|
|
} >> "$GITHUB_OUTPUT"
|
|
fi
|
|
echo "release=${release}" >> "$GITHUB_OUTPUT"
|
|
echo "Computed bump=${bump}, release=${release}, base=${base}"
|
|
|
|
- name: Log in to the Gitea container registry
|
|
if: github.event_name != 'pull_request'
|
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
|
|
|
# Plain `docker build` rather than buildx. buildx boots its own buildkit
|
|
# in a container with a cache of its own, so it shared nothing with the
|
|
# test build above and rebuilt the image from the base up -- installing
|
|
# ffmpeg and the package a second time, for nothing. It earns that cost
|
|
# when building for several platforms; this only ever targets the amd64
|
|
# NAS, so it does not.
|
|
#
|
|
# `--target runtime` is a strict prefix of the test stage, so every layer
|
|
# is already in the daemon's cache and this resolves in seconds.
|
|
- name: Build the runtime image
|
|
run: |
|
|
set -euo pipefail
|
|
tags=()
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] && tags+=(-t "$tag")
|
|
done <<< "${{ steps.version.outputs.tags }}"
|
|
docker build --target runtime \
|
|
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
|
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
|
"${tags[@]}" .
|
|
|
|
- name: Push
|
|
if: github.event_name != 'pull_request'
|
|
run: |
|
|
set -euo pipefail
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] && docker push "$tag"
|
|
done <<< "${{ steps.version.outputs.tags }}"
|
|
|
|
# Record the release: write the computed version into pyproject.toml, then
|
|
# commit and tag it, so the packaging metadata always matches the release
|
|
# instead of drifting behind it. The version is derived from commit
|
|
# messages and only known here, after the build, so it cannot be set by
|
|
# hand in the pull request that causes the release.
|
|
- name: Record and tag the release
|
|
if: steps.version.outputs.release == 'true'
|
|
env:
|
|
VERSION: ${{ steps.version.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
python3 - "$VERSION" <<'PY'
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
version = sys.argv[1]
|
|
path = pathlib.Path("pyproject.toml")
|
|
text = path.read_text()
|
|
text, count = re.subn(
|
|
r'(?m)^version = ".*"$', f'version = "{version}"', text, count=1
|
|
)
|
|
if count != 1:
|
|
raise SystemExit("no version line found in pyproject.toml")
|
|
path.write_text(text)
|
|
PY
|
|
|
|
git config user.name "${{ github.actor }}"
|
|
git config user.email "${{ github.actor }}@users.noreply.${REGISTRY}"
|
|
git add pyproject.toml
|
|
|
|
# The file may already carry this version, in which case there is
|
|
# nothing to commit and `git commit` would fail the job.
|
|
if git diff --cached --quiet; then
|
|
echo "pyproject.toml is already at ${VERSION}"
|
|
else
|
|
git commit -m "chore(release): v${VERSION}"
|
|
# Push the branch before the tag. If main has moved on and this push
|
|
# is rejected, the job fails without having left a tag pointing at a
|
|
# commit that is not on main.
|
|
git push origin "HEAD:${GITHUB_REF_NAME}"
|
|
fi
|
|
|
|
git tag -a "v${VERSION}" -m "v${VERSION}"
|
|
git push origin "v${VERSION}"
|