diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..b0718f8 --- /dev/null +++ b/.github/workflows/images.yml @@ -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" 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..27b0bd7 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,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 diff --git a/deploy/deploy.test.mjs b/deploy/deploy.test.mjs new file mode 100644 index 0000000..21f8968 --- /dev/null +++ b/deploy/deploy.test.mjs @@ -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')); +}); 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: {