Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/dockerhub.yml
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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 }}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<p align="center">
<a href="https://github.com/SandObserver/Posteryard/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/SandObserver/Posteryard/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://github.com/SandObserver/Posteryard/pkgs/container/posteryard"><img alt="ghcr.io" src="https://img.shields.io/badge/ghcr.io-posteryard-2496ED?logo=github&logoColor=white"></a>
<a href="https://hub.docker.com/r/sandobserver/posteryard"><img alt="Docker Hub" src="https://img.shields.io/badge/docker%20hub-posteryard-2496ED?logo=docker&logoColor=white"></a>
</p>

<p align="center"><img src="docs/before-after.jpg" alt="Deadpool & Wolverine, Thunderbolts* and Lilo & Stitch, each as the official poster and as rendered by Posteryard with quality badges and a Maintainerr label"></p>
Expand All @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions tests/test_dockerhub.py
Original file line number Diff line number Diff line change
@@ -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")
91 changes: 91 additions & 0 deletions tools/dockerhub_description.py
Original file line number Diff line number Diff line change
@@ -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"<details>\n<summary>([^<]+)</summary>\n", r"#### \1\n", md).replace("\n</details>\n", "\n")
md = re.sub(r'\n<a href="[^"]*"><img src="docs/tmdb\.svg"[^>]*></a>\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())