Build and publish container / build (push) Failing after 51s
## What A path-for-path MP3 mirror of a lossless library. FLAC in, MP3 out, same relative layout, tags and cover art carried across. Already-MP3 sources are copied rather than re-encoded. Mirror files whose source has gone are deleted, along with any directory they emptied. The source library is never written to — mounted read-only in the compose file, and never opened for writing in code. | Situation | Action | | -------------------------------- | ------------------------------------ | | No mirror file | encode | | Source modified since the mirror | re-encode (a Lidarr quality upgrade) | | Mirror up to date | skip | | Source is already MP3 | copy verbatim | | Source gone | delete, prune empty dirs | ## Design notes - **No database.** Freshness is mtime: an encode is stamped with its source's mtime, so a file is stale exactly when the two differ. Lidarr owns the library; a second tool with its own index would only fall out of step with it. This is also why it is not beets. - **Atomic writes.** Encode to a temp file, rename into place. An interrupted run cannot leave a truncated MP3 that the next run treats as finished. - **A lock file** in the mirror root stops two passes overlapping. - **Refuses a mirror inside the source tree**, which would otherwise recurse. - `--subdir` never prunes: a partial pass cannot distinguish an orphan from a file outside its scope. ## Shipping - Python package with a `music-mirror` console script, no runtime dependencies beyond ffmpeg. - `Dockerfile` plus `compose.yaml` as a TrueNAS Scale Custom App: source dataset read-only, mirror dataset writable, `MUSIC_MIRROR_INTERVAL=6h`. - CI runs the tests **inside the image**, against the ffmpeg that ships, on every pull request, and on merge publishes multi-arch (amd64 + arm64) to this Gitea's registry using the `PACKAGES_SECRET` repository secret. Versioning follows the same conventional-commit scheme as `legacy-email-proxy`, including writing the released version back into `pyproject.toml` so the packaging metadata cannot drift behind the tag. ## Behaviour under Lidarr | Lidarr does this | The mirror does this | | --------------------------------------- | -------------------------------------------------------------------------------- | | Replaces a file with a better rip | Re-encodes in place; same path in, same path out, so no duplicate | | Upgrades MP3 to FLAC | Both map to the same `.mp3` mirror path, so the old one is overwritten | | Renames a track, album or artist folder | Old path pruned, new path encoded — correct, though it re-encodes rather than moving | | Deletes an album or artist | Every orphaned mirror file goes, and the directories they emptied with them | Three defects were found and fixed while writing those tests, each verified to fail against the previous code: - Pruning probed the source tree for a mirror file's original name, lowercase extensions only. A `.FLAC` source was never found, so its mirror file was deleted as an orphan and rebuilt on the next pass, for ever. Pruning now works from the set of paths the pass actually accounted for. - Two sources could claim one mirror path — `01 Song.flac` beside a leftover `01 Song.mp3`. Both encoded to the same destination and each pass found the loser stale. The better format now wins, ties break on path. - The source mtime was read after encoding rather than before, so a file still being written when the pass reached it could be stamped current while holding truncated audio. ## Why there is no flake The deployment target is a container. A flake here would sit on no path between the source and the NAS, and `nix flake check` would test against nixpkgs' ffmpeg while the artefact ships Debian's — precisely the layer these tests exercise. It was removed in favour of running the suite inside the image. The sibling `legacy-email-proxy` keeps its flake because there the flake *is* the deployment mechanism. ## Verification - 21 tests, all real ffmpeg round-trips: layout, tag survival, MP3 output, skip-when-current, re-encode-on-change, orphan pruning, empty-dir removal, `--no-prune`, MP3 passthrough, `--dry-run`, `--subdir`, external cover art embedding, both refusal paths, and the Lidarr lifecycle cases below. - The suite also runs in the multi-stage Docker `test` stage: 21 passed. The published `runtime` stage carries neither the tests nor pytest, verified by inspecting the image. - Container built and run locally against a sample library: correct output path, Cyrillic tags intact. - The release step was extracted from the workflow and run against a scratch repository: it commits and tags when the version changes, and skips the commit but still tags when `pyproject.toml` already carries it. - **Not tested against the real library** — the first run there should be `--dry-run`. ## Follow-ups, not in this PR - Lidarr imports are picked up on the next scheduled pass rather than instantly. A webhook trigger is the obvious next step if six hours feels slow. - `PACKAGES_SECRET` must exist as a repository secret before the first merge, or the login step fails. --------- Co-authored-by: Emma Thorpe <emma.thorpe@citrix.com> Reviewed-on: #1
194 lines
7.2 KiB
YAML
194 lines
7.2 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. Layers are shared with the push build below.
|
|
- 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##*.}"
|
|
|
|
release="false"
|
|
if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "$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: Set up Buildx
|
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
|
|
|
- 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_SECRET }}
|
|
|
|
- name: Build and push
|
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
|
with:
|
|
context: .
|
|
# Without this the last stage in the Dockerfile -- the test stage --
|
|
# would be what gets published.
|
|
target: runtime
|
|
# The NAS is the only host this runs on. Building arm64 as well would
|
|
# mean emulating it under QEMU for no consumer.
|
|
platforms: linux/amd64
|
|
push: ${{ github.event_name != 'pull_request' }}
|
|
tags: ${{ steps.version.outputs.tags }}
|
|
labels: |
|
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
|
org.opencontainers.image.revision=${{ github.sha }}
|
|
|
|
# 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}"
|