Skip to content

Commit d752540

Browse files
committed
ci(docker): smoke test compose stack
1 parent 1a567c9 commit d752540

9 files changed

Lines changed: 140 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,15 @@ jobs:
6868
steps:
6969
- uses: actions/checkout@v5
7070

71+
- uses: actions/setup-node@v5
72+
with:
73+
node-version: "22"
74+
7175
- uses: docker/setup-buildx-action@v4
7276

77+
- name: Docker Compose smoke
78+
run: npm run smoke:docker
79+
7380
- uses: docker/build-push-action@v7
7481
with:
7582
context: .

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Cache resilience for article searches: cache read/write failures are logged and metriced without failing requests.
1818
- In-flight coalescing for identical cache misses so concurrent same-key searches share one upstream request per process.
1919
- Redocly-powered `npm run contract` check for the OpenAPI document, wired into CI.
20+
- Docker Compose smoke test against a fake GNews provider, wired into CI.
2021

2122
### Security
2223

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Errors: `{ "error": "message" }`. Rate limit: `429` with standard rate-limit hea
124124
| `npm run lint` | ESLint. |
125125
| `npm run contract` | Validate `docs/openapi.yaml` with Redocly CLI. |
126126
| `npm run smoke` | Curl-based smoke test against a running instance (`BASE_URL`, `QUERY`, `COUNT`, optional `CLIENT_API_KEY`). |
127+
| `npm run smoke:docker` | Compose smoke test: boot the image against a fake GNews provider and run `npm run smoke`. |
127128
| `npm run benchmark:local` | Builds the app, starts a fake GNews provider, and measures cold searches vs warm cache hits. See [docs/BENCHMARKS.md](docs/BENCHMARKS.md). |
128129

129130
## Project layout

docker-compose.ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
services:
2+
fake-gnews:
3+
image: node:22-alpine
4+
working_dir: /app
5+
command: ["node", "/app/scripts/fake-gnews-provider.mjs"]
6+
environment:
7+
PORT: "4010"
8+
FAKE_GNEWS_DELAY_MS: "5"
9+
volumes:
10+
- ./scripts:/app/scripts:ro
11+
healthcheck:
12+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4010/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
13+
interval: 2s
14+
timeout: 2s
15+
retries: 15
16+
17+
api:
18+
build: .
19+
depends_on:
20+
fake-gnews:
21+
condition: service_healthy
22+
ports:
23+
- "3000:3000"
24+
environment:
25+
GNEWS_API_KEY: ci
26+
GNEWS_BASE_URL: http://fake-gnews:4010
27+
PORT: "3000"
28+
NODE_ENV: production
29+
DISABLE_RATE_LIMIT: "1"
30+
LOG_LEVEL: silent

docs/CI.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,20 @@ The [workflow](../.github/workflows/ci.yml) runs on `ubuntu-latest` with **Node.
1717

1818
### Container (`docker` job)
1919

20-
9. **Buildx build**[Dockerfile](../Dockerfile) with **`provenance: mode=max`** and **SBOM** (no registry push). Validates supply-chain metadata generation in CI.
20+
9. **`npm run smoke:docker`** — Compose boots the production image against a GNews-compatible fake provider, waits for readiness, then runs `npm run smoke` against the exposed API.
21+
10. **Buildx build**[Dockerfile](../Dockerfile) with **`provenance: mode=max`** and **SBOM** (no registry push). Validates supply-chain metadata generation in CI.
2122

2223
### Pull requests only
2324

24-
10. **[Dependency review](../.github/workflows/dependency-review.yml)** — flags vulnerable or blocked dependencies introduced by the PR.
25+
11. **[Dependency review](../.github/workflows/dependency-review.yml)** — flags vulnerable or blocked dependencies introduced by the PR.
2526

2627
### Every push / PR (supply chain)
2728

