Skip to content

feat(docker): publish the container image to GHCR - #130

Open
yoobi wants to merge 4 commits into
hristo2612:mainfrom
yoobi:feat/docker-ghcr-image
Open

feat(docker): publish the container image to GHCR#130
yoobi wants to merge 4 commits into
hristo2612:mainfrom
yoobi:feat/docker-ghcr-image

Conversation

@yoobi

@yoobi yoobi commented Aug 5, 2026

Copy link
Copy Markdown

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 native
better-sqlite3/node-pty compile and a dashboard build on the user's machine before the
first docker compose up finishes. This publishes the image so the container path costs a
curl and a pull:

curl -O https://raw.githubusercontent.com/hristo2612/jinn/main/docker-compose.yml
# uncomment a project mount
docker compose up -d

The image is ghcr.io/<owner>/jinn, built for linux/amd64 and linux/arm64, tagged
latest, 0.29.1, 0.29 and sha-<short> per release.

.github/workflows/publish-image.yml

Triggers on v* tags and workflow_dispatch. Three jobs:

verify — cheap identity checks before ~20 runner-minutes of build. The tag must match
packages/jinn/package.json and be an ancestor of main, borrowing the checks from
publish-npm.yml, so an image tag naming a version is that version. It also resolves the
registry path from github.repository (lowercased, since GHCR rejects uppercase path
segments) rather than a literal — nothing in the workflow names an owner, so a fork
publishes its own ghcr.io/<owner>/jinn with no edit, which is also how this can be
dry-run before it is trusted with a release.

build — a matrix over ubuntu-latest and ubuntu-24.04-arm, one native runner per
architecture 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-timeout
risk. 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 reads
it back from the registry and fails if either architecture is missing. Tagging in the build
legs instead would publish a single-architecture latest twice and let the slower leg win.

Deliberate choices worth a reviewer's attention:

  • No trigger on pushes to main. A double-architecture build is ~20 runner-minutes per
    merge, and latest tracking releases is what "pin a version" wants. Dispatch covers
    rebuilding a tag and dry runs.
  • No cache-to: type=gha. This image would take a large share of the repository's
    10 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 create copies only what the digest itself names — attestations
    would be silently dropped from the published tag while the log claimed they were made.
  • Tags lean on metadata-action's default flavor rather than overriding it. latest=auto
    already withholds latest from a prerelease and collapses {{major}}.{{minor}} into
    {{version}} for one, so a v1.0.0-rc.1 gets its exact version and moves nothing. A
    dispatch from a branch reaches neither semver rule and publishes only sha-<short>.

scripts/docker-smoke.sh

The boot checks that were inline in ci.yml's docker job, extracted so both workflows run
the same ones: native addons load and a PTY spawns, the gateway answers /api/status, an
unauthenticated /api/sessions returns 401, the dashboard is actually served, the
HEALTHCHECK reaches healthy, and a restart over a stale gateway.pid recovers.

CI covers amd64 on every push to main; the publish workflow is the only place arm64 is
ever built. Two copies of these checks would drift apart exactly where nobody looks — and
an arm64 latest that cannot spawn a session is worse than a release that failed loudly.
No check was changed in the move; ci.yml loses 93 lines and gains a one-line run.

Compose

docker-compose.yml now references the published image instead of carrying a build:
section:

image: ${JINN_IMAGE:-ghcr.io/hristo2612/jinn}:${JINN_VERSION:-latest}
pull_policy: missing

Both halves are overridable from a .env file, so pinning a release or pointing at a fork
or 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>/jinn helps nobody. pull_policy: missing makes upgrading
an explicit docker compose pull — with latest, an up -d run for an unrelated reason
would otherwise swap versions underneath a working instance.

Behaviour change for contributors. docker compose up -d --build in a checkout no
longer builds; with no build: section Compose skips it and runs the published image, so a
local edit would silently not be there. Building from a source tree is now:

docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build

The new docker-compose.build.yml adds the build: section and retags the result
jinn:local, so a local build never shadows the published image for anything else on the
machine. It is deliberately not docker-compose.override.yml, which Compose merges
automatically — in a checkout that would make every docker compose up rebuild instead of
pull. .dockerignore excludes it alongside the other compose files.

Docs

  • README's Docker quickstart is curl -O the compose file instead of git clone, and the
    Docker 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 ## Development section still opens with a clone for source
    work.
  • docs/docker.md gains a tag table (what moves, what never does), a Compose-less
    docker run equivalent, a "Building from a checkout" section, and an image-provenance
    note in Notes on the container. Upgrading is now pull then up -d, and the reason
    pull is a separate step is written down.
  • The speech-to-text recipe extends the published image (FROM ghcr.io/…/jinn:0.29.1)
    instead of requiring a local jinn:base build first, so it needs no checkout either. Its
    override now carries context:/image:/pull_policy: build, which the base file's
    build: 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 pull fails with
denied for everyone but the owner, and the workflow gives no hint: it pushes successfully
either way. Packages → jinn → Package settings. This is noted in the workflow header too.

Verification

  • ci.yml's docker job exercises scripts/docker-smoke.sh on this PR, against the same
    image it built before — that is the extraction's regression test.
  • All four compose permutations resolve as intended (docker compose config): default,
    JINN_VERSION/JINN_IMAGE overridden, merged with the build override, and the
    speech-to-text override from the docs.
  • docker-entrypoint.test.ts's assertion that docker-compose.yml carries
    command: ["__jinn_service_start__"] still holds; the line is untouched.
  • The publish workflow itself cannot run until it is on the default branch. A
    workflow_dispatch from a branch is safe to try first: it publishes only sha-<short>
    to ghcr.io/<owner>/jinn and never moves latest.

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.
@yoobi
yoobi force-pushed the feat/docker-ghcr-image branch from 8ab3b0f to 28789b6 Compare August 6, 2026 08:24

@hristo2612 hristo2612 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/jinn does not exist (the Packages API returns 404).
  • npm latest is 0.29.0), and there is no v0.29.1` tag.
  • This PR changes the default Compose path and README quickstart to pull :latest.
  • The workflow only creates latest from a stable semver tag. A branch workflow_dispatch does 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.

yoobi added 2 commits August 6, 2026 21:30
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.
@yoobi yoobi mentioned this pull request Aug 7, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants