fix: exercise strict release checks in CI

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 197e6a3a70
commit b13df3fa34
8 changed files with 46 additions and 11 deletions
+7
View File
@@ -8,3 +8,10 @@ build
data data
coverage coverage
*.log *.log
*.pem
*.key
*.p12
*.pfx
credentials*
secrets*
.npmrc
+1 -1
View File
@@ -43,5 +43,5 @@ CONVEX_SITE_ORIGIN=http://localhost:3211
# Backend digest exercised by the isolated self-hosted smoke test. # Backend digest exercised by the isolated self-hosted smoke test.
# Pin the optional dashboard too before production use. # Pin the optional dashboard too before production use.
CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard:latest CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b
# Production release checks require this optional admin image to be digest-pinned. # Production release checks require this optional admin image to be digest-pinned.
+7
View File
@@ -26,6 +26,13 @@ jobs:
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check
- name: Isolated fixture smoke - name: Isolated fixture smoke
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture
- name: Strict staging release safety check
run: >-
RELEASE_ENV=staging APP_MODE=live
APP_RELEASE_ID=ci-$(git rev-parse --short HEAD)
CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b
bun run release:check
container: container:
# Requires a runner with Docker/Compose. This job is prepared but remains # Requires a runner with Docker/Compose. This job is prepared but remains
+1 -1
View File
@@ -23,7 +23,7 @@ services:
start_period: 10s start_period: 10s
dashboard: dashboard:
profiles: ["admin"] profiles: ["admin"]
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard:latest} image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b}
ports: ["127.0.0.1:6791:6791"] ports: ["127.0.0.1:6791:6791"]
environment: environment:
NEXT_PUBLIC_DEPLOYMENT_URL: ${PUBLIC_CONVEX_URL:-http://localhost:3210} NEXT_PUBLIC_DEPLOYMENT_URL: ${PUBLIC_CONVEX_URL:-http://localhost:3210}
+3 -1
View File
@@ -82,7 +82,9 @@ persistent deployment.
Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs
for `jb/eden`; runner labels, Docker availability, and action availability are for `jb/eden`; runner labels, Docker availability, and action availability are
therefore not validated. The workflow is prepared locally but has not run therefore not validated. The workflow is prepared locally but has not run
remotely. remotely. The source job runs a strict staging release check after building
the client, and the separate container job validates Compose expansion and
builds the digest-pinned web image.
No browser automation, OAuth callback failure injection, Convex migration No browser automation, OAuth callback failure injection, Convex migration
rollback, instance-credential backup/restore, key rotation, or live rollback, instance-credential backup/restore, key rotation, or live
revocation/transfer evidence is claimed by this lane. Those remain explicit revocation/transfer evidence is claimed by this lane. Those remain explicit
+6 -5
View File
@@ -19,6 +19,7 @@ const configuredPort = process.env.SMOKE_WEB_PORT;
const port = configuredPort === undefined ? await freePort() : Number(configuredPort); const port = configuredPort === undefined ? await freePort() : Number(configuredPort);
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('SMOKE_WEB_PORT must be a valid TCP port'); if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('SMOKE_WEB_PORT must be a valid TCP port');
const base = `http://127.0.0.1:${port}`; const base = `http://127.0.0.1:${port}`;
const releaseId = `fixture-${crypto.randomUUID()}`;
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-')); const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
let server: Bun.Subprocess | undefined; let server: Bun.Subprocess | undefined;
// Never pass ambient vault/session/provider variables into repository-controlled // Never pass ambient vault/session/provider variables into repository-controlled
@@ -38,12 +39,12 @@ async function compileFixture(directory: string): Promise<void> {
if (code !== 0) throw new Error('Fixture reference compilation failed'); if (code !== 0) throw new Error('Fixture reference compilation failed');
} }
async function waitForServer(): Promise<void> { async function waitForServer(expectedReleaseId: string): Promise<void> {
const deadline = Date.now() + 30_000; const deadline = Date.now() + 30_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const response = await fetch(base); const response = await fetch(base);
if (response.status === 200 && response.headers.get('x-application-release') === 'fixture') return; if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return;
} catch { /* startup race */ } } catch { /* startup race */ }
await Bun.sleep(250); await Bun.sleep(250);
} }
@@ -58,15 +59,15 @@ try {
...safeEnv, ...safeEnv,
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base, APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root, HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
APP_RELEASE_ID: 'fixture', APP_RELEASE_ID: releaseId,
APP_CAPABILITY_ALLOWLIST: 'asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions', APP_CAPABILITY_ALLOWLIST: 'asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions',
}, },
}); });
await waitForServer(); await waitForServer(releaseId);
const rootResponse = await fetch(base); const rootResponse = await fetch(base);
if (!(await rootResponse.text()).includes('FIXTURE DEMO')) throw new Error('Fixture label missing'); if (!(await rootResponse.text()).includes('FIXTURE DEMO')) throw new Error('Fixture label missing');
if (rootResponse.headers.get('x-application-release') !== 'fixture') throw new Error('Release identifier header missing'); if (rootResponse.headers.get('x-application-release') !== releaseId) throw new Error('Release identifier header missing');
const assets = await fetch(`${base}/assets`); const assets = await fetch(`${base}/assets`);
if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed'); if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed');
+7 -2
View File
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { readdirSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate'; import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']); const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']);
@@ -15,6 +15,7 @@ type ReleaseCheckInput = {
capabilityAllowlist: string | undefined; capabilityAllowlist: string | undefined;
trackedFiles: readonly string[]; trackedFiles: readonly string[];
browserBundleFiles?: readonly string[]; browserBundleFiles?: readonly string[];
browserBundlePresent?: boolean;
}; };
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] { export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
@@ -35,6 +36,9 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) { if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) {
errors.push('CONVEX_IMAGE must be digest-pinned for staging/production'); errors.push('CONVEX_IMAGE must be digest-pinned for staging/production');
} }
if (strictEnvironment && input.browserBundlePresent === false) {
errors.push('built browser client artifacts are required for staging/production release checks');
}
if (strictEnvironment) { if (strictEnvironment) {
const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]); const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]);
if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) { if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) {
@@ -45,7 +49,7 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
errors.push('APP_MODE=demo is not permitted for a staging/production release'); errors.push('APP_MODE=demo is not permitted for a staging/production release');
} }
for (const file of input.trackedFiles) { for (const file of input.trackedFiles) {
if (/(^|\/)(?:\.env(?:\.|$)|.*\.pem$|.*\.key$)/i.test(file) && file !== '.env.example') { if (/(^|\/)(?:\.env(?:\.|$)|.*\.(?:pem|key|p12|pfx)$|(?:credentials|secrets)(?:\.(?:json|ya?ml|env|txt)|$)|\.npmrc$)/i.test(file) && file !== '.env.example') {
errors.push(`secret-like file is tracked: ${file}`); errors.push(`secret-like file is tracked: ${file}`);
} }
} }
@@ -91,6 +95,7 @@ if (import.meta.main) {
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST, capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
trackedFiles: trackedFiles(), trackedFiles: trackedFiles(),
browserBundleFiles: browserBundleFiles(), browserBundleFiles: browserBundleFiles(),
browserBundlePresent: existsSync('build/client'),
}); });
if (errors.length) { if (errors.length) {
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`); console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
+14 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { import {
BASELINE_RELEASE_CAPABILITIES, BASELINE_RELEASE_CAPABILITIES,
capabilityEnabled, capabilityEnabled,
@@ -36,7 +37,7 @@ describe('deployment rollout gate', () => {
mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest', mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest',
convexImage: 'convex-backend:latest', dockerfile: 'FROM oven/bun:latest', convexImage: 'convex-backend:latest', dockerfile: 'FROM oven/bun:latest',
capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'], capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'],
browserBundleFiles: ['build/client/app.js'], browserBundleFiles: ['build/client/app.js'], browserBundlePresent: false,
}); });
expect(errors).toEqual(expect.arrayContaining([ expect(errors).toEqual(expect.arrayContaining([
'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment', 'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment',
@@ -47,6 +48,18 @@ describe('deployment rollout gate', () => {
'Unknown deployment capability: unknown', 'Unknown deployment capability: unknown',
'secret-like file is tracked: secrets/app.pem', 'secret-like file is tracked: secrets/app.pem',
'browser client artifacts contain managed secret names', 'browser client artifacts contain managed secret names',
'built browser client artifacts are required for staging/production release checks',
])); ]));
}); });
test('the repository Dockerfile passes strict base-image validation', () => {
const errors = releaseSafetyErrors({
mode: 'live', releaseId: 'ci-abc1234', releaseEnvironment: 'production',
dashboardImage: 'ghcr.io/get-convex/convex-dashboard@sha256:' + 'a'.repeat(64),
convexImage: 'ghcr.io/get-convex/convex-backend@sha256:' + 'b'.repeat(64),
dockerfile: readFileSync('Dockerfile', 'utf8'), capabilityAllowlist: undefined,
trackedFiles: [], browserBundlePresent: true,
});
expect(errors).toEqual([]);
});
}); });