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_curator.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 interpreter 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-curator: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- 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 image up -- two # full builds per run. 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}"