Skip to content

Commit 333b738

Browse files
committed
chore(core): add GHCR Docker release workflow
Resolve the published @atomicmemory/core npm version as the source of truth, checkout its gitHead, build the Core Docker image, smoke-test it, and push matching GHCR tags from the public monorepo.
1 parent 79c9cf1 commit 333b738

4 files changed

Lines changed: 323 additions & 5 deletions

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
name: Publish Core Docker Image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- packages/core/**
9+
- package.json
10+
- pnpm-lock.yaml
11+
repository_dispatch:
12+
types:
13+
- core-npm-published
14+
workflow_dispatch:
15+
inputs:
16+
core_version:
17+
description: "Published @atomicmemory/core version or dist-tag to mirror to GHCR"
18+
required: true
19+
default: "latest"
20+
21+
permissions:
22+
contents: read
23+
packages: write
24+
25+
concurrency:
26+
group: publish-core-docker-${{ github.event.inputs.core_version || github.event.client_payload.core_version || github.ref }}
27+
cancel-in-progress: false
28+
29+
defaults:
30+
run:
31+
shell: bash
32+
33+
env:
34+
IMAGE_NAME: ghcr.io/atomicstrata/atomicmemory-core
35+
36+
jobs:
37+
publish:
38+
name: publish @atomicmemory/core Docker image
39+
runs-on: ubuntu-24.04
40+
timeout-minutes: 60
41+
steps:
42+
- name: Checkout trigger ref
43+
uses: actions/checkout@v4
44+
45+
- name: Setup Node
46+
uses: actions/setup-node@v4
47+
with:
48+
node-version: "22"
49+
50+
- name: Login to GHCR
51+
env:
52+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53+
run: echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
54+
55+
- name: Resolve published package source
56+
id: package
57+
env:
58+
CORE_VERSION_INPUT: ${{ github.event.inputs.core_version || github.event.client_payload.core_version }}
59+
IMAGE_NAME: ${{ env.IMAGE_NAME }}
60+
run: |
61+
set -euo pipefail
62+
63+
if [[ -z "${CORE_VERSION_INPUT}" ]]; then
64+
CORE_VERSION_INPUT="$(node -p "require('./packages/core/package.json').version")"
65+
fi
66+
67+
if ! metadata="$(npm view "@atomicmemory/core@${CORE_VERSION_INPUT}" version gitHead dist.tarball --json 2>/tmp/core-npm-view.err)"; then
68+
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
69+
echo "should_publish=false" >>"${GITHUB_OUTPUT}"
70+
echo "skip_reason=@atomicmemory/core@${CORE_VERSION_INPUT} is not visible on npm yet." >>"${GITHUB_OUTPUT}"
71+
cat /tmp/core-npm-view.err
72+
exit 0
73+
fi
74+
cat /tmp/core-npm-view.err >&2
75+
exit 1
76+
fi
77+
78+
version="$(jq -r '.version' <<<"${metadata}")"
79+
git_head="$(jq -r '.gitHead' <<<"${metadata}")"
80+
tarball="$(jq -r '.dist.tarball' <<<"${metadata}")"
81+
latest_version="$(npm view @atomicmemory/core@latest version)"
82+
83+
if [[ -z "${version}" || "${version}" == "null" ]]; then
84+
echo "::error::Could not resolve @atomicmemory/core@${CORE_VERSION_INPUT}"
85+
exit 1
86+
fi
87+
88+
if [[ -z "${git_head}" || "${git_head}" == "null" ]]; then
89+
echo "::error::@atomicmemory/core@${version} does not include npm gitHead metadata"
90+
exit 1
91+
fi
92+
93+
if docker manifest inspect "${IMAGE_NAME}:${version}" >/dev/null 2>&1; then
94+
echo "should_publish=false" >>"${GITHUB_OUTPUT}"
95+
echo "skip_reason=${IMAGE_NAME}:${version} already exists." >>"${GITHUB_OUTPUT}"
96+
echo "${IMAGE_NAME}:${version} already exists; no Docker publish required."
97+
exit 0
98+
fi
99+
100+
{
101+
echo "should_publish=true"
102+
echo "version=${version}"
103+
echo "git_head=${git_head}"
104+
echo "short_sha=${git_head:0:7}"
105+
echo "tarball=${tarball}"
106+
echo "is_latest=$([[ "${version}" == "${latest_version}" ]] && echo true || echo false)"
107+
} >>"${GITHUB_OUTPUT}"
108+
109+
echo "Resolved @atomicmemory/core@${version}"
110+
echo "gitHead=${git_head}"
111+
echo "tarball=${tarball}"
112+
113+
- name: Report skipped publish
114+
if: steps.package.outputs.should_publish != 'true'
115+
run: echo "${{ steps.package.outputs.skip_reason }}"
116+
117+
- name: Checkout package gitHead
118+
if: steps.package.outputs.should_publish == 'true'
119+
uses: actions/checkout@v4
120+
with:
121+
ref: ${{ steps.package.outputs.git_head }}
122+
path: release-source
123+
124+
- name: Verify checked-out package version
125+
if: steps.package.outputs.should_publish == 'true'
126+
working-directory: release-source
127+
run: |
128+
set -euo pipefail
129+
checked_out_version="$(node -p "require('./packages/core/package.json').version")"
130+
if [[ "${checked_out_version}" != "${{ steps.package.outputs.version }}" ]]; then
131+
echo "::error::packages/core/package.json is ${checked_out_version}, expected ${{ steps.package.outputs.version }}"
132+
exit 1
133+
fi
134+
135+
- name: Build local release image
136+
if: steps.package.outputs.should_publish == 'true'
137+
run: |
138+
set -euo pipefail
139+
140+
docker build \
141+
--file release-source/packages/core/Dockerfile \
142+
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
143+
--label "org.opencontainers.image.revision=${{ steps.package.outputs.git_head }}" \
144+
--label "org.opencontainers.image.version=${{ steps.package.outputs.version }}" \
145+
--label "org.opencontainers.image.title=@atomicmemory/core" \
146+
--tag "${IMAGE_NAME}:${{ steps.package.outputs.version }}" \
147+
--tag "${IMAGE_NAME}:${{ steps.package.outputs.short_sha }}" \
148+
--tag "${IMAGE_NAME}:sha-${{ steps.package.outputs.short_sha }}" \
149+
release-source
150+
151+
if [[ "${{ steps.package.outputs.is_latest }}" == "true" ]]; then
152+
docker tag "${IMAGE_NAME}:${{ steps.package.outputs.version }}" "${IMAGE_NAME}:latest"
153+
fi
154+
155+
- name: Verify image package version
156+
if: steps.package.outputs.should_publish == 'true'
157+
run: |
158+
set -euo pipefail
159+
image_version="$(docker run --rm --entrypoint node "${IMAGE_NAME}:${{ steps.package.outputs.version }}" -p "require('./package.json').version")"
160+
if [[ "${image_version}" != "${{ steps.package.outputs.version }}" ]]; then
161+
echo "::error::Built image package version is ${image_version}, expected ${{ steps.package.outputs.version }}"
162+
exit 1
163+
fi
164+
165+
- name: Smoke test local release image
166+
if: steps.package.outputs.should_publish == 'true'
167+
env:
168+
CORE_IMAGE: ${{ env.IMAGE_NAME }}:${{ steps.package.outputs.version }}
169+
run: |
170+
set -euo pipefail
171+
172+
smoke_dir="${RUNNER_TEMP}/core-docker-smoke"
173+
compose_project="atomicmemory-core-release-${{ steps.package.outputs.short_sha }}"
174+
app_port="3060"
175+
postgres_port="5444"
176+
core_api_key="test-core-api-key-do-not-leak"
177+
storage_secret="000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
178+
179+
rm -rf "${smoke_dir}"
180+
mkdir -p "${smoke_dir}"
181+
cd "${smoke_dir}"
182+
183+
cat > .env <<EOF
184+
CORE_API_KEY=${core_api_key}
185+
STORAGE_KEY_HMAC_SECRET=${storage_secret}
186+
RAW_STORAGE_DEPLOYMENT_ENV=local
187+
EOF
188+
189+
cat > docker-compose.yml <<EOF
190+
services:
191+
postgres:
192+
image: pgvector/pgvector:pg17
193+
environment:
194+
POSTGRES_USER: atomicmemory
195+
POSTGRES_PASSWORD: atomicmemory
196+
POSTGRES_DB: atomicmemory
197+
ports:
198+
- "${postgres_port}:5432"
199+
healthcheck:
200+
test: ["CMD", "pg_isready", "-q", "-d", "atomicmemory", "-U", "atomicmemory"]
201+
interval: 5s
202+
timeout: 5s
203+
retries: 5
204+
205+
app:
206+
image: ${CORE_IMAGE}
207+
ports:
208+
- "${app_port}:17350"
209+
environment:
210+
DATABASE_URL: postgresql://atomicmemory:atomicmemory@postgres:5432/atomicmemory
211+
PORT: "17350"
212+
EMBEDDING_PROVIDER: transformers
213+
EMBEDDING_MODEL: Xenova/all-MiniLM-L6-v2
214+
EMBEDDING_DIMENSIONS: "384"
215+
LLM_PROVIDER: openai
216+
OPENAI_API_KEY: sk-smoke-test-dummy
217+
env_file:
218+
- .env
219+
depends_on:
220+
postgres:
221+
condition: service_healthy
222+
healthcheck:
223+
test: ["CMD", "node", "-e", "fetch('http://localhost:17350/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
224+
interval: 15s
225+
timeout: 5s
226+
retries: 3
227+
start_period: 30s
228+
EOF
229+
230+
cleanup() {
231+
docker compose -p "${compose_project}" down -v --remove-orphans || true
232+
}
233+
trap cleanup EXIT
234+
235+
docker compose -p "${compose_project}" up -d
236+
237+
for attempt in {1..45}; do
238+
if curl -fsS "http://localhost:${app_port}/health" >/dev/null; then
239+
break
240+
fi
241+
if [[ "${attempt}" == "45" ]]; then
242+
docker compose -p "${compose_project}" logs app
243+
exit 1
244+
fi
245+
sleep 2
246+
done
247+
248+
auth_header="Authorization: Bearer ${core_api_key}"
249+
health_body="$(curl -fsS "http://localhost:${app_port}/health")"
250+
test "$(jq -r .status <<<"${health_body}")" = "ok"
251+
252+
memories_health="$(curl -fsS -H "${auth_header}" "http://localhost:${app_port}/v1/memories/health")"
253+
test "$(jq -r .status <<<"${memories_health}")" = "ok"
254+
255+
stats_status="$(curl -fsS -o /dev/null -w '%{http_code}' \
256+
-H "${auth_header}" \
257+
-G "http://localhost:${app_port}/v1/memories/stats" \
258+
--data-urlencode "user_id=smoke-test-user")"
259+
test "${stats_status}" = "200"
260+
261+
ingest_response="$(curl -fsS -w '\n%{http_code}' \
262+
-X POST "http://localhost:${app_port}/v1/memories/ingest/quick" \
263+
-H "${auth_header}" \
264+
-H "Content-Type: application/json" \
265+
-d '{
266+
"user_id": "smoke-test-user",
267+
"conversation": "User: I am testing the Docker deployment. The project uses PostgreSQL and Next.js.",
268+
"source_site": "docker-smoke-test"
269+
}')"
270+
ingest_status="$(tail -n 1 <<<"${ingest_response}")"
271+
ingest_body="$(sed '$d' <<<"${ingest_response}")"
272+
test "${ingest_status}" = "200"
273+
test "$(jq -r '.memories_stored // .memoriesStored // 0' <<<"${ingest_body}")" -ge 1
274+
275+
search_response="$(curl -fsS -w '\n%{http_code}' \
276+
-X POST "http://localhost:${app_port}/v1/memories/search" \
277+
-H "${auth_header}" \
278+
-H "Content-Type: application/json" \
279+
-d '{
280+
"user_id": "smoke-test-user",
281+
"query": "What database is the project using?",
282+
"source_site": "docker-smoke-test"
283+
}')"
284+
search_status="$(tail -n 1 <<<"${search_response}")"
285+
search_body="$(sed '$d' <<<"${search_response}")"
286+
test "${search_status}" = "200"
287+
test "$(jq -r .count <<<"${search_body}")" -ge 1
288+
289+
bad_status="$(curl -sS -o /dev/null -w '%{http_code}' \
290+
-X POST "http://localhost:${app_port}/v1/memories/ingest" \
291+
-H "${auth_header}" \
292+
-H "Content-Type: application/json" \
293+
-d '{"user_id":"x"}')"
294+
test "${bad_status}" = "400"
295+
296+
- name: Push release tags
297+
if: steps.package.outputs.should_publish == 'true'
298+
run: |
299+
set -euo pipefail
300+
301+
docker push "${IMAGE_NAME}:${{ steps.package.outputs.version }}"
302+
docker push "${IMAGE_NAME}:${{ steps.package.outputs.short_sha }}"
303+
docker push "${IMAGE_NAME}:sha-${{ steps.package.outputs.short_sha }}"
304+
305+
if [[ "${{ steps.package.outputs.is_latest }}" == "true" ]]; then
306+
docker push "${IMAGE_NAME}:latest"
307+
else
308+
echo "Not moving latest: ${{ steps.package.outputs.version }} is not npm latest."
309+
fi

packages/core/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ docker run --rm -it --pull always \
6565
The image is published as `ghcr.io/atomicstrata/atomicmemory-core` with
6666
`latest`, semver, and commit-SHA tags.
6767

68+
The public monorepo's `Publish Core Docker Image` workflow runs after Core
69+
changes land on public `main`. It resolves the `@atomicmemory/core` npm version,
70+
skips if that version is not published yet or is already present in GHCR,
71+
checks out the package `gitHead`, builds `packages/core/Dockerfile`,
72+
smoke-tests the local image, and then pushes the matching GHCR tags.
73+
6874
Local Docker defaults use `Authorization: Bearer local-dev-key`, OpenAI
6975
embeddings at 1536 dimensions, and `RAW_STORAGE_DEPLOYMENT_ENV=local`. The
7076
quickstart binds to `127.0.0.1` so that default key is only exposed locally.

packages/core/docker-compose.image.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ services:
1717
retries: 5
1818

1919
app:
20-
image: ghcr.io/atomicstrata/atomicmemory-core:latest
20+
image: ${CORE_IMAGE:-ghcr.io/atomicstrata/atomicmemory-core:latest}
2121
restart: unless-stopped
2222
ports:
2323
- "${APP_PORT:-17350}:17350"

packages/core/scripts/docker-smoke-test.sh

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
# inside a container, missing env vars, broken DB connection).
1515
#
1616
# Usage:
17-
# ./scripts/docker-smoke-test.sh # full build + test
18-
# SKIP_BUILD=1 ./scripts/docker-smoke-test.sh # reuse existing image
17+
# ./scripts/docker-smoke-test.sh # full source build + test
18+
# SKIP_BUILD=1 ./scripts/docker-smoke-test.sh # reuse existing compose image
19+
# COMPOSE_BASE_FILE=docker-compose.image.yml CORE_IMAGE=... SKIP_BUILD=1 ./scripts/docker-smoke-test.sh
20+
# # test a prebuilt release image
1921
#
2022
# Requirements: docker, docker compose, curl, jq
2123

@@ -24,6 +26,7 @@ set -euo pipefail
2426
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
2527
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
2628
COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
29+
COMPOSE_BASE_FILE="${COMPOSE_BASE_FILE:-docker-compose.yml}"
2730
APP_PORT="${APP_PORT:-}"
2831
POSTGRES_PORT="${POSTGRES_PORT:-}"
2932
SMOKE_ENV_FILE="$PROJECT_DIR/.env.docker-smoke-test"
@@ -137,11 +140,11 @@ export APP_PORT POSTGRES_PORT
137140

138141
if [[ "${SKIP_BUILD:-}" == "1" ]]; then
139142
docker compose -p "$COMPOSE_PROJECT" \
140-
-f docker-compose.yml -f docker-compose.smoke.yml \
143+
-f "$COMPOSE_BASE_FILE" -f docker-compose.smoke.yml \
141144
up -d
142145
else
143146
docker compose -p "$COMPOSE_PROJECT" \
144-
-f docker-compose.yml -f docker-compose.smoke.yml \
147+
-f "$COMPOSE_BASE_FILE" -f docker-compose.smoke.yml \
145148
up -d --build
146149
fi
147150

0 commit comments

Comments
 (0)