Skip to content

Commit 363bcf6

Browse files
committed
Add release smoke test workflow
1 parent ce550b4 commit 363bcf6

6 files changed

Lines changed: 311 additions & 0 deletions

File tree

.github/release-drafter.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ autolabeler:
7474
- ".github/release-drafter.yml"
7575
- ".github/workflows/release-drafter.yml"
7676
- ".github/workflows/release.yml"
77+
- ".github/workflows/release-smoke.yml"
7778
- ".github/workflows/sync-labels.yml"
7879
- ".github/labels.json"
80+
- "scripts/check-release-smoke.mjs"
7981
- label: infra
8082
files:
8183
- "scripts/**"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Release Smoke
2+
3+
on:
4+
release:
5+
types:
6+
- published
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: "Release tag to smoke test, for example v0.1.0"
11+
required: true
12+
type: string
13+
14+
permissions:
15+
contents: read
16+
packages: read
17+
18+
jobs:
19+
smoke:
20+
name: Smoke Test Published Images
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 20
23+
steps:
24+
- name: Resolve release tag and owner
25+
id: vars
26+
shell: bash
27+
run: |
28+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
29+
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
30+
else
31+
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
32+
fi
33+
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
34+
35+
- name: Checkout repository at release tag
36+
uses: actions/checkout@v4
37+
with:
38+
ref: ${{ steps.vars.outputs.tag }}
39+
40+
- name: Setup Node
41+
uses: actions/setup-node@v4
42+
with:
43+
node-version-file: .nvmrc
44+
45+
- name: Log in to GHCR
46+
uses: docker/login-action@v3
47+
with:
48+
registry: ghcr.io
49+
username: ${{ github.actor }}
50+
password: ${{ secrets.GITHUB_TOKEN }}
51+
52+
- name: Smoke test published backend and frontend images
53+
run: npm run check:release-smoke
54+
env:
55+
BACKEND_IMAGE: ghcr.io/${{ steps.vars.outputs.owner }}/nextjs-python-computer-vision-kit-backend:${{ steps.vars.outputs.tag }}
56+
FRONTEND_IMAGE: ghcr.io/${{ steps.vars.outputs.owner }}/nextjs-python-computer-vision-kit-frontend:${{ steps.vars.outputs.tag }}

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,8 @@ If you modify request or response shapes:
101101
3. Apply `minor` or `major` to a pull request when the default patch bump is not enough.
102102
4. Push a semver tag like `v0.1.0`.
103103
5. Wait for the release workflow to verify the repo, publish GHCR images, and create the GitHub Release.
104+
6. Confirm the release smoke workflow passes against the published images, or dispatch it manually for a tag if you need to re-check a release.
104105

105106
The component labels used by Release Drafter are synced from `.github/labels.json`, and most of the common ones are applied automatically from changed paths.
107+
108+
To run the same image smoke check locally, set `BACKEND_IMAGE` and `FRONTEND_IMAGE`, then run `npm run check:release-smoke`.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ The root check runs:
119119
- Release Drafter defaults to a patch bump unless a maintainer applies `minor` or `major` to the pull request.
120120
- Pushing a tag like `v0.1.0` triggers the release workflow.
121121
- That workflow verifies the tagged commit, publishes backend/frontend images to GHCR, and creates a GitHub Release with generated notes.
122+
- A follow-up smoke workflow pulls those published GHCR images and checks backend health, a real inference request, and the frontend shell before you treat the release as healthy.
123+
- Maintainers can re-run the same check manually with `BACKEND_IMAGE=... FRONTEND_IMAGE=... npm run check:release-smoke`.
122124

