diff --git a/.dockerignore b/.dockerignore index 6034181..98bb079 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,10 @@ build data coverage *.log +*.pem +*.key +*.p12 +*.pfx +credentials* +secrets* +.npmrc diff --git a/.env.example b/.env.example index 85bb471..1be3073 100644 --- a/.env.example +++ b/.env.example @@ -43,5 +43,5 @@ CONVEX_SITE_ORIGIN=http://localhost:3211 # Backend digest exercised by the isolated self-hosted smoke test. # Pin the optional dashboard too before production use. 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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ba8b232..87e03c9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -26,6 +26,13 @@ jobs: run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check - name: Isolated fixture smoke 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: # Requires a runner with Docker/Compose. This job is prepared but remains diff --git a/compose.yaml b/compose.yaml index 961700e..aab4481 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,7 +23,7 @@ services: start_period: 10s dashboard: 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"] environment: NEXT_PUBLIC_DEPLOYMENT_URL: ${PUBLIC_CONVEX_URL:-http://localhost:3210} diff --git a/docs/release-safety.md b/docs/release-safety.md index be351de..3f7a8aa 100644 --- a/docs/release-safety.md +++ b/docs/release-safety.md @@ -82,7 +82,9 @@ persistent deployment. 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 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 rollback, instance-credential backup/restore, key rotation, or live revocation/transfer evidence is claimed by this lane. Those remain explicit diff --git a/scripts/fixture-smoke.ts b/scripts/fixture-smoke.ts index e9c03ed..f942326 100644 --- a/scripts/fixture-smoke.ts +++ b/scripts/fixture-smoke.ts @@ -19,6 +19,7 @@ const configuredPort = process.env.SMOKE_WEB_PORT; 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'); const base = `http://127.0.0.1:${port}`; +const releaseId = `fixture-${crypto.randomUUID()}`; const root = await mkdtemp(join(tmpdir(), 'eden-fixture-')); let server: Bun.Subprocess | undefined; // Never pass ambient vault/session/provider variables into repository-controlled @@ -38,12 +39,12 @@ async function compileFixture(directory: string): Promise { if (code !== 0) throw new Error('Fixture reference compilation failed'); } -async function waitForServer(): Promise { +async function waitForServer(expectedReleaseId: string): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { try { 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 */ } await Bun.sleep(250); } @@ -58,15 +59,15 @@ try { ...safeEnv, APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base, 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', }, }); - await waitForServer(); + await waitForServer(releaseId); const rootResponse = await fetch(base); 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`); if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed'); diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 409ba0c..d560b36 100644 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,5 +1,5 @@ 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'; const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']); @@ -15,6 +15,7 @@ type ReleaseCheckInput = { capabilityAllowlist: string | undefined; trackedFiles: readonly string[]; browserBundleFiles?: readonly string[]; + browserBundlePresent?: boolean; }; export function releaseSafetyErrors(input: ReleaseCheckInput): string[] { @@ -35,6 +36,9 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] { if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) { 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) { 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))) { @@ -45,7 +49,7 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] { errors.push('APP_MODE=demo is not permitted for a staging/production release'); } 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}`); } } @@ -91,6 +95,7 @@ if (import.meta.main) { capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST, trackedFiles: trackedFiles(), browserBundleFiles: browserBundleFiles(), + browserBundlePresent: existsSync('build/client'), }); if (errors.length) { console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`); diff --git a/tests/release-safety.test.ts b/tests/release-safety.test.ts index b6bf2c0..a0c696e 100644 --- a/tests/release-safety.test.ts +++ b/tests/release-safety.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; import { BASELINE_RELEASE_CAPABILITIES, capabilityEnabled, @@ -36,7 +37,7 @@ describe('deployment rollout gate', () => { mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest', convexImage: 'convex-backend:latest', dockerfile: 'FROM oven/bun:latest', 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([ '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', 'secret-like file is tracked: secrets/app.pem', '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([]); + }); });