From 72faa85dc6bdda36558244ece826ff55932cdc8e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 04:48:41 +0000 Subject: [PATCH 1/3] fix(deploy): prune unused runtime schema history --- .github/workflows/build-check.yml | 3 + Dockerfile | 22 +- package.json | 1 + scripts/prune-runtime-artifacts.cjs | 269 ++++++++++++++++++++ scripts/smoke-hosted-compliance-runtime.mjs | 51 ++++ tests/runtime-artifact-pruning.test.cjs | 172 +++++++++++++ 6 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 scripts/prune-runtime-artifacts.cjs create mode 100644 scripts/smoke-hosted-compliance-runtime.mjs create mode 100644 tests/runtime-artifact-pruning.test.cjs diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index d17804def80..05ee1737308 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -66,6 +66,9 @@ jobs: - name: Assert dist/ contains every server/src runtime asset run: node scripts/copy-server-assets.cjs --check + - name: Check Fly runtime artifact closure + run: npm run test:runtime-artifacts + - name: Routed tenant smoke tests run: npm run test:tenant-routing diff --git a/Dockerfile b/Dockerfile index ff71d07323e..426b332de2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,16 @@ COPY . . # Build the TypeScript server (increase heap for large tsc compilation) RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build +# Historical releases remain immutable in git and in the public R2 artifact +# CDN. Fly still carries every compliance, docs, and protocol artifact, but its +# local schema tree only needs hosted-compliance pairs, current docs, aliases, +# and the training-agent checkpoint. Prune the builder copy so repository +# release records are never deleted or rewritten. +FROM builder AS runtime-artifacts +RUN node scripts/prune-runtime-artifacts.cjs \ + && DOCS_SMOKE_APP_ROOT=/app node scripts/smoke-docs-index-runtime.mjs \ + && HOSTED_COMPLIANCE_SMOKE_APP_ROOT=/app node scripts/smoke-hosted-compliance-runtime.mjs + # Pre-clone external repos in parallel, then strip .git metadata. # Only markdown files are indexed at runtime so .git dirs are dead weight. # IMPORTANT: Keep in sync with EXTERNAL_REPOS in server/src/addie/mcp/external-repos.ts @@ -165,7 +175,7 @@ RUN npm ci --omit=dev --ignore-scripts \ # into dist/ by scripts/copy-server-assets.cjs during `npm run build`, so # `COPY ... /dist` is sufficient — no per-directory asset lines needed here. # Adding an asset to server/src/** is a one-place change. -COPY --from=builder /app/dist ./dist +COPY --from=runtime-artifacts /app/dist ./dist COPY --from=builder /app/server/public ./server/public COPY --from=builder /app/static ./static COPY --from=builder /app/docs ./docs @@ -190,12 +200,12 @@ RUN npm run verify:training-agent-runtime # Copy pre-cloned repos (warm cache for Addie) COPY --from=repos /repos ./.addie-repos -# Fly unpacks images onto an 8 GiB root filesystem. Reserve space for the -# base image and runtime writes; fail the build before the release migration -# if growing artifacts or dependencies consume that headroom again. +# Fly unpacks images onto an 8 GiB root filesystem. Keep at least 1280 MiB +# below Fly's 7424 MiB application ceiling so normal release growth cannot +# strand production again. RUN app_mib=$(du -sm /app | cut -f1) \ - && echo "Runtime application size: ${app_mib} MiB (limit: 7424 MiB)" \ - && test "$app_mib" -le 7424 + && echo "Runtime application size: ${app_mib} MiB (budget: 6144 MiB; Fly limit: 7424 MiB)" \ + && test "$app_mib" -le 6144 # Set environment variables ENV NODE_ENV=production diff --git a/package.json b/package.json index 417c90c8726..ede3c03002e 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "test:release-workflow": "node tests/release-workflow-immutability.test.cjs && node tests/promote-release-candidate.test.cjs && node tests/published-compliance-versions.test.cjs", "promote:rc": "node scripts/promote-release-candidate.mjs", "test:immutable-release-artifacts": "node tests/immutable-release-artifacts.test.cjs", + "test:runtime-artifacts": "node --test --test-force-exit --test-timeout=30000 tests/runtime-artifact-pruning.test.cjs", "test:patch-3-0-compat-bundle": "node --test --test-force-exit --test-timeout=30000 tests/patch-3-0-compat-bundle.test.cjs", "test:error-codes": "node scripts/lint-error-codes.cjs && npm run test:seller-governance-policy-surfaces && npm run test:test-script-registration", "test:seller-governance-policy-surfaces": "node --test --test-force-exit tests/seller-governance-policy-surfaces.test.cjs", diff --git a/scripts/prune-runtime-artifacts.cjs b/scripts/prune-runtime-artifacts.cjs new file mode 100644 index 00000000000..2689c4c7b85 --- /dev/null +++ b/scripts/prune-runtime-artifacts.cjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +const { + existsSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} = require("node:fs"); +const { join, resolve } = require("node:path"); +const semver = require("semver"); + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function assertAdcpRepoRoot(repoRoot) { + const packagePath = join(repoRoot, "package.json"); + if (!existsSync(packagePath) || readJson(packagePath)?.name !== "adcontextprotocol") { + throw new Error(`Refusing to prune outside an adcontextprotocol checkout: ${repoRoot}`); + } +} + +function assertVersion(version, source) { + if (typeof version !== "string" || semver.valid(version) === null) { + throw new Error(`${source} contains invalid release version ${JSON.stringify(version)}`); + } + return version; +} + +function docsSnapshotVersions(repoRoot) { + const configPath = join(repoRoot, "docs.json"); + const versions = readJson(configPath)?.navigation?.versions; + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error(`${configPath} must declare navigation.versions`); + } + + return new Set(versions.map((entry) => { + const snapshots = new Set(); + const visit = (value) => { + if (typeof value === "string") { + const match = value.match(/^dist\/docs\/([^/]+)\//); + if (match) snapshots.add(assertVersion(match[1], configPath)); + } else if (Array.isArray(value)) { + value.forEach(visit); + } else if (value && typeof value === "object") { + Object.values(value).forEach(visit); + } + }; + visit(entry?.groups); + if (snapshots.size !== 1) { + throw new Error( + `${configPath} version ${JSON.stringify(entry?.version)} must reference exactly one dist/docs snapshot`, + ); + } + return [...snapshots][0]; + })); +} + +function retainedTrainingSchemaVersion(repoRoot) { + const sourcePath = join(repoRoot, "server", "src", "training-agent", "types.ts"); + const source = readFileSync(sourcePath, "utf8"); + const matches = [...source.matchAll( + /export const SELLER_GOVERNANCE_DISCOVERY_ADCP_VERSION\s*=\s*'([^']+)'\s+as const;/g, + )]; + if (matches.length !== 1) { + throw new Error( + `${sourcePath} must contain exactly one literal SELLER_GOVERNANCE_DISCOVERY_ADCP_VERSION declaration`, + ); + } + const wireVersion = matches[0][1]; + const match = wireVersion.match(/^(\d+)\.(\d+)(?:\.(\d+))?((?:-(?:beta|rc)\.\d+)?)$/); + if (!match) { + throw new Error(`Unsupported retained training-agent schema version ${wireVersion}`); + } + return assertVersion( + `${match[1]}.${match[2]}.${match[3] ?? "0"}${match[4]}`, + sourcePath, + ); +} + +function publishedComplianceVersions(repoRoot) { + const manifestPath = join( + repoRoot, + "static", + "compliance", + "published-versions.json", + ); + const manifest = readJson(manifestPath); + if ( + manifest?.schema_version !== 1 || + !Array.isArray(manifest.published_versions) || + manifest.published_versions.length === 0 + ) { + throw new Error(`${manifestPath} must declare schema_version 1 published_versions`); + } + const versions = manifest.published_versions.map((version) => + assertVersion(version, manifestPath)); + if (new Set(versions).size !== versions.length) { + throw new Error(`${manifestPath} published_versions must be unique`); + } + return new Set(versions); +} + +function schemaDiscoveryAliasTargets(repoRoot) { + const indexPath = join(repoRoot, "dist", "schemas", "index.json"); + const index = readJson(indexPath); + if (!index?.aliases || typeof index.aliases !== "object") { + throw new Error(`${indexPath} must declare schema aliases`); + } + const targets = new Set( + Object.values(index.aliases).map((version) => assertVersion(version, indexPath)), + ); + for (const field of ["latest", "latest_stable"]) { + targets.add(assertVersion(index[field], indexPath)); + } + for (const value of Object.values(index.latest_by_major ?? {})) { + targets.add(assertVersion(value, indexPath)); + } + for (const value of Object.values(index.latest_by_minor ?? {})) { + targets.add(assertVersion(value, indexPath)); + } + return targets; +} + +function runtimeArtifactManifest(repoRoot = resolve(__dirname, "..")) { + const published = publishedComplianceVersions(repoRoot); + const docs = docsSnapshotVersions(repoRoot); + const schemaAliases = schemaDiscoveryAliasTargets(repoRoot); + const trainingSchema = retainedTrainingSchemaVersion(repoRoot); + + return { + published, + docs, + schemaAliases, + trainingSchema, + schemaVersions: new Set([ + ...published, + ...docs, + ...schemaAliases, + trainingSchema, + ]), + }; +} + +function requireArtifact(rootPath, version, marker) { + const artifactPath = join(rootPath, version, marker); + if (!existsSync(artifactPath)) { + throw new Error(`Required runtime artifact is missing: ${artifactPath}`); + } +} + +function verifyRequiredArtifacts(repoRoot, manifest = runtimeArtifactManifest(repoRoot)) { + const schemaRoot = join(repoRoot, "dist", "schemas"); + const complianceRoot = join(repoRoot, "dist", "compliance"); + const docsRoot = join(repoRoot, "dist", "docs"); + for (const version of manifest.schemaVersions) { + requireArtifact(schemaRoot, version, "index.json"); + } + for (const version of manifest.published) { + requireArtifact(complianceRoot, version, "index.json"); + // hostedComplianceOptions() requires the same-version schema bundle. + requireArtifact(schemaRoot, version, "index.json"); + } + for (const version of manifest.docs) { + if (!existsSync(join(docsRoot, version))) { + throw new Error(`Required runtime docs snapshot is missing: ${join(docsRoot, version)}`); + } + } + requireArtifact(schemaRoot, "latest", "index.json"); + requireArtifact(complianceRoot, "latest", "index.json"); + for (const file of ["latest.tgz", "latest.tgz.sha256"]) { + const protocolPath = join(repoRoot, "dist", "protocol", file); + if (!existsSync(protocolPath)) { + throw new Error(`Required runtime protocol artifact is missing: ${protocolPath}`); + } + } + return manifest; +} + +function pruneVersionDirectories(rootPath, keep) { + const removed = []; + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + if ( + entry.isDirectory() && + semver.valid(entry.name) !== null && + !keep.has(entry.name) + ) { + rmSync(join(rootPath, entry.name), { recursive: true }); + removed.push(entry.name); + } + } + return removed; +} + +function rewriteSchemaDiscovery(repoRoot, keep) { + const indexPath = join(repoRoot, "dist", "schemas", "index.json"); + const index = readJson(indexPath); + if (!Array.isArray(index.versions)) { + throw new Error(`${indexPath} must declare versions before runtime pruning`); + } + index.versions = index.versions.filter((entry) => keep.has(entry?.version)); + writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`); +} + +function verifyPrunedTree(repoRoot, manifest) { + verifyRequiredArtifacts(repoRoot, manifest); + const actual = new Set( + readdirSync(join(repoRoot, "dist", "schemas"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && semver.valid(entry.name) !== null) + .map((entry) => entry.name), + ); + const unexpected = [...actual].filter((version) => !manifest.schemaVersions.has(version)); + const missing = [...manifest.schemaVersions].filter((version) => !actual.has(version)); + if (unexpected.length || missing.length) { + throw new Error( + `Runtime schema closure mismatch; missing=${missing.join(",") || "none"} unexpected=${unexpected.join(",") || "none"}`, + ); + } + + const schemaIndex = readJson(join(repoRoot, "dist", "schemas", "index.json")); + const indexed = new Set(schemaIndex.versions.map((entry) => entry.version)); + const stale = [...indexed].filter((version) => !manifest.schemaVersions.has(version)); + const unindexed = [...manifest.schemaVersions].filter((version) => !indexed.has(version)); + if (stale.length || unindexed.length) { + throw new Error( + `Runtime schema discovery mismatch; stale=${stale.join(",") || "none"} unindexed=${unindexed.join(",") || "none"}`, + ); + } +} + +function pruneRuntimeArtifacts(repoRoot = resolve(__dirname, "..")) { + assertAdcpRepoRoot(repoRoot); + const manifest = verifyRequiredArtifacts(repoRoot); + const schemasRemoved = pruneVersionDirectories( + join(repoRoot, "dist", "schemas"), + manifest.schemaVersions, + ); + rewriteSchemaDiscovery(repoRoot, manifest.schemaVersions); + verifyPrunedTree(repoRoot, manifest); + return { manifest, schemasRemoved }; +} + +function run() { + const repoRoot = resolve(process.argv[2] ?? join(__dirname, "..")); + const result = pruneRuntimeArtifacts(repoRoot); + console.log( + `Runtime artifact closure retained ${result.manifest.schemaVersions.size} schema releases; ` + + `pruned ${result.schemasRemoved.length}. Compliance, docs, and protocol artifacts were left intact.`, + ); +} + +if (require.main === module) { + try { + run(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +module.exports = { + docsSnapshotVersions, + pruneRuntimeArtifacts, + retainedTrainingSchemaVersion, + runtimeArtifactManifest, + verifyPrunedTree, + verifyRequiredArtifacts, +}; diff --git a/scripts/smoke-hosted-compliance-runtime.mjs b/scripts/smoke-hosted-compliance-runtime.mjs new file mode 100644 index 00000000000..e6107ba127d --- /dev/null +++ b/scripts/smoke-hosted-compliance-runtime.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const appRoot = process.env.HOSTED_COMPLIANCE_SMOKE_APP_ROOT || '/app'; +const manifestPath = path.join( + appRoot, + 'static', + 'compliance', + 'published-versions.json', +); +const servicePath = pathToFileURL( + path.join(appRoot, 'dist/services/hosted-compliance-version.js'), +).href; + +assert.ok(fs.existsSync(manifestPath), 'The runtime image must contain the compliance publication manifest'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +assert.equal(manifest.schema_version, 1); +assert.ok( + Array.isArray(manifest.published_versions) && manifest.published_versions.length > 0, + 'The compliance publication manifest must contain published_versions', +); + +// hosted-compliance-version resolves repository-relative paths from cwd. +process.chdir(appRoot); +const { + hostedComplianceOptions, + hostedComplianceTarget, + resolveHostedComplianceVersion, +} = await import(servicePath); + +for (const version of manifest.published_versions) { + assert.equal(resolveHostedComplianceVersion(version), version); + const target = hostedComplianceTarget(version); + assert.equal(target.version, version); + const options = hostedComplianceOptions(target); + assert.equal(options.version, version); + assert.ok( + fs.existsSync(path.join(options.complianceDir, 'index.json')), + `Missing hosted compliance bundle ${version}`, + ); + assert.ok( + fs.existsSync(path.join(options.schemaRoot, 'index.json')), + `Missing hosted schema bundle ${version}`, + ); +} + +console.log( + `Hosted compliance runtime smoke passed for ${manifest.published_versions.length} published versions.`, +); diff --git a/tests/runtime-artifact-pruning.test.cjs b/tests/runtime-artifact-pruning.test.cjs new file mode 100644 index 00000000000..6c31a95bdba --- /dev/null +++ b/tests/runtime-artifact-pruning.test.cjs @@ -0,0 +1,172 @@ +const assert = require("node:assert/strict"); +const { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); +const test = require("node:test"); + +const { + pruneRuntimeArtifacts, + retainedTrainingSchemaVersion, + runtimeArtifactManifest, + verifyRequiredArtifacts, +} = require("../scripts/prune-runtime-artifacts.cjs"); + +const repoRoot = resolve(__dirname, ".."); + +function writeJson(path, value) { + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeArtifact(root, kind, version, marker = "index.json") { + const dir = join(root, "dist", kind, version); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, marker), `${kind}:${version}\n`); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "adcp-runtime-artifacts-")); + writeJson(join(root, "package.json"), { name: "adcontextprotocol" }); + writeJson(join(root, "static", "compliance", "published-versions.json"), { + schema_version: 1, + published_versions: ["3.1.1"], + }); + writeJson(join(root, "docs.json"), { + navigation: { + versions: [{ version: "3.1", default: true, groups: ["dist/docs/3.1.2/intro"] }], + }, + }); + mkdirSync(join(root, "server", "src", "training-agent"), { recursive: true }); + writeFileSync( + join(root, "server", "src", "training-agent", "types.ts"), + "export const SELLER_GOVERNANCE_DISCOVERY_ADCP_VERSION = '3.1-beta.1' as const;\n", + ); + + for (const version of ["3.1.0-beta.1", "3.1.0-beta.2", "3.1.1", "3.1.2"]) { + writeArtifact(root, "schemas", version); + } + for (const version of ["3.1.0-beta.2", "3.1.1", "3.1.2"]) { + writeArtifact(root, "compliance", version); + } + writeArtifact(root, "schemas", "latest"); + writeArtifact(root, "compliance", "latest"); + mkdirSync(join(root, "dist", "docs", "3.1.2"), { recursive: true }); + writeFileSync(join(root, "dist", "docs", "3.1.2", "intro.mdx"), "docs sentinel\n"); + mkdirSync(join(root, "dist", "protocol"), { recursive: true }); + writeFileSync(join(root, "dist", "protocol", "latest.tgz"), "latest protocol\n"); + writeFileSync(join(root, "dist", "protocol", "latest.tgz.sha256"), "checksum\n"); + writeFileSync(join(root, "dist", "protocol", "3.0.0.tgz"), "release sentinel\n"); + writeJson(join(root, "dist", "schemas", "index.json"), { + latest: "3.1.2", + latest_stable: "3.1.2", + aliases: { v3: "3.1.2", "v3.1": "3.1.2" }, + latest_by_major: { 3: "3.1.2" }, + latest_by_minor: { "3.1": "3.1.2" }, + versions: [ + { version: "3.1.2" }, + { version: "3.1.1" }, + { version: "3.1.0-beta.2" }, + { version: "3.1.0-beta.1" }, + ], + }); + return root; +} + +test("runtime closure includes every published hosted compliance bundle and paired schema", () => { + const manifest = runtimeArtifactManifest(repoRoot); + const published = JSON.parse( + readFileSync(join(repoRoot, "static", "compliance", "published-versions.json")), + ).published_versions; + + for (const version of published) { + assert.ok(manifest.published.has(version), `missing compliance ${version}`); + assert.ok(manifest.schemaVersions.has(version), `missing schema ${version}`); + assert.ok(existsSync(join(repoRoot, "dist", "compliance", version, "index.json"))); + assert.ok(existsSync(join(repoRoot, "dist", "schemas", version, "index.json"))); + } + for (const version of manifest.schemaVersions) { + assert.ok( + existsSync(join(repoRoot, "dist", "schemas", version, "index.json")), + `missing required runtime schema ${version}`, + ); + } + for (const version of manifest.docs) { + assert.ok( + existsSync(join(repoRoot, "dist", "docs", version)), + `missing required runtime docs ${version}`, + ); + } + assert.ok(manifest.schemaVersions.has(manifest.trainingSchema)); +}); + +test("runtime pruning removes only unrequired build-stage versions", () => { + const root = fixture(); + try { + const docsBefore = readFileSync(join(root, "dist", "docs", "3.1.2", "intro.mdx")); + const protocolBefore = readFileSync(join(root, "dist", "protocol", "3.0.0.tgz")); + const complianceBefore = readFileSync( + join(root, "dist", "compliance", "3.1.0-beta.2", "index.json"), + ); + const result = pruneRuntimeArtifacts(root); + + assert.deepEqual(result.schemasRemoved, ["3.1.0-beta.2"]); + assert.ok(existsSync(join(root, "dist", "schemas", "3.1.0-beta.1", "index.json"))); + assert.ok(existsSync(join(root, "dist", "schemas", "3.1.1", "index.json"))); + assert.ok(existsSync(join(root, "dist", "schemas", "3.1.2", "index.json"))); + assert.ok(existsSync(join(root, "dist", "compliance", "3.1.1", "index.json"))); + assert.ok(existsSync(join(root, "dist", "compliance", "3.1.2", "index.json"))); + assert.ok(existsSync(join(root, "dist", "compliance", "3.1.0-beta.2", "index.json"))); + assert.deepEqual( + readFileSync(join(root, "dist", "compliance", "3.1.0-beta.2", "index.json")), + complianceBefore, + ); + assert.deepEqual( + readFileSync(join(root, "dist", "docs", "3.1.2", "intro.mdx")), + docsBefore, + ); + assert.deepEqual(readFileSync(join(root, "dist", "protocol", "3.0.0.tgz")), protocolBefore); + assert.deepEqual( + JSON.parse(readFileSync(join(root, "dist", "schemas", "index.json"))).versions + .map((entry) => entry.version), + ["3.1.2", "3.1.1", "3.1.0-beta.1"], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime closure fails closed when a published hosted schema is absent", () => { + const root = fixture(); + try { + rmSync(join(root, "dist", "schemas", "3.1.1"), { recursive: true }); + assert.throws( + () => verifyRequiredArtifacts(root), + /Required runtime artifact is missing: .*dist\/schemas\/3\.1\.1\/index\.json/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("training schema discovery fails closed if its literal contract changes", () => { + const root = fixture(); + try { + writeFileSync( + join(root, "server", "src", "training-agent", "types.ts"), + "export const SELLER_GOVERNANCE_DISCOVERY_ADCP_VERSION = selectedVersion;\n", + ); + assert.throws( + () => retainedTrainingSchemaVersion(root), + /must contain exactly one literal SELLER_GOVERNANCE_DISCOVERY_ADCP_VERSION declaration/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 960ae49b583a15d52fb72976019c78635c10089a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 11 Oct 2026 13:07:15 +0000 Subject: [PATCH 2/3] ci: measure the default runtime image with its repository cache --- .github/workflows/runtime-default-image.yml | 98 +++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/runtime-default-image.yml diff --git a/.github/workflows/runtime-default-image.yml b/.github/workflows/runtime-default-image.yml new file mode 100644 index 00000000000..26d43423509 --- /dev/null +++ b/.github/workflows/runtime-default-image.yml @@ -0,0 +1,98 @@ +name: Default runtime image footprint + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/runtime-default-image.yml' + workflow_dispatch: + inputs: + expected_sha: + description: Exact reviewed source commit to measure + required: true + type: string + +permissions: + contents: read + +jobs: + footprint: + name: Default image with repository cache + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EXPECTED_SHA: ${{ inputs.expected_sha }} + IMAGE: adcp-default-runtime-footprint + OUTPUT_DIR: ${{ runner.temp }}/default-runtime-footprint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify the exact source commit + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + test "$EXPECTED_SHA" = "$SOURCE_SHA" + fi + mkdir -p "$OUTPUT_DIR" + printf '%s\n' "$SOURCE_SHA" > "$OUTPUT_DIR/source-sha.txt" + + - name: Build with default repository cloning and size ceiling + run: | + set -euo pipefail + # No build-argument overrides: the production cache and 7424 MiB + # application ceiling are enforced by the unchanged Dockerfile. + docker build --progress=plain --tag "$IMAGE" . 2>&1 | tee "$OUTPUT_DIR/build.log" + + - name: Measure the image and verify every configured repository cache + run: | + set -euo pipefail + docker image inspect "$IMAGE" > "$OUTPUT_DIR/image.json" + docker run --rm --interactive --user 0 --entrypoint node "$IMAGE" --input-type=module > "$OUTPUT_DIR/runtime.json" <<'JS' + import fs from 'node:fs'; + import { execFileSync } from 'node:child_process'; + const mib = path => Number(execFileSync('du', ['-smx', path], { encoding: 'utf8' }).split(/\s+/)[0]); + const root = '/app/.addie-repos'; + const repositories = fs.readdirSync(root).filter(name => fs.statSync(`${root}/${name}`).isDirectory()).sort(); + console.log(JSON.stringify({ rootFilesystemMiB: mib('/'), applicationMiB: mib('/app'), cacheMiB: mib(root), repositories }, null, 2)); + JS + python3 - <<'PY' + import json, os, pathlib, re + output = pathlib.Path(os.environ['OUTPUT_DIR']) + runtime = json.loads((output / 'runtime.json').read_text()) + dockerfile = pathlib.Path('Dockerfile').read_text() + clone_lines = re.findall(r'^run_clone\s+.*$', dockerfile, re.M) + configured = set(re.findall(r'^run_clone\s+\S+\s+\S+\s+([\w-]+)\s*$', dockerfile, re.M)) + assert configured, 'No configured repository clones found' + assert len(configured) == len(clone_lines), 'Repository clone parser did not match every configured clone' + assert configured <= set(runtime['repositories']), sorted(configured - set(runtime['repositories'])) + assert runtime['cacheMiB'] > 0 + assert runtime['applicationMiB'] <= 7424 + runtime.update(sourceSha=os.environ['SOURCE_SHA'], defaultBuildArguments=True, configuredRepositories=sorted(configured)) + (output / 'runtime.json').write_text(json.dumps(runtime, indent=2) + '\n') + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write(f"Source `{runtime['sourceSha']}`: visible unpacked root {runtime['rootFilesystemMiB']} MiB; application {runtime['applicationMiB']} MiB / 7424 MiB; repository cache {runtime['cacheMiB']} MiB, {len(configured)} configured repositories.\n") + PY + + - name: Initialize every configured docs snapshot + run: | + set -euo pipefail + docker run --rm --interactive --entrypoint node "$IMAGE" --input-type=module < scripts/smoke-docs-index-runtime.mjs 2>&1 | tee "$OUTPUT_DIR/docs-smoke.log" + + - name: Load every hosted bundle with the production SDK + run: | + set -euo pipefail + docker run --rm --interactive --entrypoint node "$IMAGE" --input-type=module < scripts/smoke-hosted-compliance-runtime.mjs 2>&1 | tee "$OUTPUT_DIR/sdk-smoke.log" + + - name: Retain footprint and smoke receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: default-runtime-footprint-${{ github.run_id }} + path: ${{ runner.temp }}/default-runtime-footprint + if-no-files-found: warn + retention-days: 7 From c238b56188d7a4832bdc1c653b565268c15d259c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 11 Oct 2026 13:10:01 +0000 Subject: [PATCH 3/3] fix(ci): initialize footprint path in the runner step --- .github/workflows/runtime-default-image.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/runtime-default-image.yml b/.github/workflows/runtime-default-image.yml index 26d43423509..1a56d444f57 100644 --- a/.github/workflows/runtime-default-image.yml +++ b/.github/workflows/runtime-default-image.yml @@ -24,7 +24,6 @@ jobs: SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} EXPECTED_SHA: ${{ inputs.expected_sha }} IMAGE: adcp-default-runtime-footprint - OUTPUT_DIR: ${{ runner.temp }}/default-runtime-footprint steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -38,6 +37,8 @@ jobs: if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then test "$EXPECTED_SHA" = "$SOURCE_SHA" fi + OUTPUT_DIR="$RUNNER_TEMP/default-runtime-footprint" + printf 'OUTPUT_DIR=%s\n' "$OUTPUT_DIR" >> "$GITHUB_ENV" mkdir -p "$OUTPUT_DIR" printf '%s\n' "$SOURCE_SHA" > "$OUTPUT_DIR/source-sha.txt"