From 9cee7ded94a8420b8a84205916dbc994c6ec2914 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 03:17:33 +0200 Subject: [PATCH 1/3] ci(release): publish the image to docker hub as well as ghcr Each architecture is pushed by digest to both registries from the same build, and one `imagetools create` writes the manifest list under every tag in both, so a pull from either answers the same digest. The publish job reads both digests back and fails if they differ. Docker Hub is reached through the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets, as docker.io/instazdll/waveflow-server. The release notes, the README and the Compose file name both registries. Signed-off-by: InstaZDLL --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++++++-- README.md | 11 +++++++- docker-compose.yml | 2 ++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b19d2bd7..a1122243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,14 @@ name: Release # image floats as `beta` rather than `latest`, and the release wears the # pre-release badge. Same rule as the desktop repository. # +# The image goes to two registries, GHCR and Docker Hub, from one build: each +# architecture is pushed to both by digest, and one manifest list is written +# under every tag in both. A pull from either therefore resolves to the same +# digest — two builds would have produced two images that only share a name. +# Docker Hub is reached with the `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` +# repository secrets; without them the build stops at its login rather than +# publishing to one registry and announcing two. +# # The tag is the source of truth for the version. `Cargo.toml` is not read # here — a tag that disagrees with it would produce an image labelled one way # and reporting itself another, so the two are checked against each other @@ -37,6 +45,10 @@ concurrency: env: REGISTRY: ghcr.io + # Docker Hub is spelled out rather than derived from `github.repository`: + # the GitHub owner and the Docker Hub namespace are two accounts that only + # happen to share a name. Lowercase, which Docker Hub requires as GHCR does. + DOCKERHUB_IMAGE: docker.io/instazdll/waveflow-server jobs: # Cheap, and it runs before any build minute is spent: a tag that does not @@ -115,6 +127,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - id: build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: @@ -123,7 +141,11 @@ jobs: # Pushed by digest; the tags are attached by the merge job. Pushing a # tag from each runner would leave whichever finished last as the # whole image rather than one architecture of it. - outputs: type=image,name=${{ needs.verify.outputs.image }},push-by-digest=true,name-canonical=true,push=true + # + # Both names in one output, quoted because the comma between them + # would otherwise end the `name` field: one build, so one digest, + # pushed to each registry. + outputs: type=image,"name=${{ needs.verify.outputs.image }},${{ env.DOCKERHUB_IMAGE }}",push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} @@ -166,14 +188,26 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # `latest` is deliberately absent. It is a pre-release: a reader who # pulls `latest` should get a stable build or nothing, never a beta they # did not ask for. The floating tag for this line is `beta`. + # + # One `imagetools create` writes the manifest list under every tag in + # both registries, so the list itself is the same object everywhere. + # Its sources are named in GHCR only; the build pushed each architecture + # to Docker Hub too, so nothing has to cross between the registries. - name: Create the manifest working-directory: /tmp/digests run: | version="${{ needs.verify.outputs.version }}" image="${{ needs.verify.outputs.image }}" + hub_image="${{ env.DOCKERHUB_IMAGE }}" case "$version" in *-*) extra="beta" ;; *) extra="latest" ;; @@ -192,8 +226,19 @@ jobs: docker buildx imagetools create \ --tag "$image:$version" \ --tag "$image:$extra" \ + --tag "$hub_image:$version" \ + --tag "$hub_image:$extra" \ "${refs[@]}" docker buildx imagetools inspect "$image:$version" + # Read back rather than assumed: the promise is that both registries + # answer the same digest, and a mismatch is a failed release. + ghcr_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$version")" + hub_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$hub_image:$version")" + echo "ghcr=$ghcr_digest hub=$hub_digest" + if [ "$ghcr_digest" != "$hub_digest" ]; then + echo "::error::GHCR answers $ghcr_digest but Docker Hub answers $hub_digest" + exit 1 + fi # The GitHub release comes last, once the image it announces exists. A # release page whose pull command 404s is worse than no page. @@ -216,6 +261,7 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} VERSION: ${{ needs.verify.outputs.version }} IMAGE: ${{ needs.verify.outputs.image }} + HUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} run: | case "$VERSION" in *-*) release_flag="--prerelease" ; channel="beta" ;; @@ -225,8 +271,8 @@ jobs: # printf with a single-quoted format rather than a heredoc: the body # is mostly backticks, and every one of them would be a command # substitution in an unquoted here-document. - printf '## Container image\n\n```bash\ndocker pull %s:%s\n```\n\nAlso published as `%s`, for `linux/amd64` and `linux/arm64`.\n' \ - "$IMAGE" "$VERSION" "$channel" > notes.md + printf '## Container image\n\n```bash\ndocker pull %s:%s\n```\n\nThe same image, digest for digest, is on Docker Hub:\n\n```bash\ndocker pull %s:%s\n```\n\nAlso published as `%s` in both, for `linux/amd64` and `linux/arm64`.\n' \ + "$IMAGE" "$VERSION" "${HUB_IMAGE#docker.io/}" "$VERSION" "$channel" > notes.md # `--generate-notes` appends the merged pull requests below this, so # the install line stays at the top where it is useful. diff --git a/README.md b/README.md index 9d55e102..4795c570 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust 1.98+ SQLite only OpenSubsonic - Docker image on GHCR + Docker image on GHCR and Docker Hub License AGPL-3.0

