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

CI ghcr.io + Docker Hub

Deadpool & Wolverine, Thunderbolts* and Lilo & Stitch, each as the official poster and as rendered by Posteryard with quality badges and a Maintainerr label

@@ -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.io](https://img.shields.io/badge/ghcr.io-posteryard-2496ED?logo=github&logoColor=white)]({GHCR_PAGE}) +[![Docker Hub](https://img.shields.io/badge/docker%20hub-posteryard-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/{HUB_IMAGE}) +[![Latest release](https://img.shields.io/github/v/release/{REPO})]({RELEASES}) + +![{BANNER}]({RAW}/docs/before-after.jpg) + +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())