From 75ea5d2c80f02e14f212cddc5abe4b1b9eec3353 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 21 May 2026 23:35:19 -0700 Subject: [PATCH] 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. --- .github/workflows/publish-core-docker.yml | 319 +++++++++++++++++++++ packages/core/README.md | 7 + packages/core/docker-compose.image.yml | 2 +- packages/core/scripts/docker-smoke-test.sh | 11 +- 4 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish-core-docker.yml diff --git a/.github/workflows/publish-core-docker.yml b/.github/workflows/publish-core-docker.yml new file mode 100644 index 0000000..417a114 --- /dev/null +++ b/.github/workflows/publish-core-docker.yml @@ -0,0 +1,319 @@ +name: Publish Core Docker Image + +on: + repository_dispatch: + types: + - core-npm-published + +permissions: + contents: read + packages: write + +concurrency: + group: publish-core-docker-${{ github.event.client_payload.core_version }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + IMAGE_NAME: ghcr.io/atomicstrata/atomicmemory-core + +jobs: + publish: + name: publish @atomicmemory/core Docker image + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Login to GHCR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Resolve published package source + id: package + env: + CORE_VERSION_INPUT: ${{ github.event.client_payload.core_version }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + + if [[ -z "${CORE_VERSION_INPUT}" ]]; then + echo "::error::core_version is required for repository_dispatch or workflow_dispatch." + exit 1 + fi + + if ! metadata="$(npm view "@atomicmemory/core@${CORE_VERSION_INPUT}" version gitHead dist.tarball --json 2>/tmp/core-npm-view.err)"; then + cat /tmp/core-npm-view.err >&2 + exit 1 + fi + + version="$(jq -r '.version' <<<"${metadata}")" + git_head="$(jq -r '.gitHead' <<<"${metadata}")" + tarball="$(jq -r '.dist.tarball' <<<"${metadata}")" + latest_version="$(npm view @atomicmemory/core@latest version)" + + if [[ -z "${version}" || "${version}" == "null" ]]; then + echo "::error::Could not resolve @atomicmemory/core@${CORE_VERSION_INPUT}" + exit 1 + fi + + if [[ -z "${git_head}" || "${git_head}" == "null" ]]; then + echo "::error::@atomicmemory/core@${version} does not include npm gitHead metadata" + exit 1 + fi + + manifest_digest() { + docker manifest inspect "$1" --verbose 2>/dev/null | jq -r 'if type == "array" then .[0].Descriptor.digest else .Descriptor.digest // empty end' + } + + version_digest="$(manifest_digest "${IMAGE_NAME}:${version}" || true)" + latest_digest="$(manifest_digest "${IMAGE_NAME}:latest" || true)" + is_latest="$([[ "${version}" == "${latest_version}" ]] && echo true || echo false)" + + if [[ -n "${version_digest}" && "${is_latest}" == "true" && "${version_digest}" != "${latest_digest}" ]]; then + { + echo "should_publish=true" + echo "retag_latest_only=true" + echo "version=${version}" + echo "git_head=${git_head}" + echo "short_sha=${git_head:0:7}" + echo "tarball=${tarball}" + echo "is_latest=true" + } >>"${GITHUB_OUTPUT}" + echo "${IMAGE_NAME}:${version} already exists; moving latest to the same digest." + exit 0 + fi + + if [[ -n "${version_digest}" ]]; then + echo "should_publish=false" >>"${GITHUB_OUTPUT}" + echo "skip_reason=${IMAGE_NAME}:${version} already exists and latest is current." >>"${GITHUB_OUTPUT}" + echo "${IMAGE_NAME}:${version} already exists and latest is current; no Docker publish required." + exit 0 + fi + + { + echo "should_publish=true" + echo "retag_latest_only=false" + echo "version=${version}" + echo "git_head=${git_head}" + echo "short_sha=${git_head:0:7}" + echo "tarball=${tarball}" + echo "is_latest=${is_latest}" + } >>"${GITHUB_OUTPUT}" + + echo "Resolved @atomicmemory/core@${version}" + echo "gitHead=${git_head}" + echo "tarball=${tarball}" + + - name: Report skipped publish + if: steps.package.outputs.should_publish != 'true' + run: echo "${{ steps.package.outputs.skip_reason }}" + + - name: Checkout package gitHead + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.package.outputs.git_head }} + path: release-source + + - name: Verify checked-out package version + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + working-directory: release-source + run: | + set -euo pipefail + checked_out_version="$(node -p "require('./packages/core/package.json').version")" + if [[ "${checked_out_version}" != "${{ steps.package.outputs.version }}" ]]; then + echo "::error::packages/core/package.json is ${checked_out_version}, expected ${{ steps.package.outputs.version }}" + exit 1 + fi + + - name: Build local release image + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + run: | + set -euo pipefail + + docker build \ + --file release-source/packages/core/Dockerfile \ + --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${{ steps.package.outputs.git_head }}" \ + --label "org.opencontainers.image.version=${{ steps.package.outputs.version }}" \ + --label "org.opencontainers.image.title=@atomicmemory/core" \ + --tag "${IMAGE_NAME}:${{ steps.package.outputs.version }}" \ + --tag "${IMAGE_NAME}:${{ steps.package.outputs.short_sha }}" \ + --tag "${IMAGE_NAME}:sha-${{ steps.package.outputs.short_sha }}" \ + release-source + + if [[ "${{ steps.package.outputs.is_latest }}" == "true" ]]; then + docker tag "${IMAGE_NAME}:${{ steps.package.outputs.version }}" "${IMAGE_NAME}:latest" + fi + + - name: Verify image package version + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + run: | + set -euo pipefail + image_version="$(docker run --rm --entrypoint node "${IMAGE_NAME}:${{ steps.package.outputs.version }}" -p "require('./package.json').version")" + if [[ "${image_version}" != "${{ steps.package.outputs.version }}" ]]; then + echo "::error::Built image package version is ${image_version}, expected ${{ steps.package.outputs.version }}" + exit 1 + fi + + - name: Smoke test local release image + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + env: + CORE_IMAGE: ${{ env.IMAGE_NAME }}:${{ steps.package.outputs.version }} + run: | + set -euo pipefail + + smoke_dir="${RUNNER_TEMP}/core-docker-smoke" + compose_project="atomicmemory-core-release-${{ steps.package.outputs.short_sha }}" + app_port="3060" + postgres_port="5444" + core_api_key="test-core-api-key-do-not-leak" + storage_secret="000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + rm -rf "${smoke_dir}" + mkdir -p "${smoke_dir}" + cd "${smoke_dir}" + + cat > .env < docker-compose.yml <{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + EOF + + cleanup() { + docker compose -p "${compose_project}" down -v --remove-orphans || true + } + trap cleanup EXIT + + docker compose -p "${compose_project}" up -d + + for attempt in {1..45}; do + if curl -fsS "http://localhost:${app_port}/health" >/dev/null; then + break + fi + if [[ "${attempt}" == "45" ]]; then + docker compose -p "${compose_project}" logs app + exit 1 + fi + sleep 2 + done + + auth_header="Authorization: Bearer ${core_api_key}" + health_body="$(curl -fsS "http://localhost:${app_port}/health")" + test "$(jq -r .status <<<"${health_body}")" = "ok" + + memories_health="$(curl -fsS -H "${auth_header}" "http://localhost:${app_port}/v1/memories/health")" + test "$(jq -r .status <<<"${memories_health}")" = "ok" + + stats_status="$(curl -fsS -o /dev/null -w '%{http_code}' \ + -H "${auth_header}" \ + -G "http://localhost:${app_port}/v1/memories/stats" \ + --data-urlencode "user_id=smoke-test-user")" + test "${stats_status}" = "200" + + ingest_response="$(curl -fsS -w '\n%{http_code}' \ + -X POST "http://localhost:${app_port}/v1/memories/ingest/quick" \ + -H "${auth_header}" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "smoke-test-user", + "conversation": "User: I am testing the Docker deployment. The project uses PostgreSQL and Next.js.", + "source_site": "docker-smoke-test" + }')" + ingest_status="$(tail -n 1 <<<"${ingest_response}")" + ingest_body="$(sed '$d' <<<"${ingest_response}")" + test "${ingest_status}" = "200" + test "$(jq -r '.memories_stored // .memoriesStored // 0' <<<"${ingest_body}")" -ge 1 + + search_response="$(curl -fsS -w '\n%{http_code}' \ + -X POST "http://localhost:${app_port}/v1/memories/search" \ + -H "${auth_header}" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "smoke-test-user", + "query": "What database is the project using?", + "source_site": "docker-smoke-test" + }')" + search_status="$(tail -n 1 <<<"${search_response}")" + search_body="$(sed '$d' <<<"${search_response}")" + test "${search_status}" = "200" + test "$(jq -r .count <<<"${search_body}")" -ge 1 + + bad_status="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "http://localhost:${app_port}/v1/memories/ingest" \ + -H "${auth_header}" \ + -H "Content-Type: application/json" \ + -d '{"user_id":"x"}')" + test "${bad_status}" = "400" + + - name: Push release tags + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only != 'true' + run: | + set -euo pipefail + + docker push "${IMAGE_NAME}:${{ steps.package.outputs.version }}" + docker push "${IMAGE_NAME}:${{ steps.package.outputs.short_sha }}" + docker push "${IMAGE_NAME}:sha-${{ steps.package.outputs.short_sha }}" + + if [[ "${{ steps.package.outputs.is_latest }}" == "true" ]]; then + docker push "${IMAGE_NAME}:latest" + else + echo "Not moving latest: ${{ steps.package.outputs.version }} is not npm latest." + fi + + - name: Move latest to existing version image + if: steps.package.outputs.should_publish == 'true' && steps.package.outputs.retag_latest_only == 'true' + run: | + set -euo pipefail + docker buildx imagetools create \ + --tag "${IMAGE_NAME}:latest" \ + "${IMAGE_NAME}:${{ steps.package.outputs.version }}" diff --git a/packages/core/README.md b/packages/core/README.md index 2f5520f..ced24ae 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -65,6 +65,13 @@ docker run --rm -it --pull always \ The image is published as `ghcr.io/atomicstrata/atomicmemory-core` with `latest`, semver, and commit-SHA tags. +The public monorepo's `Publish Core Docker Image` workflow runs after +`@atomicmemory/core` is published to npm and verified by the ops publishing +helper. It resolves the npm package version, skips if that version is already +present in GHCR, checks out the package `gitHead`, builds +`packages/core/Dockerfile`, smoke-tests the local image, and then pushes the +matching GHCR tags. + Local Docker defaults use `Authorization: Bearer local-dev-key`, OpenAI embeddings at 1536 dimensions, and `RAW_STORAGE_DEPLOYMENT_ENV=local`. The quickstart binds to `127.0.0.1` so that default key is only exposed locally. diff --git a/packages/core/docker-compose.image.yml b/packages/core/docker-compose.image.yml index d076a1a..543e240 100644 --- a/packages/core/docker-compose.image.yml +++ b/packages/core/docker-compose.image.yml @@ -17,7 +17,7 @@ services: retries: 5 app: - image: ghcr.io/atomicstrata/atomicmemory-core:latest + image: ${CORE_IMAGE:-ghcr.io/atomicstrata/atomicmemory-core:latest} restart: unless-stopped ports: - "${APP_PORT:-17350}:17350" diff --git a/packages/core/scripts/docker-smoke-test.sh b/packages/core/scripts/docker-smoke-test.sh index 3c32331..e4898a9 100755 --- a/packages/core/scripts/docker-smoke-test.sh +++ b/packages/core/scripts/docker-smoke-test.sh @@ -14,8 +14,10 @@ # inside a container, missing env vars, broken DB connection). # # Usage: -# ./scripts/docker-smoke-test.sh # full build + test -# SKIP_BUILD=1 ./scripts/docker-smoke-test.sh # reuse existing image +# ./scripts/docker-smoke-test.sh # full source build + test +# SKIP_BUILD=1 ./scripts/docker-smoke-test.sh # reuse existing compose image +# COMPOSE_BASE_FILE=docker-compose.image.yml CORE_IMAGE=... SKIP_BUILD=1 ./scripts/docker-smoke-test.sh +# # test a prebuilt release image # # Requirements: docker, docker compose, curl, jq @@ -24,6 +26,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" COMPOSE_PROJECT="${COMPOSE_PROJECT:-}" +COMPOSE_BASE_FILE="${COMPOSE_BASE_FILE:-docker-compose.yml}" APP_PORT="${APP_PORT:-}" POSTGRES_PORT="${POSTGRES_PORT:-}" SMOKE_ENV_FILE="$PROJECT_DIR/.env.docker-smoke-test" @@ -137,11 +140,11 @@ export APP_PORT POSTGRES_PORT if [[ "${SKIP_BUILD:-}" == "1" ]]; then docker compose -p "$COMPOSE_PROJECT" \ - -f docker-compose.yml -f docker-compose.smoke.yml \ + -f "$COMPOSE_BASE_FILE" -f docker-compose.smoke.yml \ up -d else docker compose -p "$COMPOSE_PROJECT" \ - -f docker-compose.yml -f docker-compose.smoke.yml \ + -f "$COMPOSE_BASE_FILE" -f docker-compose.smoke.yml \ up -d --build fi