@@ -39,6 +39,15 @@ docker run -d --name waveflow \ ghcr.io/instazdll/waveflow-server:2.0.0-beta.1 ``` +The image is published to two registries, for `linux/amd64` and `linux/arm64`, under the same tags and the same digest — pull from whichever your host reaches: + +| Registry | Image | +| --- | --- | +| GitHub Container Registry | `ghcr.io/instazdll/waveflow-server` | +| Docker Hub | `docker.io/instazdll/waveflow-server` | + +Each release is tagged with its version; `beta` follows the latest pre-release, and `latest` will follow the latest stable release once there is one. Docker Hub carries the releases after `2.0.0-beta.1` — that one and earlier are on GHCR only, which is why the commands here pull from GHCR. + Or with Compose — set `WAVEFLOW_MUSIC_PATH` to your music directory, which is mounted read-only: ```bash diff --git a/docker-compose.yml b/docker-compose.yml index d8fec391..2795a14f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ # Pulls the published image. To run your own build instead, uncomment `build` # below — with it present Compose builds from this checkout rather than pulling, # which is what a contributor wants and what an operator does not. +# The same image is on Docker Hub as `docker.io/instazdll/waveflow-server`, with +# the same tags and digest, for a host that pulls from there instead. services: waveflow: image: ghcr.io/instazdll/waveflow-server:2.0.0-beta.1 From 1745727bf08874c1db94b100ba48fe2b30a595d0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 03:26:18 +0200 Subject: [PATCH 2/3] ci(release): serialize the publish job across releases The run's concurrency group is per tag, but the floating beta and latest tags are shared by every release. Two releases publishing at once could interleave their writes and leave a floating tag on one version in GHCR and another on Docker Hub. The publish job now takes a group common to every release; the builds still run in parallel. Signed-off-by: InstaZDLL --- .github/workflows/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1122243..2e4d8eb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,6 +170,16 @@ jobs: needs: [verify, build] runs-on: ubuntu-24.04 timeout-minutes: 15 + # The run's own group is per tag, but `beta` and `latest` are shared by + # every tag: two releases publishing at once could interleave their + # writes and leave the floating tag on one version in GHCR and another on + # Docker Hub. Only this job writes tags, so only this job is serialized; + # the builds still run side by side. GitHub keeps one waiting job per + # group, so a third release arriving meanwhile cancels the second one's + # publish — visibly, and a dispatch of that tag publishes it again. + concurrency: + group: release-floating-tags + cancel-in-progress: false permissions: contents: read packages: write From 10498d954078ad407f1ce290c3657396d267ca29 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 03:31:47 +0200 Subject: [PATCH 3/3] ci(release): queue every waiting publish rather than one GitHub keeps one pending job per concurrency group by default, so a third release arriving while one publish runs and another waits would cancel the waiting one. queue: max keeps them all in line. Signed-off-by: InstaZDLL --- .github/workflows/release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e4d8eb3..b6782c6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,12 +174,13 @@ jobs: # every tag: two releases publishing at once could interleave their # writes and leave the floating tag on one version in GHCR and another on # Docker Hub. Only this job writes tags, so only this job is serialized; - # the builds still run side by side. GitHub keeps one waiting job per - # group, so a third release arriving meanwhile cancels the second one's - # publish — visibly, and a dispatch of that tag publishes it again. + # the builds still run side by side. `queue: max` keeps every waiting + # publish in line: by default GitHub keeps only one, and a third release + # arriving meanwhile would cancel the second's publish outright. concurrency: group: release-floating-tags cancel-in-progress: false + queue: max permissions: contents: read packages: write