123125
## Contract Notes
124126

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"api:types": "openapi-typescript ./docs/openapi.yaml -o ./frontend/src/generated/openapi.ts",
99
"check:contract": "node scripts/check-contract-drift.mjs",
1010
"check:images": "node scripts/check-docker-builds.mjs",
11+
"check:release-smoke": "node scripts/check-release-smoke.mjs",
1112
"check": "node scripts/check.mjs"
1213
},
1314
"keywords": [

scripts/check-release-smoke.mjs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { readFile } from "node:fs/promises";
2+
import { spawnSync } from "node:child_process";
3+
import path from "node:path";
4+
import process from "node:process";
5+
import { setTimeout as delay } from "node:timers/promises";
6+
7+
const root = process.cwd();
8+
const backendImage = process.env.BACKEND_IMAGE;
9+
const frontendImage = process.env.FRONTEND_IMAGE;
10+
11+
if (!backendImage || !frontendImage) {
12+
console.error("Set BACKEND_IMAGE and FRONTEND_IMAGE before running the release smoke check.");
13+
process.exit(1);
14+
}
15+
16+
const backendUrl = process.env.BACKEND_SMOKE_URL ?? "http://127.0.0.1:8000";
17+
const frontendUrl = process.env.FRONTEND_SMOKE_URL ?? "http://127.0.0.1:3000";
18+
const backendContainer = `cv-kit-backend-smoke-${Date.now()}`;
19+
const frontendContainer = `cv-kit-frontend-smoke-${Date.now()}`;
20+
const networkName = `cv-kit-smoke-${Date.now()}`;
21+
const fixturePath = path.join(root, "backend", "tests", "fixtures", "detection-scene.png");
22+
23+
let cleanedUp = false;
24+
25+
function run(command, args, options = {}) {
26+
const result = spawnSync(command, args, {
27+
encoding: "utf8",
28+
shell: process.platform === "win32",
29+
stdio: options.capture ? "pipe" : "inherit",
30+
});
31+
32+
if (result.error) {
33+
throw result.error;
34+
}
35+
36+
if (!options.allowFailure && result.status !== 0) {
37+
const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
38+
throw new Error(
39+
details
40+
? `Command failed: ${command} ${args.join(" ")}\n${details}`
41+
: `Command failed: ${command} ${args.join(" ")}`,
42+
);
43+
}
44+
45+
return result;
46+
}
47+
48+
async function waitFor(label, action, options = {}) {
49+
const attempts = options.attempts ?? 30;
50+
const intervalMs = options.intervalMs ?? 2000;
51+
let lastError = new Error(`${label} did not finish.`);
52+
53+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
54+
try {
55+
return await action();
56+
} catch (error) {
57+
lastError = error instanceof Error ? error : new Error(String(error));
58+
if (attempt === attempts) {
59+
break;
60+
}
61+
62+
console.log(`${label} not ready yet (${attempt}/${attempts}). Retrying...`);
63+
await delay(intervalMs);
64+
}
65+
}
66+
67+
throw lastError;
68+
}
69+
70+
async function expectText(url, snippet) {
71+
const response = await fetch(url);
72+
const text = await response.text();
73+
74+
if (!response.ok) {
75+
throw new Error(`Expected ${url} to return 2xx, received ${response.status}.`);
76+
}
77+
78+
if (!text.includes(snippet)) {
79+
throw new Error(`Expected ${url} to include "${snippet}".`);
80+
}
81+
}
82+
83+
async function expectJson(url, predicate, label) {
84+
const response = await fetch(url);
85+
const body = await response.text();
86+
87+
if (!response.ok) {
88+
throw new Error(`Expected ${url} to return 2xx, received ${response.status}.`);
89+
}
90+
91+
const payload = JSON.parse(body);
92+
if (!predicate(payload)) {
93+
throw new Error(`Unexpected payload for ${label}.`);
94+
}
95+
96+
return payload;
97+
}
98+
99+
async function runInferenceSmoke() {
100+
const bytes = await readFile(fixturePath);
101+
const formData = new FormData();
102+
formData.set("file", new Blob([bytes], { type: "image/png" }), "detection-scene.png");
103+
formData.set("pipeline_id", "starter-detection");
104+
105+
const response = await fetch(`${backendUrl}/api/v1/analyze`, {
106+
method: "POST",
107+
body: formData,
108+
});
109+
const body = await response.text();
110+
111+
if (!response.ok) {
112+
throw new Error(`Inference smoke request failed with ${response.status}.\n${body}`);
113+
}
114+
115+
const payload = JSON.parse(body);
116+
if (payload?.pipeline?.id !== "starter-detection") {
117+
throw new Error("Smoke inference returned the wrong pipeline id.");
118+
}
119+
120+
if (!Array.isArray(payload?.detections) || payload.detections.length === 0) {
121+
throw new Error("Smoke inference returned no detections.");
122+
}
123+
124+
if (!payload?.image?.width || !payload?.image?.height) {
125+
throw new Error("Smoke inference returned invalid image dimensions.");
126+
}
127+
}
128+
129+
function cleanup() {
130+
if (cleanedUp) {
131+
return;
132+
}
133+
134+
cleanedUp = true;
135+
136+
run("docker", ["rm", "-f", frontendContainer], { allowFailure: true });
137+
run("docker", ["rm", "-f", backendContainer], { allowFailure: true });
138+
run("docker", ["network", "rm", networkName], { allowFailure: true });
139+
}
140+
141+
function printContainerLogs() {
142+
console.log("\nBackend container logs:");
143+
run("docker", ["logs", backendContainer], { allowFailure: true });
144+
145+
console.log("\nFrontend container logs:");
146+
run("docker", ["logs", frontendContainer], { allowFailure: true });
147+
}
148+
149+
process.on("SIGINT", () => {
150+
cleanup();
151+
process.exit(130);
152+
});
153+
154+
process.on("SIGTERM", () => {
155+
cleanup();
156+
process.exit(143);
157+
});
158+
159+
try {
160+
run("docker", ["network", "create", networkName]);
161+
162+
await waitFor(
163+
"Backend image pull",
164+
async () => {
165+
run("docker", ["pull", backendImage], { capture: true });
166+
},
167+
{ attempts: 12, intervalMs: 10000 },
168+
);
169+
170+
await waitFor(
171+
"Frontend image pull",
172+
async () => {
173+
run("docker", ["pull", frontendImage], { capture: true });
174+
},
175+
{ attempts: 12, intervalMs: 10000 },
176+
);
177+
178+
run("docker", [
179+
"run",
180+
"--detach",
181+
"--rm",
182+
"--name",
183+
backendContainer,
184+
"--network",
185+
networkName,
186+
"--publish",
187+
"8000:8000",
188+
backendImage,
189+
]);
190+
191+
await waitFor(
192+
"Backend health",
193+
async () =>
194+
expectJson(
195+
`${backendUrl}/health`,
196+
(payload) => payload?.status === "ok",
197+
"backend health",
198+
),
199+
);
200+
201+
await expectJson(
202+
`${backendUrl}/api/v1/pipelines`,
203+
(payload) =>
204+
Array.isArray(payload?.pipelines) &&
205+
payload.pipelines.some((item) => item?.id === "starter-detection"),
206+
"pipeline catalog",
207+
);
208+
209+
await runInferenceSmoke();
210+
211+
run("docker", [
212+
"run",
213+
"--detach",
214+
"--rm",
215+
"--name",
216+
frontendContainer,
217+
"--network",
218+
networkName,
219+
"--publish",
220+
"3000:3000",
221+
"--env",
222+
`NEXT_PUBLIC_API_BASE_URL=http://${backendContainer}:8000/api/v1`,
223+
frontendImage,
224+
]);
225+
226+
await waitFor(
227+
"Frontend home page",
228+
async () =>
229+
expectText(
230+
frontendUrl,
231+
"A detection-first computer vision kit with room to grow.",
232+
),
233+
);
234+
235+
await expectText(
236+
`${frontendUrl}/webcam`,
237+
"Webcam mode is an extension, not the template main story.",
238+
);
239+
240+
console.log("Release smoke check passed.");
241+
} catch (error) {
242+
console.error(error instanceof Error ? error.message : String(error));
243+
printContainerLogs();
244+
process.exitCode = 1;
245+
} finally {
246+
cleanup();
247+
}

0 commit comments

Comments
 (0)