Skip to content

feat(ci): publish multi-arch Docker images to GHCR - #65

Open
camden-bock wants to merge 12 commits into
PhilflowIO:mainfrom
camden-bock:feat/workflows-ghcr-docker
Open

feat(ci): publish multi-arch Docker images to GHCR#65
camden-bock wants to merge 12 commits into
PhilflowIO:mainfrom
camden-bock:feat/workflows-ghcr-docker

Conversation

@camden-bock

@camden-bock camden-bock commented Aug 16, 2026

Copy link
Copy Markdown

Publish multi-arch images to GHCR and clean up the compose/CI setup.

Changes / maintenance surface

  • .github/workflows/docker-publish.yml — triggers: release published, push to main, pull_request, manual dispatch
  • GHCR package: ghcr.io/philflowio/dav-mcp with tags latest, vX.Y.Z (semver), sha-…
  • Multi-arch builds: linux/amd64 + linux/arm64 (QEMU + Buildx)
  • Concurrency guard prevents overlapping publish runs
  • PR-triggered build job (no push, no GHCR login) guards Dockerfile changes
  • .github/dependabot.yml — npm, docker, github-actions (weekly); docker ecosystem tracks node:22-alpine only (distroless tag is non-semver)
  • compose.yaml — renamed from docker-compose.yml, dropped the obsolete version: key
  • Compose image: + build: semantics — docker compose up builds locally; docker compose pull uses the prebuilt image
  • No compose healthcheck override — image HEALTHCHECK is authoritative
  • .dockerignore — build context excludes tests/docs/dev files
  • README Docker section documents pull-vs-build

Test / validation

  • podman compose config parses clean
  • Workflow + dependabot YAML valid (js-yaml parse)
  • podman compose up builds and starts against a live Radicale backend
  • Image-level healthcheck reports healthy with no compose override (verified on both alpine and distroless bases)
  • Final image /app contains only runtime files (src, node_modules, package.json)
  • CI build job runs on PRs without attempting GHCR login/push

Footnotes

  • Service/container names (tsdav-mcp / tsdav-mcp-server) are pre-existing; left untouched.
  • Podman-compose 1.6.0 builds images in OCI format (drops HEALTHCHECK) and doesn't surface health status without a compose override — a local tooling quirk only; Docker Compose v2 and GHCR builds (docker format) inherit the image healthcheck.

Commits

  • feat(ci): publish multi-arch Docker images to GHCR
  • chore(compose): rename docker-compose.yml to compose.yaml
  • refactor(compose): drop the obsolete version key
  • feat(compose): add GHCR image reference alongside local build
  • docs(readme): use docker compose v2 syntax
  • feat(ci): add Dependabot config for npm, docker, and github-actions
  • refactor(compose): rely on the image healthcheck instead of overriding it
  • docs(compose): document GHCR image pull vs local build
  • ci(workflow): prevent concurrent docker publish runs
  • ci(workflow): tag images with v-prefixed semver
  • ci(workflow): build image on pull requests
  • chore(dockerignore): exclude dev/test files from build context

Add a workflow that builds and pushes linux/amd64 and linux/arm64
images to ghcr.io/philflowio/dav-mcp on release and on push to main,
using the built-in GITHUB_TOKEN (no PAT required). Multi-arch matters
here since this is a self-hosted service commonly run on Raspberry Pi
and other ARM boxes.
Follow the Compose Spec's canonical filename ahead of the modernization
changes in the following commits.
The top-level version field is a no-op under the Compose Spec and
Compose v2 prints a deprecation warning if it's left in.
Lets `docker compose pull` grab the published GHCR image directly
without needing the Dockerfile, while `build: .` still works for
local development.
Replace the legacy hyphenated docker-compose invocation with the
current v2 subcommand form.
Covers the registry-sourced half of package.json, base image bumps
for the new distroless Dockerfile, and the growing set of third-party
actions the GHCR workflow depends on.

Known gap: tsdav and tsdav-utils are installed via the github:
protocol pinned to a branch ref, not a registry version, so
Dependabot's npm support won't generate update PRs for them -- no
version to diff against. The existing `npm run update:tsdav` script
already covers updating them manually.
@camden-bock
camden-bock marked this pull request as ready for review August 16, 2026 18:49
@PhilflowIO

Copy link
Copy Markdown
Owner

