diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 84a9ee6..80ff8df 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,7 +7,7 @@ body: attributes: label: Version description: The image tag you run, or the commit if you build from source. - placeholder: "thodadev/shhh:1.0.1" + placeholder: "ghcr.io/thoda-dev/shhh:1.1.5" validations: required: true diff --git a/README.md b/README.md index 470b76b..47f01f0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ without trusting the server with the contents. ## Deploy -No checkout — the image is published on [Docker Hub](https://hub.docker.com/r/thodadev/shhh). +No checkout — the image is published on [GHCR](https://github.com/thoda-dev/shhh/pkgs/container/shhh) +and mirrored on [Docker Hub](https://hub.docker.com/r/thodadev/shhh). The installer fetches the compose file, generates the database password and the auth secret, writes a `.env` with mode 600, and starts the stack. diff --git a/apps/docs/content/2.self-hosting/1.installation.md b/apps/docs/content/2.self-hosting/1.installation.md index cfb8147..089bea6 100644 --- a/apps/docs/content/2.self-hosting/1.installation.md +++ b/apps/docs/content/2.self-hosting/1.installation.md @@ -13,8 +13,8 @@ navigation: ## Quick start -No checkout needed — the image is published on Docker Hub. The installer fetches the compose file, -generates the secrets, writes a `.env` with mode 600 and starts the stack: +No checkout needed — the image is published, see [Registries](#registries) below. The installer +fetches the compose file, generates the secrets, writes a `.env` with mode 600 and starts the stack: ```bash mkdir shhh && cd shhh @@ -57,6 +57,22 @@ docker compose up -d` with no manual step. Concurrent starts are serialised with advisory lock. :: +### Registries + +Both images are published to two registries, under the same tags: + +| Registry | App | Docs | +| --- | --- | --- | +| GHCR | `ghcr.io/thoda-dev/shhh` | `ghcr.io/thoda-dev/shhh-docs` | +| Docker Hub | `thodadev/shhh` | `thodadev/shhh-docs` | + +The compose file points at GHCR. Docker Hub limits anonymous pulls per IP address, and an ISP using +CGNAT hands the same address to many subscribers, so the limit can be reached by people who are not +you. GHCR applies no such limit to public packages. + +Swapping the `image:` line for the Docker Hub name changes nothing else: a release builds each image +once and pushes the identical manifest to both, so the two never drift. + ## Using an existing PostgreSQL Point `DATABASE_URL` at your server, then remove the bundled database from diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index 1373b1f..cb3e2fd 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -1,4 +1,4 @@ -# Override that builds the image from this checkout instead of pulling it from Docker Hub. +# Override that builds the image from this checkout instead of pulling the published one. # Must run from the repo root — the build context is the whole tree: # docker compose -f docker/docker-compose.yml -f docker/docker-compose.build.yml up -d --build # diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aa8ac66..9e5e0d2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,7 +13,8 @@ name: shhh services: app: # `latest` never points at a pre-release. Swap the tag for a version to stay on it. - image: thodadev/shhh:latest + # Also on Docker Hub as `thodadev/shhh` — the same image, if you prefer that registry. + image: ghcr.io/thoda-dev/shhh:latest restart: unless-stopped ports: - "${PORT:-3000}:3000" diff --git a/scripts/release.mjs b/scripts/release.mjs index 69679dc..a835546 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -21,6 +21,9 @@ import { generateNotes, prependToChangelog } from './changelog.mjs' const IMAGE = 'thodadev/shhh' const DOCS_IMAGE = 'thodadev/shhh-docs' +// Mirrored on GHCR: Docker Hub caps anonymous pulls per IP, and a CGNAT shares that IP between subscribers. +const GHCR_IMAGE = 'ghcr.io/thoda-dev/shhh' +const GHCR_DOCS_IMAGE = 'ghcr.io/thoda-dev/shhh-docs' const PLATFORMS = 'linux/amd64,linux/arm64' const BUILDER = 'shhh-release' const BRANCH = 'master' @@ -139,23 +142,27 @@ if (!has('docker')) { problems.push('docker buildx introuvable') } -// A `credsStore` entry proves nothing — Docker Desktop always writes it. Only the keychain can say whether docker.io is actually in there. -function isLoggedIntoDockerHub() { +// A `credsStore` entry proves nothing — Docker Desktop always writes it. Only the keychain can say which registries are actually in there. +function loggedInRegistries() { let config try { config = JSON.parse(readFileSync(`${process.env.HOME}/.docker/config.json`, 'utf8')) } catch { - return false + return [] } - const registries = config.credsStore + return config.credsStore ? Object.keys(JSON.parse(capture(`docker-credential-${config.credsStore}`, ['list']) || '{}')) : Object.keys(config.auths ?? {}) - return registries.some(r => r.includes('docker.io')) } -if (!isLoggedIntoDockerHub()) { +// Both checked before the build: `--skip-docker` is all or nothing, so a push landing on one registry only cannot be replayed by halves. +const registries = loggedInRegistries() +if (!registries.some(r => r.includes('docker.io'))) { problems.push('pas de session Docker Hub — lance `docker login`') } +if (!registries.some(r => r.includes('ghcr.io'))) { + problems.push('pas de session GHCR — lance `gh auth token | docker login ghcr.io -u --password-stdin` (le jeton a besoin du scope write:packages)') +} const hasGh = has('gh') && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status === 0 if (!hasGh) { @@ -173,15 +180,15 @@ if (problems.length) { // The tag ladder every official image publishes: `:1` keeps receiving 1.x fixes without ever crossing into a breaking 2.0, and `:1.2` narrows that to patches. // A prerelease gets only its exact version, so no moving tag ever resolves to it. const [major, minor] = version.split('.') -const tagsFor = image => isPrerelease - ? [`${image}:${version}`] - : [`${image}:${version}`, `${image}:${major}.${minor}`, `${image}:${major}`, `${image}:latest`] +const tagsFor = (...repos) => repos.flatMap(repo => isPrerelease + ? [`${repo}:${version}`] + : [`${repo}:${version}`, `${repo}:${major}.${minor}`, `${repo}:${major}`, `${repo}:latest`]) // The docs ride the app's version rather than carrying their own: they document that exact release, // so a reader can pin both to the same tag and know they match. const images = [ - { name: 'app', dockerfile: 'docker/Dockerfile', tags: tagsFor(IMAGE) }, - { name: 'docs', dockerfile: 'docker/docs.Dockerfile', tags: tagsFor(DOCS_IMAGE) } + { name: 'app', dockerfile: 'docker/Dockerfile', tags: tagsFor(IMAGE, GHCR_IMAGE) }, + { name: 'docs', dockerfile: 'docker/docs.Dockerfile', tags: tagsFor(DOCS_IMAGE, GHCR_DOCS_IMAGE) } ] const imageTags = images.flatMap(i => i.tags)