fix: exercise strict release checks in CI
This commit is contained in:
@@ -8,3 +8,10 @@ build
|
|||||||
data
|
data
|
||||||
coverage
|
coverage
|
||||||
*.log
|
*.log
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
credentials*
|
||||||
|
secrets*
|
||||||
|
.npmrc
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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'})`);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user