From 6feb25111cf4db9620c59c52ca3983ab6f398d31 Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Thu, 22 Oct 2026 21:53:14 +0100 Subject: [PATCH 1/3] Publish AgQL production images from GitHub runners --- .github/workflows/images.yml | 38 ++++++++++++++++++++ README.md | 7 ++++ deploy/README.md | 26 +++++++++++++- deploy/deploy.sh | 16 ++++++--- deploy/deploy.test.mjs | 67 ++++++++++++++++++++++++++++++++++++ deploy/docker-compose.yml | 4 +-- eslint.config.mjs | 5 +++ 7 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/images.yml create mode 100644 deploy/deploy.test.mjs diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..15abeeb --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,38 @@ +name: Publish production image + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish the release image + id: image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: true + tags: ghcr.io/unlikeotherai/agql:${{ github.sha }} + labels: | + org.opencontainers.image.source=https://github.com/UnlikeOtherAI/AgQL + org.opencontainers.image.revision=${{ github.sha }} + - name: Record the immutable image reference + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: echo "AGQL_IMAGE=ghcr.io/unlikeotherai/agql@$IMAGE_DIGEST" >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 7438f7b..42df6d3 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,10 @@ instructions are in [deploy/README.md](deploy/README.md). MIT — see [LICENSE](LICENSE). A contract that isn't freely implementable isn't a contract. + +## Production images + +GitHub Actions publishes `ghcr.io/unlikeotherai/agql:` from `main`. +The host runs `./deploy/deploy.sh` to pull that checkout's image and verify readiness +and an authenticated query. Image compilation runs on GitHub runners. See the +[deployment prerequisites](deploy/README.md#prerequisites) for GHCR access. diff --git a/deploy/README.md b/deploy/README.md index cb4cf16..02283d4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -20,6 +20,11 @@ schema or database roles are unavailable. Both the API container healthcheck and [`Caddyfile.snippet`](Caddyfile.snippet), pointing at the host. - `git`, `openssl`, and `curl` installed on the host. - Read access to the AgQL repository. +- A published API image for the selected commit in `ghcr.io/unlikeotherai/agql`. + `.github/workflows/images.yml` builds it on a GitHub runner after a push to `main`, + or on manual dispatch. The Dockerfile runs lint and typecheck during the build. + Make the GHCR package public for anonymous pulls, or have the operator authenticate + Docker on the host with package read access. The workflow needs package write permission. Confirm that the shared network is available before continuing: @@ -85,12 +90,25 @@ does send a one-year HSTS header with `includeSubDomains`; clients must still use `https://` on their first connection, because HSTS cannot protect a bearer key already sent over plaintext HTTP. -Run the normal deploy command: +Wait for the production image workflow for the selected commit to succeed, then run the normal deploy command: ```sh ./deploy/deploy.sh ``` +The command pulls the image tagged with the checkout's full commit SHA; it never +compiles source on the server. A missing or inaccessible image fails before any +container is replaced. Existing manual `--seed` behavior is preserved. To select an +immutable digest from the workflow summary, use: + +```sh +AGQL_IMAGE=ghcr.io/unlikeotherai/agql@sha256: ./deploy/deploy.sh +``` + +For manual recovery, select a previously published digest and its matching checkout +after checking schema compatibility. Deployments do not automatically roll the +database or containers back. Commit-tagged images must remain available for recovery. + Normal deployment provisions the catalog's PostgreSQL schema before the API can become ready. The script waits for `/ready` and then sends an authenticated MCP `run_query` over the private API address. Its exit status therefore means the @@ -108,6 +126,12 @@ starter `projects` rows on a fresh installation, use: query gate. It is safe to repeat for the supplied starter data, but it is not a data-migration mechanism. +For direct Compose commands below, first select the checkout image: + +```sh +export AGQL_IMAGE="ghcr.io/unlikeotherai/agql:$(git rev-parse HEAD)" +``` + ## Verification There is intentionally no `localhost:8787` host listener. Verify database-backed diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 952d4a6..bbba94d 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -16,9 +16,11 @@ usage() { cat <<'USAGE' Usage: ./deploy/deploy.sh [--seed] -Builds and starts AgQL with Docker Compose, waits for database-backed readiness, +Pulls the published image for this checkout and starts AgQL with Docker Compose, +waits for database-backed readiness, then confirms that an authenticated MCP run_query succeeds. --seed Run the workspace starter seed command before the run_query check. +Set AGQL_IMAGE to an explicit published image digest to deploy another revision. USAGE } @@ -84,16 +86,22 @@ while IFS= read -r name; do done < <(required_env_names) cd "${repo_dir}" +# Manual deployments select the checked-out commit, never a mutable latest tag. +# An explicit digest/reference supports operators deploying a previously published release. +if [[ -z "${AGQL_IMAGE:-}" ]]; then + revision="$(git rev-parse --verify HEAD)" + export AGQL_IMAGE="ghcr.io/unlikeotherai/agql:${revision}" +fi compose=(docker compose --env-file "${env_file}" -f "${compose_file}") echo "Pulling the digest-pinned PostgreSQL image..." "${compose[@]}" pull agql-postgres -echo "Building the AgQL API image..." -"${compose[@]}" build --pull agql-api +echo "Pulling the published AgQL API image..." +"${compose[@]}" pull agql-api echo "Starting AgQL..." -"${compose[@]}" up -d +"${compose[@]}" up -d --no-build --pull never container_id="$("${compose[@]}" ps -q agql-api)" if [[ -z "${container_id}" ]]; then diff --git a/deploy/deploy.test.mjs b/deploy/deploy.test.mjs new file mode 100644 index 0000000..b917a69 --- /dev/null +++ b/deploy/deploy.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +for (const scenario of ['normal', 'seed', 'missing-image']) { + test(`manual deploy with published image: ${scenario}`, async () => { + const dir = await mkdtemp(join(process.cwd(), '.deploy-test-')); + try { + await mkdir(join(dir, 'deploy')); + const script = await readFile('deploy/deploy.sh', 'utf8'); + await writeFile(join(dir, 'deploy', 'deploy.sh'), script); + await writeFile(join(dir, 'deploy', '.env.example'), 'REQUIRED_VALUE=\n'); + await writeFile(join(dir, 'deploy', '.env'), 'REQUIRED_VALUE=test\n'); + const harness = ` +set -euo pipefail +export TRACE="$PWD/trace" +export AGQL_IMAGE='ghcr.io/unlikeotherai/agql@sha256:${'a'.repeat(64)}' +export SCENARIO='${scenario}' +docker() { + echo "$*" >> "$TRACE" + case "$*" in + *'pull agql-api') [ "$SCENARIO" != missing-image ]; return ;; + *'ps -q agql-api') echo test-container ;; + 'inspect '*) echo healthy ;; + *'exec -T agql-api node -') cat >/dev/null ;; + esac +} +export -f docker +bash deploy/deploy.sh ${scenario === 'seed' ? '--seed' : ''} +`; + await writeFile(join(dir, 'harness.sh'), harness); + const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'; + const result = spawnSync(bash, ['harness.sh'], { cwd: dir, encoding: 'utf8', timeout: 10000 }); + const trace = await readFile(join(dir, 'trace'), 'utf8'); + assert.equal(result.status, scenario === 'missing-image' ? 1 : 0, result.stderr); + assert.doesNotMatch(trace, / build /); + assert.match(trace, /pull agql-api/); + if (scenario === 'missing-image') { + assert.doesNotMatch(trace, /up -d/); + } else { + assert.match(trace, /up -d --no-build --pull never/); + assert.match(trace, /exec -T agql-api node -/); + assert.equal(trace.includes('exec -T agql-api pnpm seed'), scenario === 'seed'); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +} + +test('production Compose has an image-only API and preserves private Postgres storage', async () => { + const source = (await readFile('deploy/docker-compose.yml', 'utf8')).replace(' - .env', ' - deploy/.env.example'); + const image = `ghcr.io/unlikeotherai/agql@sha256:${'a'.repeat(64)}`; + const result = spawnSync('docker', ['compose', '-f', '-', 'config', '--format', 'json'], { + input: source, encoding: 'utf8', timeout: 30000, + env: { ...process.env, AGQL_IMAGE: image, POSTGRES_DB: 'test', POSTGRES_USER: 'test', POSTGRES_PASSWORD: 'test' }, + }); + assert.equal(result.status, 0, result.stderr); + const config = JSON.parse(result.stdout); + assert.equal(config.services['agql-api'].image, image); + assert.equal(config.services['agql-api'].build, undefined); + assert.equal(config.services['agql-api'].ports, undefined); + assert.equal(config.networks.db.internal, true); + assert.ok(config.services['agql-postgres'].volumes.some(volume => volume.target === '/var/lib/postgresql/data')); +}); diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b4b4a52..7325532 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -2,9 +2,7 @@ name: agql services: agql-api: - build: - context: .. - dockerfile: Dockerfile + image: ${AGQL_IMAGE:?Run deploy/deploy.sh or set AGQL_IMAGE to a published release image} restart: unless-stopped # Keep V8's old-space cap below this cgroup limit (see NODE_OPTIONS in .env). mem_limit: 512m diff --git a/eslint.config.mjs b/eslint.config.mjs index e48f8e4..0dd4531 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,11 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked, + { + files: ['deploy/*.test.mjs'], + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { globals: { process: 'readonly' } }, + }, { files: ['**/*.ts'], languageOptions: { From 0f3b47efd1589312891a364269e488d611889673 Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Thu, 22 Oct 2026 21:54:57 +0100 Subject: [PATCH 2/3] Verify AgQL image revision before startup --- deploy/deploy.sh | 6 +++++- deploy/deploy.test.mjs | 13 ++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index bbba94d..27b0bd7 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -88,8 +88,8 @@ done < <(required_env_names) cd "${repo_dir}" # Manual deployments select the checked-out commit, never a mutable latest tag. # An explicit digest/reference supports operators deploying a previously published release. +revision="$(git rev-parse --verify HEAD)" if [[ -z "${AGQL_IMAGE:-}" ]]; then - revision="$(git rev-parse --verify HEAD)" export AGQL_IMAGE="ghcr.io/unlikeotherai/agql:${revision}" fi compose=(docker compose --env-file "${env_file}" -f "${compose_file}") @@ -99,6 +99,10 @@ echo "Pulling the digest-pinned PostgreSQL image..." echo "Pulling the published AgQL API image..." "${compose[@]}" pull agql-api +image_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$AGQL_IMAGE")" +[[ "$image_revision" == "$revision" ]] || { echo "AgQL image revision does not match this checkout" >&2; exit 1; } +image_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$AGQL_IMAGE")" +[[ "$image_platform" == linux/amd64 ]] || { echo "AgQL image must target linux/amd64" >&2; exit 1; } echo "Starting AgQL..." "${compose[@]}" up -d --no-build --pull never diff --git a/deploy/deploy.test.mjs b/deploy/deploy.test.mjs index b917a69..21f8968 100644 --- a/deploy/deploy.test.mjs +++ b/deploy/deploy.test.mjs @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { test } from 'node:test'; -for (const scenario of ['normal', 'seed', 'missing-image']) { +for (const scenario of ['normal', 'seed', 'missing-image', 'wrong-revision', 'wrong-platform']) { test(`manual deploy with published image: ${scenario}`, async () => { const dir = await mkdtemp(join(process.cwd(), '.deploy-test-')); try { @@ -18,10 +18,16 @@ set -euo pipefail export TRACE="$PWD/trace" export AGQL_IMAGE='ghcr.io/unlikeotherai/agql@sha256:${'a'.repeat(64)}' export SCENARIO='${scenario}' +git() { echo ${'b'.repeat(40)}; } +export -f git docker() { echo "$*" >> "$TRACE" case "$*" in *'pull agql-api') [ "$SCENARIO" != missing-image ]; return ;; + *'image inspect --format '*org.opencontainers.image.revision*) + if [ "$SCENARIO" = wrong-revision ]; then echo wrong; else echo ${'b'.repeat(40)}; fi ;; + *'image inspect --format '*'.Os'* ) + if [ "$SCENARIO" = wrong-platform ]; then echo linux/arm64; else echo linux/amd64; fi ;; *'ps -q agql-api') echo test-container ;; 'inspect '*) echo healthy ;; *'exec -T agql-api node -') cat >/dev/null ;; @@ -34,10 +40,11 @@ bash deploy/deploy.sh ${scenario === 'seed' ? '--seed' : ''} const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'; const result = spawnSync(bash, ['harness.sh'], { cwd: dir, encoding: 'utf8', timeout: 10000 }); const trace = await readFile(join(dir, 'trace'), 'utf8'); - assert.equal(result.status, scenario === 'missing-image' ? 1 : 0, result.stderr); + const rejected = ['missing-image', 'wrong-revision', 'wrong-platform'].includes(scenario); + assert.equal(result.status, rejected ? 1 : 0, result.stderr); assert.doesNotMatch(trace, / build /); assert.match(trace, /pull agql-api/); - if (scenario === 'missing-image') { + if (rejected) { assert.doesNotMatch(trace, /up -d/); } else { assert.match(trace, /up -d --no-build --pull never/); From 001f1eeddced38622fd073be2336afaba1a3ea0e Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Thu, 22 Oct 2026 21:59:55 +0100 Subject: [PATCH 3/3] Verify AgQL production image on pull requests --- .github/workflows/images.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 15abeeb..b0718f8 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -1,6 +1,7 @@ name: Publish production image on: + pull_request: push: branches: [main] workflow_dispatch: @@ -16,6 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.actor }} @@ -27,7 +29,7 @@ jobs: context: . file: Dockerfile platforms: linux/amd64 - push: true + push: ${{ github.event_name != 'pull_request' }} tags: ghcr.io/unlikeotherai/agql:${{ github.sha }} labels: | org.opencontainers.image.source=https://github.com/UnlikeOtherAI/AgQL