28-
11. **[SBOM](../.github/workflows/supply-chain.yml)**[Anchore SBOM Action](https://github.com/anchore/sbom-action) produces SPDX JSON and uploads it as a workflow artifact.
29+
12. **[SBOM](../.github/workflows/supply-chain.yml)**[Anchore SBOM Action](https://github.com/anchore/sbom-action) produces SPDX JSON and uploads it as a workflow artifact.
2930

3031
### `main` branch pushes only
3132

32-
12. **[Provenance](../.github/workflows/provenance.yml)**[build provenance attestation](https://github.com/actions/attest-build-provenance) for `package-lock.json` (best-effort; `continue-on-error` if attestations are unavailable on the plan).
33+
13. **[Provenance](../.github/workflows/provenance.yml)**[build provenance attestation](https://github.com/actions/attest-build-provenance) for `package-lock.json` (best-effort; `continue-on-error` if attestations are unavailable on the plan).
3334

3435
### Code scanning (`CodeQL` workflow)
3536

@@ -53,6 +54,7 @@ npm run contract
5354
npm test
5455
npm run build
5556
docker build .
57+
npm run smoke:docker
5658
```
5759

5860
Optional (matches the CI Docker job more closely, requires Buildx):

docs/OPERATIONS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ CLIENT_API_KEY=client-secret-one npm run smoke
8484

8585
The smoke test checks `/health`, `/ready`, `/openapi.yaml`, `/api/articles`, and `/metrics`.
8686

87+
To smoke-test the production container without a live GNews key, boot the CI Compose stack against the fake provider:
88+
89+
```bash
90+
npm run smoke:docker
91+
```
92+
8793
## Local Benchmark
8894

8995
For reproducible cache/upstream performance checks without a live GNews key:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"lint": "eslint src test vitest.config.ts",
1717
"contract": "redocly lint docs/openapi.yaml",
1818
"smoke": "sh scripts/smoke-test.sh",
19+
"smoke:docker": "sh scripts/docker-compose-smoke.sh",
1920
"benchmark:local": "npm run build && node scripts/local-benchmark.mjs"
2021
},
2122
"keywords": [

scripts/docker-compose-smoke.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.ci.yml}"
5+
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-news-api-ci}"
6+
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
7+
8+
cleanup() {
9+
status=$?
10+
if [ "$status" -ne 0 ]; then
11+
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs --no-color || true
12+
fi
13+
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v --remove-orphans || true
14+
exit "$status"
15+
}
16+
17+
trap cleanup EXIT
18+
19+
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up --build -d
20+
21+
attempt=1
22+
while [ "$attempt" -le 30 ]; do
23+
if curl -fsS "$BASE_URL/ready" >/dev/null 2>&1; then
24+
break
25+
fi
26+
27+
if [ "$attempt" -eq 30 ]; then
28+
echo "FAIL api did not become ready at $BASE_URL"
29+
exit 1
30+
fi
31+
32+
attempt=$((attempt + 1))
33+
sleep 2
34+
done
35+
36+
BASE_URL="$BASE_URL" QUERY="${QUERY:-ci-smoke}" COUNT="${COUNT:-3}" npm run smoke

scripts/fake-gnews-provider.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import http from "node:http";
2+
3+
const port = Number(process.env.PORT ?? 4010);
4+
const delayMs = Number(process.env.FAKE_GNEWS_DELAY_MS ?? 0);
5+
6+
function articleFor(query, index) {
7+
return {
8+
title: `${query} headline ${index + 1}`,
9+
description: `Synthetic article ${index + 1} for ${query}`,
10+
content: `Synthetic content for ${query}`,
11+
url: `https://example.test/news/${encodeURIComponent(query)}/${index + 1}`,
12+
image: null,
13+
publishedAt: "2026-01-01T00:00:00.000Z",
14+
source: {
15+
name: "CI News",
16+
url: "https://example.test",
17+
},
18+
};
19+
}
20+
21+
function sendJson(res, status, body) {
22+
res.writeHead(status, { "content-type": "application/json" });
23+
res.end(JSON.stringify(body));
24+
}
25+
26+
const server = http.createServer((req, res) => {
27+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
28+
29+
if (url.pathname === "/health") {
30+
sendJson(res, 200, { status: "ok" });
31+
return;
32+
}
33+
34+
if (url.pathname !== "/search") {
35+
sendJson(res, 404, { error: "not found" });
36+
return;
37+
}
38+
39+
const query = url.searchParams.get("q") ?? "news";
40+
const count = Math.max(1, Math.min(Number(url.searchParams.get("max") ?? 3), 100));
41+
const articles = Array.from({ length: count }, (_, index) => articleFor(query, index));
42+
43+
setTimeout(() => sendJson(res, 200, { articles }), delayMs);
44+
});
45+
46+
server.listen(port, "0.0.0.0", () => {
47+
console.log(`fake GNews provider listening on ${port}`);
48+
});
49+
50+
process.on("SIGTERM", () => {
51+
server.close(() => process.exit(0));
52+
});

0 commit comments

Comments
 (0)