Reviewed alongside #64. The security substance here is good and I want to be explicit about that, because two of the scarier-sounding concerns turned out to be non-issues when I actually chased them down:

  • Fork PRs cannot poison the main build cache. Caches created by a pull_request run are scoped to the merge ref and can't be restored by the base branch. The cache-to in the PR job is not a path into the publish job.
  • packages: write is not granted to fork-triggered runs. Write permissions are downgraded to read-only for pull_request events from forks, and secrets other than GITHUB_TOKEN aren't passed at all.

Beyond that: no pull_request_target, no push from PR context, .env and .git excluded from the build context, no credentials anywhere in the repo, and provenance attestations are added automatically for public repos. Good baseline.

Five things need fixing before this can go in.

1. The PR build job cannot succeed

.github/workflows/docker-publish.yml:78 uses ghcr.io/${{ github.repository }}:pr-check, which resolves to ghcr.io/PhilflowIO/dav-mcp. Reproduced locally:

ERROR: failed to build: invalid tag "ghcr.io/PhilflowIO/dav-mcp:pr-check":
repository name must be lowercase

The publish job escapes this only because docker/metadata-action lowercases the image name for you; the raw tag here doesn't. Since this job never pushes, a plain local tag like dav-mcp:pr-check is enough.

2. latest is untested main HEAD — and never points at a release

There's no coupling between this workflow and test.yml; both fire on the same push, so the image can be published while tests are red. And because of enable={{is_default_branch}} (:45), a release: published run never tags latest at all, since github.ref is a tag ref there. So latest structurally means "tip of main", while the README and compose.yaml:6 point third parties straight at it. Gating latest on the release event fixes both halves at once.

3. image: + build: without pull_policy inverts the documented behaviour

The comment in compose.yaml:3-5 says docker compose up builds from source when the image is absent. The Compose spec says the opposite — pulling is the default, and latest is always pulled even under the missing policy. Today this is invisible because the package doesn't exist yet and Compose falls back to building; it breaks the moment you publish. A user who just followed the clone instructions would silently stop running their own checkout. pull_policy: build makes the comment true.

Worth flagging that this is exactly the class of thing podman-compose 1.6.0 wouldn't have surfaced, which is what the validation ran against.

4. docker compose pull won't work for anyone but the owner

A newly published GHCR package defaults to private visibility. The README block advertising docker compose pull && docker compose up needs the one-time manual switch to public documented alongside it, otherwise the first person to follow the README hits a 401.

5. Supply-chain hardening for the job that holds credentials

Two requests, both scoped to the docker job only — I'm not asking for this repo-wide:

  • Pin the actions by commit SHA. actions/checkout@v4, docker/*@v3/v5/v6 are mutable pointers to third-party code running in the one job with packages: write. tj-actions/changed-files (CVE-2025-30066) is the precedent: all tags repointed at a malicious commit, ~218 repos leaking real secrets. I know this costs Dependabot alerts on SHA-pinned actions; for the publishing job I'll take that trade.
  • Add a vulnerability scan before the push, e.g. Trivy with exit-code: 1 on HIGH/CRITICAL. Right now there is nothing between checkout and push: true. Related: on this branch Dockerfile:1 is still node:18-alpine, EOL since April 2025 — the description's claim that the docker ecosystem tracks node:22-alpine describes the state after feat(docker): distroless nonroot runtime image #64 lands, not this branch.

Smaller items, take or leave: permissions is workflow-level, so the build job carries packages: write it never uses (harmless for forks, real for same-repo branch PRs — job-level scoping would be cleaner). concurrency: group: docker-publish has no ref in the group name, so a queued release run can be displaced by a later PR run, since at most one run stays pending per group. And dependabot.yml has no open-pull-requests-limit or groups — three ecosystems at default settings is up to 15 open PRs, which tends to end with the updates being ignored.

Ordering

These two PRs both branch off main, but they're effectively coupled: #64 alone leaves the compose healthcheck broken, and #65 alone would publish latest built from the old single-stage node:18 Dockerfile under the project's own tag. #64 should land first.

Also: CI has never run on either branch (fork approval pending), so linux/arm64 has never actually been built. tsdav's prepare hook (rollup + husky) under QEMU emulation is where I'd expect that to break first — worth confirming with a real run before we merge.

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