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 @@
-
+
@@ -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