feat(docker): publish the container image to GHCR - #130
Conversation
Running Jinn in a container still required cloning the repository and building the image, so a user paid for a pnpm install, a better-sqlite3 and node-pty compile, and a dashboard build before their first `docker compose up` finished. Publish the image instead: fetch the compose file, point it at a project, pull. Build each architecture on a native runner rather than one under QEMU, because this image compiles native addons and emulating that for arm64 turns a ten-minute build into a job-timeout risk. Boot a gateway from each result before pushing, because CI only ever exercises amd64 and a published `latest` that cannot spawn a session is worse than a release that failed loudly. Assemble the tags once, in a merge job, from both digests, because tagging per architecture would publish a single-arch `latest` twice and let the slower leg win. Extract the boot checks to scripts/docker-smoke.sh so CI and the publish workflow ask the same questions instead of drifting apart where nobody looks. Refuse a tag that disagrees with the package version or is not an ancestor of main, mirroring publish-npm.yml, so an image tag naming a version is that version. Name the registry owner in exactly two places, neither of them docs/, which the privacy guard scans: the workflow derives its path from github.repository, and docker-compose.yml carries the matching default of JINN_IMAGE. The guide writes ghcr.io/<owner>/jinn and points at those, so a fork inherits nothing it then has to correct. Move the compose `build:` section to an opt-in docker-compose.build.yml and default `image:` to the published reference, overridable through JINN_IMAGE and JINN_VERSION. Note that this changes what `docker compose up -d --build` does in a checkout: with no build section Compose runs the published image, so contributors need the build file.
8ab3b0f to
28789b6
Compare
hristo2612
left a comment
There was a problem hiding this comment.
I found the build/manifest implementation solid, but I don't think this is safe to merge as one rollout yet.
Blocking: main starts pulling an artifact that does not exist
Right now:
ghcr.io/hristo2612/jinndoes not exist (the Packages API returns 404).- npm latest is
0.29.0), and there is nov0.29.1` tag. - This PR changes the default Compose path and README quickstart to pull
:latest. - The workflow only creates
latestfrom a stable semver tag. A branchworkflow_dispatchdoes not create it. - GitHub makes the first GHCR package private, so even the first successful push is not anonymously pullable until the manual visibility step is completed.
That leaves a real interval after merge where the documented one-file quickstart fails with a missing or denied image. Please make the rollout atomic. The cleanest option is two stages: land the publisher first; publish the release, make the package public, and verify anonymous amd64/arm64 pulls; then switch Compose/README to GHCR. An equivalent guarded bootstrap is fine, but the consumer switch must not precede the usable artifact.
GitHub confirms first-published container packages default to private: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pushing-container-images
Fix the manual-dispatch tag contract
type=ref,event=branch emits the branch tag on workflow_dispatch (for example main) in addition to sha-…. The workflow comment says branch/SHA, but docs/docker.md and the PR description say a manual branch build publishes only the SHA tag.
Either remove type=ref,event=branch if SHA-only is the intended safety contract, or document the mutable branch tag accurately. Official behavior: https://github.com/docker/metadata-action#type-ref
Do not call the version/SHA tags immutable
The guide says 0.29.1 “never” moves and calls sha-<short> immutable, but rerunning or manually dispatching the workflow on the same ref overwrites those tags. The Dockerfile also uses a tagged base image and unpinned apt inputs, so a rebuild of the same source commit can produce a different digest.
Please describe these as source/version aliases and tell users to pin the image digest for byte-for-byte immutability, or enforce non-overwrite plus reproducible pinned inputs.
What passed
- All five PR checks are green, including Docker and Windows.
actionlint,shellcheck,bash -n, and diff/privacy checks pass.- The extracted smoke script preserves the old CI checks.
- The two-architecture digest/manifest assembly is correctly fail-closed.
Once the rollout order and tag contract are corrected, the implementation otherwise looks mergeable.
Review of hristo2612#130 caught that the same change published the image and switched the documented default to pulling it, which leaves an interval after merge where the quick start fetches an artifact that does not exist: there is no v0.29.1 tag yet, and GitHub creates a first container package as private, so even a successful first push is not anonymously pullable until someone flips the visibility by hand. So this keeps the publisher and drops the consumer switch. docker-compose.yml keeps its build: section and the README keeps the clone quick start; pointing them at ghcr.io is a follow-up, gated on a v* tag having run this workflow, the package being public, and an anonymous pull of both architectures working. The workflow header carries that gate so it is not only in a PR description. Also drops `type=ref,event=branch` from the tag list. metadata-action emits the branch name on a workflow_dispatch, so a dry run from main would have published a mutable `main` tag that reads like a release channel and that nothing documents — while the comment beside it claimed a branch build publishes only `sha-<short>`. SHA-only is now what the code does. And stops calling any published tag immutable. Re-running on the same ref repoints every tag including `<version>` and `sha-<short>`, and the image resolves node:24-bookworm-slim and its apt inputs by tag, so one commit can rebuild to a different digest. The guide describes them as aliases and points at the digest for a fixed artifact; the merge job reads that digest back and prints the pinnable reference in the run summary.
Reaching the dashboard from a laptop or a phone meant editing the tracked
compose file to name a host IP, which is not what a compose file people copy is
supposed to ask of them. Every comparable image publishes `7777:7777`.
`${JINN_BIND_ADDR:-0.0.0.0}` makes that the default and keeps the old behaviour
one .env line away. In a container the gateway already binds 0.0.0.0 and counts
itself network-exposed, so it demands auth and each browser pairs once — what a
stranger on the network finds is a pairing prompt, not a dashboard. The guide
moves the exposure from Covered to Not covered rather than claiming loopback.
The cold-start retry test ran a fixed number of fake-timer advances, but the transcript tailer it waits on does three real fs calls per poll (stat, open, read) and each advance yields one real macrotask turn. When those turns ran out mid-chain the 200ms poll never fired again, so the turn hung until vitest's 30s timeout instead of failing — which is what windows-latest hit on this PR. Step until the turn settles, bounded, and throw with the elapsed virtual time if it never does. Unrelated to the Docker changes; it is the CI failure in front of them.
Publish the container image to GHCR
Follow-up to #127. That PR made Jinn runnable in a container, but running it still meant
cloning the repository and building the image yourself — a full
pnpm install, a nativebetter-sqlite3/node-ptycompile and a dashboard build on the user's machine before thefirst
docker compose upfinishes. This publishes the image so the container path costs acurland a pull:curl -O https://raw.githubusercontent.com/hristo2612/jinn/main/docker-compose.yml # uncomment a project mount docker compose up -dThe image is
ghcr.io/<owner>/jinn, built for linux/amd64 and linux/arm64, taggedlatest,0.29.1,0.29andsha-<short>per release..github/workflows/publish-image.ymlTriggers on
v*tags andworkflow_dispatch. Three jobs:verify— cheap identity checks before ~20 runner-minutes of build. The tag must matchpackages/jinn/package.jsonand be an ancestor ofmain, borrowing the checks frompublish-npm.yml, so an image tag naming a version is that version. It also resolves theregistry path from
github.repository(lowercased, since GHCR rejects uppercase pathsegments) rather than a literal — nothing in the workflow names an owner, so a fork
publishes its own
ghcr.io/<owner>/jinnwith no edit, which is also how this can bedry-run before it is trusted with a release.
build— a matrix overubuntu-latestandubuntu-24.04-arm, one native runner perarchitecture rather than QEMU on one. This image compiles two native addons and runs a
full
pnpm build; emulating that for arm64 turns a ~10 minute build into a job-timeoutrisk. Each leg builds into the local daemon, boots a gateway from the result, and only
then pushes — by digest, untagged.
merge— assembles one manifest list carrying the tags from both digests, then readsit back from the registry and fails if either architecture is missing. Tagging in the build
legs instead would publish a single-architecture
latesttwice and let the slower leg win.Deliberate choices worth a reviewer's attention:
main. A double-architecture build is ~20 runner-minutes permerge, and
latesttracking releases is what "pin a version" wants. Dispatch coversrebuilding a tag and dry runs.
cache-to: type=gha. This image would take a large share of the repository's10 GB Actions cache and evict the pnpm caches every PR depends on — to spare a cold build
that happens once per release. Within a job the second build still hits the Buildx
builder's own cache, so the push is not a rebuild.
provenance: false. Provenance arrives as a separate manifest hanging off the digest,and
buildx imagetools createcopies only what the digest itself names — attestationswould be silently dropped from the published tag while the log claimed they were made.
latest=autoalready withholds
latestfrom a prerelease and collapses{{major}}.{{minor}}into{{version}}for one, so av1.0.0-rc.1gets its exact version and moves nothing. Adispatch from a branch reaches neither semver rule and publishes only
sha-<short>.scripts/docker-smoke.shThe boot checks that were inline in
ci.yml's docker job, extracted so both workflows runthe same ones: native addons load and a PTY spawns, the gateway answers
/api/status, anunauthenticated
/api/sessionsreturns 401, the dashboard is actually served, theHEALTHCHECK reaches
healthy, and a restart over a stalegateway.pidrecovers.CI covers amd64 on every push to
main; the publish workflow is the only place arm64 isever built. Two copies of these checks would drift apart exactly where nobody looks — and
an arm64
latestthat cannot spawn a session is worse than a release that failed loudly.No check was changed in the move;
ci.ymlloses 93 lines and gains a one-linerun.Compose
docker-compose.ymlnow references the published image instead of carrying abuild:section:
Both halves are overridable from a
.envfile, so pinning a release or pointing at a forkor internal mirror needs no edit to a tracked file. This is the only place an owner is
load-bearing — Compose needs a resolvable name — and it is a default, not a fixed value.
The workflow derives its own path, and the README and guide name the published image because
a doc that says
ghcr.io/<owner>/jinnhelps nobody.pull_policy: missingmakes upgradingan explicit
docker compose pull— withlatest, anup -drun for an unrelated reasonwould otherwise swap versions underneath a working instance.
Behaviour change for contributors.
docker compose up -d --buildin a checkout nolonger builds; with no
build:section Compose skips it and runs the published image, so alocal edit would silently not be there. Building from a source tree is now:
The new
docker-compose.build.ymladds thebuild:section and retags the resultjinn:local, so a local build never shadows the published image for anything else on themachine. It is deliberately not
docker-compose.override.yml, which Compose mergesautomatically — in a checkout that would make every
docker compose uprebuild instead ofpull.
.dockerignoreexcludes it alongside the other compose files.Docs
curl -Othe compose file instead ofgit clone, and theDocker badge links to the package. It shows the pull path and only the pull path — that is
what nearly every reader is here for. Building the image is one line in the guide it
already points at, and the
## Developmentsection still opens with a clone for sourcework.
docs/docker.mdgains a tag table (what moves, what never does), a Compose-lessdocker runequivalent, a "Building from a checkout" section, and an image-provenancenote in Notes on the container. Upgrading is now
pullthenup -d, and the reasonpullis a separate step is written down.FROM ghcr.io/…/jinn:0.29.1)instead of requiring a local
jinn:basebuild first, so it needs no checkout either. Itsoverride now carries
context:/image:/pull_policy: build, which the base file'sbuild:section used to supply.One manual step, once
The first push creates the GHCR package as private. Until it is set to public — or
linked to this repository so it inherits repository visibility —
docker pullfails withdeniedfor everyone but the owner, and the workflow gives no hint: it pushes successfullyeither way. Packages → jinn → Package settings. This is noted in the workflow header too.
Verification
ci.yml's docker job exercisesscripts/docker-smoke.shon this PR, against the sameimage it built before — that is the extraction's regression test.
docker compose config): default,JINN_VERSION/JINN_IMAGEoverridden, merged with the build override, and thespeech-to-text override from the docs.
docker-entrypoint.test.ts's assertion thatdocker-compose.ymlcarriescommand: ["__jinn_service_start__"]still holds; the line is untouched.workflow_dispatchfrom a branch is safe to try first: it publishes onlysha-<short>to
ghcr.io/<owner>/jinnand never moveslatest.