Skip to content

Commit b771d98

Browse files
authored
ci: publish Scrye image to Docker Hub (tagged releases + dev continuo… (#19)
* ci: publish Scrye image to Docker Hub (tagged releases + dev continuous build) (#18) * ci: publish scrye image to Docker Hub on tagged releases and dev pushes Add .github/workflows/publish.yml with two independent publishing paths: - semver tags (v*.*.*) whose commit is on main build the multi-arch (amd64/arm64) image and push <dockerhub-user>/scrye:<version> + :latest - pushes to dev push the moving <dockerhub-user>/scrye:dev continuous-build tag Extract the multi-arch build (QEMU + Buildx + build-push against docker/Dockerfile) into a reusable .github/actions/build-image composite action and refactor ci.yml's multi-arch build-check to consume it, so the build is defined in one place. Publishing uses the DOCKERHUB_USERNAME/ DOCKERHUB_TOKEN repo secrets; ci.yml still never publishes. Update CLAUDE.md and docs/PLAN.md (locked decision 0.6, §9.1, §13, Deviations) and add a CONTRIBUTING.md Releasing section describing both paths. * ci: gate multi-arch build-check to main pushes and PRs to main The image-multiarch job's arm64 leg builds the whole Dockerfile under QEMU emulation, which on a cold type=gha cache takes hours. Only main-scoped runs reliably restore a warm arm64 cache; dev-based PRs rebuild from scratch every time. Gate the check to main pushes and PRs whose base is main. Multi-arch buildability stays proven for dev by publish.yml (builds amd64+arm64 on every dev push and release tag), and dev PRs still run the fast amd64-only image build + dogfood self-scan, so no coverage is lost. * ci(publish): scope :dev publish to merged PRs into dev The :dev Docker Hub publish was triggered by on: push: branches: [dev], which fired on any commit reaching the dev ref — including conflict- resolution pushes to an open, unmerged promotion PR. Re-scope it to on: pull_request: types: [closed] with base dev, gated on pull_request.merged == true, and build the merged commit (merge_commit_sha). The :dev tag now publishes only when a PR is actually merged into dev. The tagged-release path (v*.*.* on main) is unchanged. Sync docs/PLAN.md (§0.6 + Deviations entry) and the CONTRIBUTING.md Releasing section to describe the merged-PR trigger.
1 parent 292d4cf commit b771d98

6 files changed

Lines changed: 300 additions & 31 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Reusable multi-arch build for the Scrye image.
2+
#
3+
# Single source of truth for QEMU + Buildx setup and the docker/Dockerfile
4+
# build invocation, shared by ci.yml (build-only verification) and publish.yml
5+
# (build + push to Docker Hub). Callers handle registry login before invoking
6+
# this action with push: "true" — the action itself takes no secrets and stays
7+
# registry-agnostic.
8+
name: Build Scrye image
9+
description: Set up QEMU + Buildx and build the multi-arch Scrye image, optionally pushing it.
10+
11+
inputs:
12+
platforms:
13+
description: Target platforms to build.
14+
required: false
15+
default: linux/amd64,linux/arm64
16+
push:
17+
description: Whether to push the built image to the registry ("true"/"false").
18+
required: false
19+
default: "false"
20+
tags:
21+
description: Newline- or comma-separated image tags (required when push is "true").
22+
required: false
23+
default: ""
24+
25+
runs:
26+
using: composite
27+
steps:
28+
- name: Set up QEMU (arm64 emulation)
29+
uses: docker/setup-qemu-action@v3
30+
31+
- name: Set up Docker Buildx
32+
uses: docker/setup-buildx-action@v3
33+
34+
- name: Build image
35+
uses: docker/build-push-action@v6
36+
with:
37+
context: .
38+
file: docker/Dockerfile
39+
platforms: ${{ inputs.platforms }}
40+
push: ${{ inputs.push }}
41+
tags: ${{ inputs.tags }}
42+
cache-from: type=gha
43+
cache-to: type=gha,mode=max

.github/workflows/ci.yml

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
#
33
# This is the CI baseline all future phases build on: add new jobs/steps here as
44
# the app grows (e.g. the Phase 6 dogfood self-scan that runs Trivy + Grype
5-
# against Scrye's own image). Per CLAUDE.md locked decisions, there is NO
6-
# publish/registry job — the image is built locally only.
5+
# against Scrye's own image). CI itself never publishes — it lints, tests, and
6+
# proves the image builds for both architectures. Registry publishing to Docker
7+
# Hub lives in the separate publish.yml workflow, gated on tagged main releases
8+
# and dev-branch pushes (CLAUDE.md locked decision §6).
79
name: CI
810

911
on:
@@ -170,26 +172,29 @@ jobs:
170172
# Phase 6: prove the image builds for both target architectures
171173
# (linux/amd64 + linux/arm64, docs/PLAN.md §9.1). No load/push — a multi-arch
172174
# build cannot be loaded into the local daemon, so this only verifies both
173-
# platforms build cleanly (the arm64 scanner-download path included).
175+
# platforms build cleanly (the arm64 scanner-download path included). Uses the
176+
# shared build action that publish.yml also drives, so the build invocation
177+
# stays defined in exactly one place.
178+
#
179+
# Gated to main pushes and PRs whose base is main. The arm64 leg runs the whole
180+
# Dockerfile under QEMU emulation, so on a cold `type=gha` cache it takes hours;
181+
# only main-scoped runs reliably restore a warm arm64 cache, whereas dev-based
182+
# PRs would rebuild from scratch every time. Multi-arch buildability stays
183+
# continuously proven for dev anyway: publish.yml builds amd64+arm64 on every
184+
# push to dev (the :dev tag) and on release tags. dev PRs still get the fast
185+
# amd64-only `image` build + dogfood self-scan above. See docs/PLAN.md
186+
# § Deviations.
174187
image-multiarch:
175188
name: Image — multi-arch build check
176189
runs-on: ubuntu-latest
190+
# Skip on dev-based PRs (see note above); run on main pushes and PRs to main.
191+
if: github.event_name != 'pull_request' || github.event.pull_request.base.ref == 'main'
177192
steps:
178193
- name: Checkout
179194
uses: actions/checkout@v4
180195

181-
- name: Set up QEMU (arm64 emulation)
182-
uses: docker/setup-qemu-action@v3
183-
184-
- name: Set up Docker Buildx
185-
uses: docker/setup-buildx-action@v3
186-
187-
- name: Build image (amd64 + arm64)
188-
uses: docker/build-push-action@v6
196+
- name: Build image (amd64 + arm64, no push)
197+
uses: ./.github/actions/build-image
189198
with:
190-
context: .
191-
file: docker/Dockerfile
192199
platforms: linux/amd64,linux/arm64
193-
push: false
194-
cache-from: type=gha
195-
cache-to: type=gha,mode=max
200+
push: "false"

.github/workflows/publish.yml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Scrye — Docker Hub publishing.
2+
#
3+
# Two independent publishing paths (CLAUDE.md locked decision §6):
4+
#
5+
# 1. Tagged main releases — a push of a semver tag (v*.*.*) builds the
6+
# multi-arch image and pushes it as <dockerhub-user>/scrye:<version> (the tag
7+
# without its leading "v") and <dockerhub-user>/scrye:latest. The release job
8+
# refuses to run unless the tagged commit is reachable from main, so only
9+
# real releases cut from main ever reach :<version>/:latest.
10+
#
11+
# 2. dev continuous build — when a pull request is MERGED into dev, the
12+
# multi-arch image is built and pushed as the single moving tag
13+
# <dockerhub-user>/scrye:dev, always overwritten. This is NOT a versioned or
14+
# stable tag; it just mirrors the state of dev after each merged PR. It does
15+
# NOT fire on other pushes to the dev ref (e.g. conflict-resolution commits
16+
# on an open, unmerged PR) or on PRs that are closed without merging.
17+
#
18+
# The multi-arch build itself is defined once in .github/actions/build-image and
19+
# reused by both jobs (and by ci.yml's build-only check). Credentials come from
20+
# the DOCKERHUB_USERNAME / DOCKERHUB_TOKEN repository secrets.
21+
name: Publish
22+
23+
on:
24+
push:
25+
tags:
26+
- "v*.*.*"
27+
# The :dev tag publishes only when a pull request is actually merged into dev,
28+
# never on a bare push to the dev ref. types:[closed] fires on both merge and
29+
# close-without-merge; the merged==true gate lives on the dev job below.
30+
pull_request:
31+
types: [closed]
32+
branches:
33+
- dev
34+
35+
# Least privilege: reading the repo is all either job needs. Registry auth is
36+
# handled with the Docker Hub secrets, not the GITHUB_TOKEN.
37+
permissions:
38+
contents: read
39+
40+
# Serialize builds per ref. Distinct release tags have distinct refs so they
41+
# never cancel each other; each merged-PR :dev build runs under its own PR ref.
42+
concurrency:
43+
group: publish-${{ github.ref }}
44+
cancel-in-progress: true
45+
46+
jobs:
47+
release:
48+
name: Tagged release → Docker Hub (:<version> + :latest)
49+
if: startsWith(github.ref, 'refs/tags/v')
50+
runs-on: ubuntu-latest
51+
steps:
52+
- name: Checkout (full history to verify the tag is on main)
53+
uses: actions/checkout@v4
54+
with:
55+
fetch-depth: 0
56+
57+
# A tag can be pushed from any commit; a release must come from main.
58+
# Fetch main and confirm the tagged commit is an ancestor of its tip
59+
# before building anything.
60+
- name: Verify the tagged commit is on main
61+
run: |
62+
git fetch --no-tags origin main
63+
if ! git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then
64+
echo "::error::Tag ${GITHUB_REF_NAME} points at a commit that is not on main; refusing to publish a release."
65+
exit 1
66+
fi
67+
68+
# <dockerhub-user>/scrye:<version> uses the tag without its leading "v"
69+
# (v1.4.0 -> 1.4.0).
70+
- name: Derive the version from the tag
71+
id: version
72+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
73+
74+
- name: Log in to Docker Hub
75+
uses: docker/login-action@v3
76+
with:
77+
username: ${{ secrets.DOCKERHUB_USERNAME }}
78+
password: ${{ secrets.DOCKERHUB_TOKEN }}
79+
80+
- name: Build and push (:<version> + :latest)
81+
uses: ./.github/actions/build-image
82+
with:
83+
push: "true"
84+
tags: |
85+
<dockerhub-user>/scrye:${{ steps.version.outputs.version }}
86+
<dockerhub-user>/scrye:latest
87+
88+
dev:
89+
name: dev branch → Docker Hub (:dev continuous build)
90+
# Only when a PR is genuinely merged into dev. types:[closed] also fires when
91+
# a PR is closed without merging, so gate on merged==true; base.ref is pinned
92+
# to dev as belt-and-suspenders alongside the on: pull_request branch filter.
93+
if: >-
94+
github.event_name == 'pull_request' &&
95+
github.event.pull_request.merged == true &&
96+
github.event.pull_request.base.ref == 'dev'
97+
runs-on: ubuntu-latest
98+
steps:
99+
# Build the merge commit that actually landed on dev (the PR result), not
100+
# the ephemeral refs/pull/N/merge preview ref.
101+
- name: Checkout the merged commit on dev
102+
uses: actions/checkout@v4
103+
with:
104+
ref: ${{ github.event.pull_request.merge_commit_sha }}
105+
106+
- name: Log in to Docker Hub
107+
uses: docker/login-action@v3
108+
with:
109+
username: ${{ secrets.DOCKERHUB_USERNAME }}
110+
password: ${{ secrets.DOCKERHUB_TOKEN }}
111+
112+
- name: Build and push (:dev)
113+
uses: ./.github/actions/build-image
114+
with:
115+
push: "true"
116+
tags: <dockerhub-user>/scrye:dev

CLAUDE.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ CSV/Markdown/JSON; full history with filters; backup/restore; local + OIDC auth.
3333
mounts `/var/run/docker.sock`.
3434
5. **Secrets at rest:** **application-layer AES-256-GCM field encryption** (required). **SQLCipher
3535
is deferred** — leave a seam, don't build it.
36-
6. **Distribution:** **build the Docker image locally only.** Do **not** add Docker Hub / any
37-
registry publishing, CI publish jobs, or `<dockerhub-user>/...` references.
36+
6. **Distribution:** the image builds locally, and is **published to Docker Hub as
37+
`<dockerhub-user>/scrye` via two automated paths** (in `.github/workflows/publish.yml`,
38+
separate from `ci.yml`, using the `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` repo secrets):
39+
- **Tagged main releases** — pushing a semver tag `v*.*.*` builds the multi-arch
40+
(amd64/arm64) image and pushes `<dockerhub-user>/scrye:<version>` (the tag **without** the
41+
leading `v`) **and** `<dockerhub-user>/scrye:latest`. This runs **only** when the tagged
42+
commit is on `main`.
43+
- **dev continuous build** — every push to the `dev` branch builds the multi-arch image and
44+
pushes the single **moving** tag `<dockerhub-user>/scrye:dev` (always overwritten — not a
45+
version, not `latest`), so the current state of `dev` can be tested without cutting a
46+
release.
47+
No other registries or tags. `latest` and `:<version>` come **only** from tagged main
48+
releases; `:dev` comes **only** from `dev` pushes.
3849
7. **Theme:** **teal** primary (`primaryColor: 'teal'`), first-class **light and dark** modes.
3950

4051
## Hard security rules (non-negotiable)
@@ -67,8 +78,9 @@ CSV/Markdown/JSON; full history with filters; backup/restore; local + OIDC auth.
6778
- **CI is created in Phase 0 and is the gate for every PR thereafter, including Phase 0's own.**
6879
`.github/workflows/ci.yml` runs on every pull request and push to `main`: lint the backend
6980
(`ruff` + `black --check`) and frontend (ESLint + Prettier), and run `pytest` plus any frontend
70-
tests. No publish/registry job (locked decision §6). A phase's PR is not done until its CI run is
71-
green — do not ask the user to merge a PR with failing or missing checks.
81+
tests. CI never publishes — Docker Hub publishing lives in the separate `publish.yml`
82+
(locked decision §6). A phase's PR is not done until its CI run is green — do not ask the user
83+
to merge a PR with failing or missing checks.
7284
- **All commits and PRs are authored as the user, not as Claude.** Configure the local git identity
7385
for this repo (not global) before the first commit:
7486
`git config user.name "IamGroot60"` and
@@ -158,8 +170,9 @@ CSV/Markdown/JSON; full history with filters; backup/restore; local + OIDC auth.
158170
`APP_SECRET_KEY_FILE`); secrets like `OIDC_CLIENT_SECRET` appear as a named placeholder with a
159171
comment, never a real value. `.gitignore` already ignores `.env` but allows `.env.example`.
160172
- **`.github/workflows/ci.yml`** — created in **Phase 0**, before that phase's own PR is opened.
161-
Runs lint + tests on every PR/push to `main`. No publish/registry job. This is the gate every
162-
subsequent phase's PR must pass (see Git & PR conventions).
173+
Runs lint + tests on every PR/push to `main`. No publish/registry job — Docker Hub publishing
174+
is handled separately by `.github/workflows/publish.yml` (locked decision §6). This is the gate
175+
every subsequent phase's PR must pass (see Git & PR conventions).
163176
- **`THIRD_PARTY_LICENSES/`** — created in the Dockerfile phase (Phase 0), containing the
164177
Apache-2.0 `LICENSE` (and `NOTICE`, if present) for Trivy, Grype, and Syft, plus a README pointer
165178
to it. See Coding standards § Third-party license attribution.
@@ -178,4 +191,5 @@ disk); app secret key as a Docker secret file; fronted by Caddy at `scrye.your-d
178191
`https://pocket-id.your-domain.tld`.
179192

180193
## Out of scope for v1 (do not build)
181-
arq/Redis scale-out · SQLCipher full-DB encryption · registry publishing.
194+
arq/Redis scale-out · SQLCipher full-DB encryption. (Registry publishing to Docker Hub **is**
195+
in scope — see locked decision §6.)

CONTRIBUTING.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,33 @@ never a target for routine contribution PRs.
281281

282282
---
283283

284+
## Releasing
285+
286+
CI (`.github/workflows/ci.yml`) never publishes — it only lints, tests, and proves the image
287+
builds for both architectures. Publishing to Docker Hub (`<dockerhub-user>/scrye`) is handled by
288+
`.github/workflows/publish.yml`, which authenticates with the `DOCKERHUB_USERNAME` /
289+
`DOCKERHUB_TOKEN` repository secrets and has two independent paths:
290+
291+
- **Tagged releases (stable).** Push a semantic-version tag `v*.*.*` on a commit that is on
292+
`main` (e.g. `git tag v1.4.0 && git push origin v1.4.0`). This builds the multi-arch
293+
(amd64/arm64) image and pushes it as `<dockerhub-user>/scrye:<version>` (the tag without its
294+
leading `v`, so `v1.4.0``<dockerhub-user>/scrye:1.4.0`) **and** `<dockerhub-user>/scrye:latest`.
295+
The job refuses to run if the tagged commit is not on `main`, so `:latest` and `:<version>`
296+
always come from a real release.
297+
298+
- **`:dev` (continuous build, not a release).** Each time a pull request is **merged into
299+
`dev`**, the multi-arch image is built and pushed as the single **moving** tag
300+
`<dockerhub-user>/scrye:dev`, always overwritten. The workflow triggers on
301+
`pull_request: types: [closed]` (base `dev`) gated on `pull_request.merged == true`, so it
302+
fires **only on an actual merge** — not on other pushes to the `dev` ref (e.g. conflict-
303+
resolution commits on an open PR) and not on PRs closed without merging. This is **not** a
304+
stable release and **not** a version — it just mirrors the state of `dev` after each merge so
305+
you can pull and test HEAD-of-dev (`docker pull <dockerhub-user>/scrye:dev`) without cutting a
306+
tagged release. Do not treat `:dev` as production-ready; use a `:<version>` tag (or `:latest`)
307+
for that.
308+
309+
---
310+
284311
## Reporting security issues
285312

286313
**Please do not open public issues for security vulnerabilities.** Report them

0 commit comments

Comments
 (0)