From 82c9da6d623bd9291df7abcaafd2b78e55da494c Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Fri, 21 Aug 2026 14:44:29 +0100 Subject: [PATCH] ci: publish the container image to the Gitea registry Adopts the release scheme from legacy-email-proxy so both repositories behave the same way: the version is derived from conventional commits since the last v* tag, the image is pushed under the full version, the truncated major.minor and major forms, and latest, and non-release builds are published as sha-. Multi-arch (amd64 for the NAS, arm64 so the same image runs on a Pi). Authentication uses the PACKAGES_SECRET repository secret. The release step also writes the computed version into pyproject.toml and commits it as chore(release) before tagging, so the packaging metadata cannot drift behind the release. It skips the commit when the file already carries that version, which would otherwise fail the job after the image had been pushed. compose.yaml and the README now reference the published image instead of instructing the NAS to build one locally. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/build-and-publish.yaml | 201 ++++++++++++++++++++++++ .gitea/workflows/ci.yaml | 41 ----- README.md | 14 +- compose.yaml | 6 +- 4 files changed, 210 insertions(+), 52 deletions(-) create mode 100644 .gitea/workflows/build-and-publish.yaml delete mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml new file mode 100644 index 0000000..057279f --- /dev/null +++ b/.gitea/workflows/build-and-publish.yaml @@ -0,0 +1,201 @@ +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 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + # The test suite runs real encodes, so ffmpeg is a test dependency. + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y ffmpeg + + - name: Install test dependencies + run: python -m pip install --upgrade pip && pip install pytest + + - name: Run unit tests + run: python -m pytest + + - 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##*.}" + + 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 QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + + - 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: . + # amd64 for the NAS, arm64 so the same image runs on a Pi. + platforms: linux/amd64,linux/arm64 + 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 + + python - "$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}" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml deleted file mode 100644 index c73f41d..0000000 --- a/.gitea/workflows/ci.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.13 - - # The test suite runs real encodes, so ffmpeg is a test dependency. - - name: Install ffmpeg - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y ffmpeg - - - name: Install test dependencies - run: python -m pip install --upgrade pip && pip install pytest - - - name: Run unit tests - run: python -m pytest - - - name: Build the container image - run: docker build -t music-mirror:ci . diff --git a/README.md b/README.md index 8e7fa45..f070bf5 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,16 @@ both provide them. ## Running it on TrueNAS Scale -`compose.yaml` is a Custom App definition. Build the image on the NAS, adjust -the two host paths and the `user:` to match your pool, then add it as a custom -app: +`compose.yaml` is a Custom App definition. Adjust the two host paths and the +`user:` to match your pool, then add it as a custom app. The image is published +to this Gitea's registry on every release: -```sh -git clone https://code.emmathe.dev/lyrathorpe/music-mirror -cd music-mirror && docker build -t music-mirror:latest . ``` +code.emmathe.dev/lyrathorpe/music-mirror:latest +``` + +Tags are `latest`, the full version, and the truncated `major.minor` and +`major` forms; builds that are not releases are published as `sha-`. Point the mirror at its own dataset rather than a directory inside the music dataset — it is derived data, so it wants its own snapshot policy, its own diff --git a/compose.yaml b/compose.yaml index 0e12800..a157ab5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,15 +1,11 @@ # TrueNAS Scale "Custom App" definition. # -# Build the image on the NAS first (there is no published image yet): -# git clone https://code.emmathe.dev/lyrathorpe/music-mirror -# cd music-mirror && docker build -t music-mirror:latest . -# # Adjust the two host paths and the user to match your pool. The source is # mounted read-only on purpose: nothing here should ever be able to write to # the lossless library. services: music-mirror: - image: music-mirror:latest + image: code.emmathe.dev/lyrathorpe/music-mirror:latest container_name: music-mirror restart: unless-stopped # The dataset owner, so the mirror is not written as root. `id apps` or the