diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml
new file mode 100644
index 0000000..8f60b88
--- /dev/null
+++ b/.github/workflows/dockerhub.yml
@@ -0,0 +1,34 @@
+name: Docker Hub page
+
+on:
+ workflow_dispatch:
+ workflow_call:
+ secrets:
+ DOCKERHUB_TOKEN:
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ page:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - run: python3 tools/dockerhub_description.py > dockerhub.md
+ - name: Check the token can push
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: docker.io
+ username: sandobserver
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ # The token needs Read, Write and Delete. With Read and Write the page update returns 403.
+ - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
+ with:
+ username: sandobserver
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ repository: sandobserver/posteryard
+ readme-filepath: dockerhub.md
+ short-description: Clean, consistent posters for Plex, Jellyfin and Emby, rendered and kept current automatically.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5535d4e..0eb3c9e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,10 +50,17 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: docker.io
+ username: sandobserver
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
- images: ghcr.io/sandobserver/posteryard
+ images: |
+ ghcr.io/sandobserver/posteryard
+ docker.io/sandobserver/posteryard
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -84,3 +91,10 @@ jobs:
[[ "$GITHUB_REF_NAME" == *-* ]] && prerelease=(--prerelease)
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" \
--notes-file notes.md --verify-tag "${prerelease[@]}"
+
+ dockerhub-page:
+ needs: release
+ if: ${{ !contains(github.ref_name, '-') }}
+ uses: ./.github/workflows/dockerhub.yml
+ secrets:
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6263ca7..2b9eea7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+### Added
+
+- Publish the image to Docker Hub as `sandobserver/posteryard` too, with the same tags as ghcr.io.
+
### Changed
- Give a Live TV title without art its channel's logo large over the logo's blurred colours, in place of the title on dark grey.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 528b769..d246510 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -118,4 +118,4 @@ The Plex preview shows either 12 random movies or 12 random shows from `site/pos
## Releases
-Semantic versioning. Move the `Unreleased` entries in [CHANGELOG.md](CHANGELOG.md) under the new version with its date, set `version` in `pyproject.toml`, run `uv run python tools/unraid_template.py` to copy the notes into the Unraid template, merge, and push a `vX.Y.Z` tag. The tag runs the CI checks, then pushes `ghcr.io/sandobserver/posteryard:X.Y.Z`, `:X.Y` and `:latest`, and publishes a GitHub release with the version's CHANGELOG section. The release stops if the tag, `pyproject.toml` and a dated CHANGELOG section do not agree. The image carries an SBOM and build provenance. A tag with a suffix, such as `v1.0.0-rc.1`, publishes a prerelease: only the `1.0.0-rc.1` image tag, and a GitHub release marked as prerelease.
+Semantic versioning. Move the `Unreleased` entries in [CHANGELOG.md](CHANGELOG.md) under the new version with its date, set `version` in `pyproject.toml`, run `uv run python tools/unraid_template.py` to copy the notes into the Unraid template, merge, and push a `vX.Y.Z` tag. The tag runs the CI checks, then pushes `X.Y.Z`, `X.Y` and `latest` to `ghcr.io/sandobserver/posteryard` and `docker.io/sandobserver/posteryard`, publishes a GitHub release with the version's CHANGELOG section, and rewrites the Docker Hub page from `README.md` with `tools/dockerhub_description.py`. Docker Hub needs the repository secret `DOCKERHUB_TOKEN`, an access token with Read, Write and Delete. Docker Hub refuses to move an `X.Y.Z` tag, so a version cannot be published twice. The `Docker Hub page` workflow updates the page by hand. The release stops if the tag, `pyproject.toml` and a dated CHANGELOG section do not agree. The image carries an SBOM and build provenance. A tag with a suffix, such as `v1.0.0-rc.1`, publishes a prerelease: only the `1.0.0-rc.1` image tag, and a GitHub release marked as prerelease.
diff --git a/README.md b/README.md
index 6e18eae..536386c 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
+

@@ -30,7 +31,7 @@ You need [Docker](https://docs.docker.com/get-started/get-docker/) on a 64-bit I
### 1. Create the compose file
-Save this as `compose.yml` and replace the example values.
+Save this as `compose.yml` and replace the example values. The same image is on Docker Hub as `sandobserver/posteryard`.
```yaml
services:
diff --git a/tests/test_dockerhub.py b/tests/test_dockerhub.py
new file mode 100644
index 0000000..435cb06
--- /dev/null
+++ b/tests/test_dockerhub.py
@@ -0,0 +1,58 @@
+import importlib.util
+import re
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+ROOT = Path(__file__).parent.parent
+
+
+def _tool() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("dockerhub_description", ROOT / "tools" / "dockerhub_description.py")
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+TOOL = _tool()
+PAGE: str = TOOL.description((ROOT / "README.md").read_text(), "1.2.3")
+PROSE = re.sub(r"```[\s\S]*?```", "", PAGE)
+
+
+def test_the_page_fits_docker_hub() -> None:
+ assert len(PAGE) <= TOOL.MAX_LENGTH
+
+
+def test_every_link_and_image_is_absolute() -> None:
+ targets = re.findall(r"\]\(([^)\s]+)\)", PAGE)
+ assert targets
+ assert [t for t in targets if not t.startswith("https://")] == []
+
+
+def test_the_page_has_no_html_outside_code() -> None:
+ assert re.findall(r"<[a-z/][^>]*>", PROSE) == []
+
+
+def test_commands_pull_from_docker_hub() -> None:
+ blocks = "".join(re.findall(r"```[\s\S]*?```", PAGE))
+ assert "sandobserver/posteryard:latest" in blocks
+ assert "ghcr.io/sandobserver/posteryard" not in blocks
+
+
+def test_the_tags_name_the_current_version() -> None:
+ assert "| `1.2` |" in PAGE
+ assert "| `1.2.3` |" in PAGE
+ assert PAGE.index("## Image tags") < PAGE.index("## Unraid")
+
+
+def test_a_readme_without_an_unraid_section_is_refused() -> None:
+ with pytest.raises(ValueError, match="Unraid"):
+ TOOL.description("# Posteryard\n\nIntro.\n\n## Settings\n", "1.2.3")
+
+
+def test_a_readme_over_the_limit_is_refused() -> None:
+ readme = "# Posteryard\n\nIntro.\n\n## Unraid\n\n" + "x" * TOOL.MAX_LENGTH
+ with pytest.raises(ValueError, match="Shorten"):
+ TOOL.description(readme, "1.2.3")
diff --git a/tools/dockerhub_description.py b/tools/dockerhub_description.py
new file mode 100644
index 0000000..4385bf3
--- /dev/null
+++ b/tools/dockerhub_description.py
@@ -0,0 +1,91 @@
+"""Print README.md as the Docker Hub repository overview.
+
+Docker Hub renders the text away from the repository, shows HTML as literal text and has no relative links.
+The header is replaced, every link is made absolute, code blocks name the Docker Hub image, and an
+Image tags section is added for the current version.
+
+Usage: python3 tools/dockerhub_description.py > dockerhub.md
+"""
+
+import re
+import sys
+import tomllib
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+REPO = "SandObserver/Posteryard"
+BLOB = f"https://github.com/{REPO}/blob/main"
+RAW = f"https://raw.githubusercontent.com/{REPO}/main"
+GHCR_IMAGE = "ghcr.io/sandobserver/posteryard"
+HUB_IMAGE = "sandobserver/posteryard"
+MAX_LENGTH = 25000
+GHCR_PAGE = f"https://github.com/{REPO}/pkgs/container/posteryard"
+RELEASES = f"https://github.com/{REPO}/releases"
+BANNER = "Deadpool & Wolverine, Thunderbolts* and Lilo & Stitch, as the official poster and as rendered by Posteryard"
+
+HEADER = f"""# Posteryard
+
+**Clean, consistent posters for Plex, Jellyfin and Emby, picked, rendered and kept current automatically.**
+
+[]({GHCR_PAGE})
+[](https://hub.docker.com/r/{HUB_IMAGE})
+[]({RELEASES})
+
+
+
+Website: **[posteryard.sandobserver.com](https://posteryard.sandobserver.com)**
+
+Source and issues: **[github.com/{REPO}](https://github.com/{REPO})**
+"""
+
+
+def tags(version: str) -> str:
+ minor = ".".join(version.split(".")[:2])
+ return f"""## Image tags
+
+| Tag | Points to |
+| --- | --- |
+| `latest` | The newest stable release. |
+| `{minor}` | The newest patch of a minor version. |
+| `{version}` | One exact release. It never changes. |
+
+Every tag is built for `linux/amd64` and `linux/arm64`. The same image is on [{GHCR_IMAGE}]({GHCR_PAGE}).
+Release notes are on [GitHub Releases]({RELEASES}).
+
+"""
+
+
+def absolute(match: re.Match[str]) -> str:
+ bang, text, target = match.groups()
+ if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I):
+ return match.group(0)
+ if target.startswith("#"):
+ return f"{bang}[{text}](https://github.com/{REPO}{target})"
+ return f"{bang}[{text}]({RAW if bang else BLOB}/{target})"
+
+
+def description(readme: str, version: str) -> str:
+ first = readme.index("\n## ")
+ intro = readme[:first].rstrip().rsplit("\n\n", 1)[-1]
+ md = f"{HEADER}\n{intro}\n{readme[first:]}"
+ if "\n## Unraid\n" not in md:
+ raise ValueError("README.md has no Unraid section to put the image tags before")
+ md = md.replace("\n## Unraid\n", f"\n{tags(version)}## Unraid\n", 1)
+ md = re.sub(r"\n([^<]+)
\n", r"#### \1\n", md).replace("\n \n", "\n")
+ md = re.sub(r'\n
]*>\n', "", md)
+ md = re.sub(r"(!?)\[([^\]]*)\]\(([^)\s]+)\)", absolute, md)
+ md = re.sub(r"```[\s\S]*?```", lambda block: block.group(0).replace(GHCR_IMAGE, HUB_IMAGE), md)
+ md = md.rstrip() + "\n"
+ if len(md) > MAX_LENGTH:
+ raise ValueError(f"{len(md)} characters, over Docker Hub's {MAX_LENGTH}. Shorten README.md")
+ return md
+
+
+def main() -> int:
+ version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
+ sys.stdout.write(description((ROOT / "README.md").read_text(), version))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())