Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Publish production image

on:
pull_request:
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
if: github.event_name != 'pull_request'
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: ${{ github.event_name != 'pull_request' }}
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"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<full-commit-sha>` 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.
26 changes: 25 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:<published-digest> ./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
Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -84,16 +86,26 @@ 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.
revision="$(git rev-parse --verify HEAD)"
if [[ -z "${AGQL_IMAGE:-}" ]]; then
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
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
"${compose[@]}" up -d --no-build --pull never

container_id="$("${compose[@]}" ps -q agql-api)"
if [[ -z "${container_id}" ]]; then
Expand Down
74 changes: 74 additions & 0 deletions deploy/deploy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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', 'wrong-revision', 'wrong-platform']) {
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}'
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 ;;
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');
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 (rejected) {
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'));
});
4 changes: 1 addition & 3 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading