From b86655a49b75f9d33de4545ec7d6e0887f3fab22 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:45:17 -0400 Subject: [PATCH] Publish the Docker Hub description from README.md The Docker Hub repository page was set by hand and drifted from the README. Copying the README across verbatim is not an option: its links are repo-relative and resolve against hub.docker.com, and its centered HTML header is rendered as literal markup. Generate the description at release time instead, from README.md, and push it after a stable tag's image. Prereleases leave the page alone. --- .github/workflows/release.yml | 19 +++++ CHANGELOG.md | 5 ++ api/test/dockerhub-description.test.js | 68 +++++++++++++++ scripts/dockerhub-description.js | 114 +++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 api/test/dockerhub-description.test.js create mode 100644 scripts/dockerhub-description.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9135fbb5..1821758d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,6 +172,25 @@ jobs: upload-artifact: false upload-release-assets: false + # The page is only correct for an image that exists, so this runs after + # the push, and only for a stable tag: a release candidate must not + # rewrite the page describing what `latest` gives you. + - name: Generate the Docker Hub description + if: steps.registries.outputs.dockerhub == 'true' && steps.tag.outputs.prerelease == 'false' + run: node scripts/dockerhub-description.js > dockerhub-description.md + + - name: Update the Docker Hub description + if: steps.registries.outputs.dockerhub == 'true' && steps.tag.outputs.prerelease == 'false' + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + # The description API needs a token with write access; the pull-only + # token the push step is happy with returns 401 here. + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard + readme-filepath: dockerhub-description.md + short-description: A self-hosted homelab dashboard you actually want to look at. + - name: Upload SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3ee083..f89a2923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The Docker Hub repository description is published from `README.md` on each + stable release. + ### Fixed - The Books, Now Playing and Backup widgets now show why they failed, such as a diff --git a/api/test/dockerhub-description.test.js b/api/test/dockerhub-description.test.js new file mode 100644 index 00000000..a516cc0a --- /dev/null +++ b/api/test/dockerhub-description.test.js @@ -0,0 +1,68 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { toDockerHubDescription, MAX_LENGTH } = require('../../scripts/dockerhub-description'); + +const README = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8'); + +/* The Docker Hub page is generated from README.md at release time. Nothing on + that page is checked by a human before it is public, and the failures are + silent ones: a relative link 404s, the screenshot never loads, and text past + the length limit is cut off mid-sentence with a 200 from the API. */ + +test('every link target is absolute', () => { + const out = toDockerHubDescription(README); + for (const [, target] of out.matchAll(/\]\(([^)\s]+)\)/g)) { + assert.ok(/^https:\/\//.test(target), `relative link would 404 on Docker Hub: ${target}`); + } +}); + +test('images are raw URLs, so they render rather than link to a page', () => { + const out = toDockerHubDescription(README); + for (const [, target] of out.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) { + assert.ok( + target.startsWith('https://raw.githubusercontent.com/') || target.startsWith('https://img.shields.io/'), + `image would not render on Docker Hub: ${target}`, + ); + } +}); + +test('the centered HTML header does not survive', () => { + const out = toDockerHubDescription(README); + assert.doesNotMatch(out, /<\/?(?:h1|p|img|sub|b|i|a)\b/i, 'raw HTML is shown literally by Docker Hub'); +}); + +test('pull commands name Docker Hub, and prose still points at ghcr.io', () => { + const out = toDockerHubDescription(README); + for (const [block] of out.matchAll(/```[\s\S]*?```/g)) { + assert.ok( + !block.includes('ghcr.io/sandobserver'), + 'a code sample tells the reader to pull from the other registry', + ); + } + assert.ok(out.includes('`ghcr.io`'), 'the signed registry is no longer mentioned'); + assert.ok(out.includes('sandobserver/stackyard:latest'), 'no Docker Hub pull example'); +}); + +test('the table of contents is dropped, and no section is lost with it', () => { + const out = toDockerHubDescription(README); + assert.ok(!out.includes('## Contents'), 'anchor-only navigation Docker Hub cannot resolve'); + for (const heading of ['## Why Stackyard', '## Getting started', '## License']) { + assert.ok(out.includes(heading), `${heading} was removed with the table of contents`); + } +}); + +test('the result fits inside the Docker Hub length limit', () => { + assert.ok(toDockerHubDescription(README).length <= MAX_LENGTH); +}); + +test('a README over the limit fails the build rather than being truncated', () => { + const huge = README + 'x'.repeat(MAX_LENGTH); + assert.throws(() => toDockerHubDescription(huge), /over Docker Hub's/); +}); + +test('a README with no sections fails rather than publishing the header alone', () => { + assert.throws(() => toDockerHubDescription('# Stackyard\n\nJust a paragraph.\n'), /no `##` heading/); +}); diff --git a/scripts/dockerhub-description.js b/scripts/dockerhub-description.js new file mode 100644 index 00000000..d3f2ba64 --- /dev/null +++ b/scripts/dockerhub-description.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/* Rewrites README.md into the text Docker Hub shows on the repository page. + + Docker Hub renders the file on its own, away from the repo: a relative link + resolves against hub.docker.com and 404s, the screenshot never loads, and the + centered HTML the GitHub header uses is passed through as literal markup. The + pull commands also name the wrong registry for the page they are sitting on. + + Everything above the first `##` is the hand-written HTML header, so it is + replaced wholesale rather than translated; the rest is rewritten by rule and + needs no upkeep when the README changes. */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO = 'SandObserver/stackyard'; +const BRANCH = 'main'; +const BLOB = `https://github.com/${REPO}/blob/${BRANCH}`; +const RAW = `https://raw.githubusercontent.com/${REPO}/${BRANCH}`; +const README_URL = `https://github.com/${REPO}#`; + +/* Docker Hub truncates silently past this, mid-sentence, with no error from the + API. Failing the build is the only way to notice. */ +const MAX_LENGTH = 25000; + +const GHCR_IMAGE = 'ghcr.io/sandobserver/stackyard'; +const HUB_IMAGE = 'sandobserver/stackyard'; + +/* The GitHub header is centered HTML: a title, badges, a screenshot and the + demo link. This is the same content in the markdown subset Docker Hub + renders. */ +const HEADER = `# Stackyard + +**A self-hosted homelab dashboard you actually want to look at.** + +[](https://github.com/${REPO}/pkgs/container/stackyard) +[](https://hub.docker.com/r/${HUB_IMAGE}) +[](https://github.com/${REPO}/releases) + + + +Try it: **[stackyard-demo.onrender.com](https://stackyard-demo.onrender.com)** +The first demo visit may take up to a minute due to Render's free-tier cold start. + +Source, issues and full documentation: **[github.com/${REPO}](https://github.com/${REPO})** +`; + +/** Everything before the first `##` heading, replaced by the markdown header. + @param {string} md @returns {string} */ +function replaceHeader(md) { + const first = md.indexOf('\n## '); + if (first === -1) throw new Error('dockerhub-description: no `##` heading found in README.md'); + return `${HEADER}${md.slice(first)}`; +} + +/** Drops a whole `##