diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac27075..b2e14ab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,18 +39,24 @@ jobs:
if: github.ref == 'refs/heads/master'
needs: [ci, e2e]
runs-on: ubuntu-latest
+ # packages: write publishes the public image to GHCR via the built-in
+ # GITHUB_TOKEN; Scaleway then pulls it anonymously (docs/ops.md). New GHCR
+ # packages default to private — flip gitmote to public once by hand.
+ permissions:
+ contents: read
+ packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- - name: Login to Scaleway Container Registry
+ - name: Login to GHCR
uses: docker/login-action@v3
with:
- registry: ${{ secrets.SCW_REGISTRY_ENDPOINT }}
- username: nologin
- password: ${{ secrets.SCW_SECRET_KEY }}
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build & push (amd64 — Scaleway requires it)
+ - name: Build & push public image (amd64 — Scaleway requires it)
uses: docker/build-push-action@v6
with:
context: .
@@ -59,8 +65,8 @@ jobs:
build-args: |
VERSION=${{ github.sha }}
tags: |
- ${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:master
- ${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:${{ github.sha }}
+ ghcr.io/atmin/gitmote:master
+ ghcr.io/atmin/gitmote:${{ github.sha }}
- name: Install Scaleway CLI
run: |
@@ -87,5 +93,5 @@ jobs:
SCW_DEFAULT_REGION: fr-par
run: |
scw container container update ${{ secrets.SCW_CONTAINER_ID }} \
- image=${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:${{ github.sha }} \
+ image=ghcr.io/atmin/gitmote:${{ github.sha }} \
min-scale=0 max-scale=1 --wait
diff --git a/.github/workflows/publish-runner.yml b/.github/workflows/publish-runner.yml
new file mode 100644
index 0000000..825df7b
--- /dev/null
+++ b/.github/workflows/publish-runner.yml
@@ -0,0 +1,49 @@
+name: publish-runner
+
+# The CI runner image (Dockerfile.runner) published publicly to GHCR, replacing
+# the by-hand push (docs/architecture/ci.md, docs/ops.md). Rebuilds when the
+# runner binary or its Dockerfile changes on master, on a release, or on demand.
+# New GHCR packages default to private — flip gitmote-runner to public once by
+# hand so Scaleway Serverless Jobs can pull it anonymously (the Job definition
+# references ghcr.io/atmin/gitmote-runner).
+on:
+ push:
+ branches: [master]
+ paths:
+ - Dockerfile.runner
+ - cmd/gitmote-runner/**
+ - internal/runner/**
+ - go.mod
+ - go.sum
+ - .github/workflows/publish-runner.yml
+ release:
+ types: [published]
+ workflow_dispatch:
+
+jobs:
+ runner:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build & push runner (amd64 — Scaleway Serverless Jobs require it)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.runner
+ platforms: linux/amd64
+ push: true
+ tags: |
+ ghcr.io/atmin/gitmote-runner:master
+ ghcr.io/atmin/gitmote-runner:${{ github.sha }}
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index 3d4054d..223d2d8 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -118,7 +118,11 @@ branch (`checkout -B`) so `github.ref` resolves.
rebuilding gitmote's own image in-Job) was evaluated and **dropped** — it runs
straight into the limitation above. Deployment stays on **GitHub Actions**
([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)): build+push the
- image, `scw container update`; the leased writer keeps the swap safe (see
- [ops.md](../ops.md)). The GitHub mirror is deployer and break-glass both.
-- **The runner image is pushed by hand** (`gitmote-runner:master`); nothing in CI
- rebuilds it.
+ public image to GHCR (`ghcr.io/atmin/gitmote`), then `scw container update` to
+ pull it; the leased writer keeps the swap safe (see [ops.md](../ops.md)). The
+ GitHub mirror is deployer and break-glass both.
+- **The runner image is built by CI**, not by hand: a change to
+ [`Dockerfile.runner`](../../Dockerfile.runner) or the runner source on `master`
+ (or a release) publishes `ghcr.io/atmin/gitmote-runner` publicly via
+ [`.github/workflows/publish-runner.yml`](../../.github/workflows/publish-runner.yml).
+ The Scaleway Job definition references that GHCR image and pulls it anonymously.
diff --git a/docs/ops.md b/docs/ops.md
index f02c834..3718026 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -12,7 +12,7 @@ gitmote runs on the same account and host as atmin.net (see that repo's
|-----------|----------|---------|
| Compute | Scaleway | Serverless Containers (`fr-par`, custom domain via CNAME, auto TLS) |
| Object storage | Scaleway | Object Storage (S3-compatible), bucket `gitmote` |
-| Registry | Scaleway | Container Registry (`rg.fr-par.scw.cloud/atmin`, shared with atmin.net) |
+| Registry | GitHub | GHCR — **public** `ghcr.io/atmin/gitmote` (server) + `ghcr.io/atmin/gitmote-runner` (CI). Scaleway pulls both anonymously |
Reachable at **`gitmote.atmin.net`** (CNAME → the container endpoint). EU-resident
infrastructure, consistent with atmin.net's stance.
@@ -86,7 +86,7 @@ Setting these on the container does nothing for CI, and vice versa.
| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
| `GITMOTE_URL` | public base URL (`https://gitmote.atmin.net`) — injected into the CI runner's env so it clones and reports back here |
| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition for the CI runner. **Set → cloud trigger** (Scaleway job per CI job) |
-| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job; also the registry/deploy key |
+| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job (same key also deploys the container from CI) |
| `SCW_REGION` | Scaleway region for the CI job start (falls back to `AWS_REGION`) |
| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back |
| `GITMOTE_CI_SECRET_KEY_V1` | secret — base64 of 32 bytes; master key for per-repo CI secrets. Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is disabled and none are injected |
@@ -132,19 +132,19 @@ Separate from the container env above: these are the Scaleway API credentials th
deploy pipeline uses, set in the **GitHub repo** → Settings → Secrets and variables
→ **Actions** → **Repository secrets**. A missing one doesn't error at reference
time — GitHub substitutes an empty string, so a wrong name surfaces later as e.g.
-`docker login` → *"Password required"*.
+`scw … api_key not_found`.
-A Scaleway API key has two halves — don't swap them: the **access key** is
+The image push itself needs **no secret**: publishing to GHCR uses the built-in
+`GITHUB_TOKEN` with the workflow's `packages: write` permission. The Scaleway
+secrets below are only for the `scw container update` deploy (and starting CI
+jobs). A Scaleway API key has two halves — don't swap them: the **access key** is
`SCWXXXXXXXXXXXXXXXXX` (starts with `SCW`), the **secret key** is a UUID
-(`xxxxxxxx-xxxx-…`). The registry password is the **secret key**. Both must come
-from the same Organization/Project as the `atmin` registry namespace, or registry
-login fails with `api_key … not_found`.
+(`xxxxxxxx-xxxx-…`); both must come from the gitmote Organization/Project.
| Secret | Value |
|--------|-------|
-| `SCW_SECRET_KEY` | Scaleway API **secret key** — the UUID, not the `SCW…` access key (registry login + deploy) |
+| `SCW_SECRET_KEY` | Scaleway API **secret key** — the UUID, not the `SCW…` access key (deploy + start CI job) |
| `SCW_ACCESS_KEY` | Scaleway **access key** (`SCW…`) |
-| `SCW_REGISTRY_ENDPOINT` | `rg.fr-par.scw.cloud/atmin` |
| `SCW_ORGANIZATION_ID` | Scaleway organization ID |
| `SCW_PROJECT_ID` | Scaleway project ID |
| `SCW_CONTAINER_ID` | the gitmote Serverless Container ID |
@@ -160,11 +160,17 @@ Environment secrets); the name matches exactly (case-sensitive); and, if kept as
# 1. Object Storage bucket (console or CLI), fr-par, name "gitmote".
# Generate an API key pair for it.
-# 2. Push the initial image (registry namespace "atmin" is shared with atmin.net;
-# Scaleway requires amd64 — build with --platform on ARM Macs).
-docker login rg.fr-par.scw.cloud/atmin -u nologin -p <SCW_SECRET_KEY>
-docker build --platform=linux/amd64 -t rg.fr-par.scw.cloud/atmin/gitmote:master .
-docker push rg.fr-par.scw.cloud/atmin/gitmote:master
+# 2. Publish the initial images to GHCR, then flip both packages to PUBLIC once by
+# hand (GitHub → your profile/org → Packages → gitmote / gitmote-runner →
+# Package settings → Change visibility → Public). New GHCR packages default to
+# private; until public, Scaleway's anonymous pull fails with an error the code
+# can't diagnose. CI republishes on every push (server) and on runner changes,
+# so this manual build is only the very first seed. Scaleway requires amd64.
+echo "<GITHUB_PAT with write:packages>" | docker login ghcr.io -u atmin --password-stdin
+docker build --platform=linux/amd64 -t ghcr.io/atmin/gitmote:master .
+docker push ghcr.io/atmin/gitmote:master
+docker build --platform=linux/amd64 -f Dockerfile.runner -t ghcr.io/atmin/gitmote-runner:master .
+docker push ghcr.io/atmin/gitmote-runner:master
# 3. Create the container (single writer: min-scale=0 idle-to-zero, max-scale=1).
# Arg names per the current CLI: image= (not registry-image), cpu-limit (mVCPU),
@@ -175,7 +181,7 @@ docker push rg.fr-par.scw.cloud/atmin/gitmote:master
scw container container create \
namespace-id=<NAMESPACE_ID> \
name=gitmote \
- image=rg.fr-par.scw.cloud/atmin/gitmote:master \
+ image=ghcr.io/atmin/gitmote:master \
min-scale=0 max-scale=1 \
cpu-limit=250 \
memory-limit-bytes=512MB \
@@ -199,7 +205,17 @@ scw container domain list container-id=<CONTAINER_ID> # status pending → r
# Note: gitmote has no route at "/", so http(s)://gitmote.atmin.net/ returns 404
# by design; check /healthz for liveness. Git lives under /<owner>/<repo>/….
-# 5. Set the GitHub Actions repository secrets for the deploy pipeline — these are
+# 5. CI runner Serverless Job — point its definition at the public GHCR runner
+# image (one image serves all repos; per-job env is injected at trigger). Set
+# the resulting definition ID as the container's SCW_CI_JOB_DEFINITION_ID to
+# turn on the cloud CI trigger. Update the image the same way after a runner
+# change (CI republishes the tag; the Job pulls it on next start):
+scw jobs definition create name=gitmote-runner \
+ image-uri=ghcr.io/atmin/gitmote-runner:master \
+ cpu-limit=1000 memory-limit=2048
+scw jobs definition update <JOB_DEFINITION_ID> image-uri=ghcr.io/atmin/gitmote-runner:master
+
+# 6. Set the GitHub Actions repository secrets for the deploy pipeline — these are
# the CI credentials, NOT container env vars. See "CI secrets (GitHub Actions)".
```
@@ -247,10 +263,15 @@ token's hash is ever stored — the raw token is never recoverable, hence `-reis
Automatic on every green push to `master` (`.github/workflows/ci.yml`, `deploy`
job): `ci` (lint/test/build) → `e2e` (local push/clone + litestream restore) →
-build+push the amd64 image → `scw container container update … min-scale=0
-max-scale=1 --wait`. No drain step: the writer lease makes the rolling deploy
-safe by construction (new boots as a follower, old releases on SIGTERM, new
-promotes — see the single-writer section).
+build+push the public amd64 image to `ghcr.io/atmin/gitmote:{master,<sha>}` →
+`scw container container update image=ghcr.io/atmin/gitmote:<sha> … min-scale=0
+max-scale=1 --wait` (Scaleway pulls the public image). No drain step: the writer
+lease makes the rolling deploy safe by construction (new boots as a follower, old
+releases on SIGTERM, new promotes — see the single-writer section).
+
+The **runner** image publishes separately on runner/Dockerfile.runner changes (or
+a release) via `.github/workflows/publish-runner.yml` →
+`ghcr.io/atmin/gitmote-runner`; the CI Job definition pulls it on next start.
```bash
git push origin master
@@ -274,8 +295,13 @@ same path is exercised locally by `make e2e-restore`.
```bash
scw container container get <CONTAINER_ID> # status, error messages, instance count
scw container container redeploy <CONTAINER_ID> # re-pull the current image tag
-scw registry image list # images in the registry
+# Images live in GHCR now — inspect/pull them there:
+docker manifest inspect ghcr.io/atmin/gitmote:master # server image
+docker manifest inspect ghcr.io/atmin/gitmote-runner:master # CI runner image
```
+If a deploy fails on an image pull, the most likely cause is a **private** GHCR
+package: flip `gitmote` / `gitmote-runner` to public (see one-time setup step 2).
+
Logs flow to Scaleway Cockpit (console → Observability → Cockpit → Grafana →
Explore → Loki). gitmote logs JSON to stderr.
diff --git a/tasks/README.md b/tasks/README.md
index 1a82116..51e340c 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -16,11 +16,8 @@ has landed. What remains is the image chain plus a docs cap — order within the
chain matters:
**Chain B — the image**
-- [public-registry.md](public-registry.md) — publish `ghcr.io/atmin/gitmote` +
- `…/gitmote-runner` publicly; deploy from GHCR; kill the by-hand runner push.
- [dogfood-make.md](dogfood-make.md) — `make image` / `make prod` / `make publish`;
- `make prod` runs the real image against dev MinIO. *(needs -registry for the
- tags.)*
+ `make prod` runs the real image against dev MinIO.
**Cap**
- [operate-docs.md](operate-docs.md) — `docker run` quickstart (incl. "grab the
diff --git a/tasks/public-registry.md b/tasks/public-registry.md
deleted file mode 100644
index fd041f3..0000000
--- a/tasks/public-registry.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Public GHCR images; deploy from public
-
-Part of [easy to operate](README.md). Make the image runnable "from the
-internet" and stop hand-pushing the runner. Independent of the env-shrink tasks
-(can proceed in parallel).
-
-## Spec
-
-`gitmote` and `gitmote-runner` are published **publicly** to GHCR
-(`ghcr.io/atmin/gitmote`, `…/gitmote-runner`). `docker run ghcr.io/atmin/gitmote`
-works anywhere. Scaleway deploys by pulling the public image (its Serverless
-Container + the CI Job definition both reference GHCR). This is also the pragmatic
-self-deploy path: build where a real daemon exists (GitHub Actions, or locally),
-push public, and "deploy" is a retag + `scw container update` — no in-Job build
-(the sandbox can't).
-
-## Current
-
-`.github/workflows/ci.yml` builds `rg.fr-par.scw.cloud/atmin/gitmote` (private
-Scaleway registry) and `scw container update`s it. The **runner** image is built
-and pushed **by hand** ([ci.md](../docs/architecture/ci.md)); the Job definition
-points at `rg.fr-par.scw.cloud/atmin/gitmote-runner:master`.
-
-## Change
-
-- Deploy job: build + push `ghcr.io/atmin/gitmote:{sha,master}` (public), point
- `scw container update image=ghcr.io/atmin/gitmote:<sha>`.
-- Add a **runner image** build+push to CI (on `Dockerfile.runner` change / on
- release), publishing `ghcr.io/atmin/gitmote-runner` — removes the manual push.
-- Update the `scw jobs definition` image to the GHCR runner path (one-time, doc
- in ops.md).
-
-## Verify
-
-- A bare `docker run --rm -e GITMOTE_S3_BUCKET=… -e AWS_… ghcr.io/atmin/gitmote`
- boots and serves `/healthz`.
-- A push to master builds + pushes GHCR and Scaleway redeploys from it (container
- image SHA flips to the GHCR ref).
-- A cloud CI run pulls the GHCR runner image and reports green.
-
-## Gotcha
-
-New GHCR packages default to **private** — the package must be flipped to
-**public** once by hand (GitHub → package settings) before Scaleway can pull it
-anonymously. Otherwise the first deploy fails on an image pull the code can't
-diagnose. Publishing from Actions needs `packages: write` (built-in
-`GITHUB_TOKEN`).