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
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions api/test/dockerhub-description.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
114 changes: 114 additions & 0 deletions scripts/dockerhub-description.js
Original file line number Diff line number Diff line change
@@ -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.**

[![ghcr.io](https://img.shields.io/badge/ghcr.io-stackyard-2496ED?logo=github&logoColor=white)](https://github.com/${REPO}/pkgs/container/stackyard)
[![Docker Hub](https://img.shields.io/badge/docker%20hub-stackyard-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/${HUB_IMAGE})
[![Latest release](https://img.shields.io/github/v/release/${REPO})](https://github.com/${REPO}/releases)

![Stackyard dashboard](${RAW}/docs/screenshot.png)

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 `## <title>` section, heading included. Used for the table of
contents, which is navigation for a long GitHub page and whose anchors
Docker Hub does not generate.

@param {string} md @param {string} title @returns {string} */
function dropSection(md, title) {
const start = md.indexOf(`\n## ${title}\n`);
if (start === -1) return md;
const next = md.indexOf('\n## ', start + 1);
return md.slice(0, start) + (next === -1 ? '\n' : md.slice(next));
}

/** Rewrites every markdown link and image target that Docker Hub cannot
resolve: a repo-relative path becomes a github.com URL (raw for images, so
they render rather than link), and a bare `#anchor` becomes the same anchor
on the GitHub README.

@param {string} md @returns {string} */
function absolutiseLinks(md) {
return md.replace(/(!?)\[([^\]]*)\]\(([^)\s]+)\)/g, (whole, bang, text, target) => {
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) return whole;
if (target.startsWith('#')) return `${bang}[${text}](${README_URL}${target.slice(1)})`;
const [file, fragment] = target.split('#');
const base = bang ? RAW : BLOB;
return `${bang}[${text}](${base}/${file}${fragment ? `#${fragment}` : ''})`;
});
}

/** Inside fenced code blocks only, names the registry the reader is already on.
Prose keeps saying ghcr.io, which is where the release signature points.

@param {string} md @returns {string} */
function useHubImage(md) {
return md.replace(/```[\s\S]*?```/g, block => block.split(GHCR_IMAGE).join(HUB_IMAGE));
}

/** @param {string} readme @returns {string} */
function toDockerHubDescription(readme) {
let md = replaceHeader(readme);
md = dropSection(md, 'Contents');
md = absolutiseLinks(md);
md = useHubImage(md);
md = `${md.trimEnd()}\n`;
if (md.length > MAX_LENGTH) {
throw new Error(
`dockerhub-description: ${md.length} characters, over Docker Hub's ${MAX_LENGTH} limit; shorten README.md`,
);
}
return md;
}

module.exports = { toDockerHubDescription, MAX_LENGTH };

/* CLI: README.md in, description on stdout, for the workflow to redirect to a
file the description action reads. */
if (require.main === module) {
const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
process.stdout.write(toDockerHubDescription(readme));
}
Loading