Skip to content

Commit b715b01

Browse files
fix(deps): automate reproducible supply-chain updates
1 parent e9eecc3 commit b715b01

11 files changed

Lines changed: 1404 additions & 26 deletions

File tree

.changeset/bright-supply-chain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Makes the agent and PostgreSQL images reproducible by installing the agent SDK from a committed lockfile and pinning every supported PostgreSQL base image by digest.

docker/agents/pi/Dockerfile

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
# syntax=docker/dockerfile:1.26@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
22
# renovate: datasource=docker depName=node
33
ARG NODE_VERSION=24.19.0
4+
FROM ghcr.io/pnpm/pnpm:12.0.0@sha256:bce5ae25ef95edd79e696d7fa8489b80561ef660100fd35bd0286d0f90db3dcc AS dependencies
5+
ARG NODE_VERSION
6+
WORKDIR /opt/pi-sdk
7+
RUN pnpm runtime set node ${NODE_VERSION} -g
8+
COPY pi/package.json pi/pnpm-lock.yaml ./
9+
RUN pnpm install --prod --frozen-lockfile --ignore-scripts --ignore-workspace
10+
411
FROM node:${NODE_VERSION}-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df
12+
ARG NODE_VERSION
513

614
# The base digest is pinned, so Debian security updates published after the upstream image was
715
# built can reach it only here; Renovate bumps the digest only when upstream republishes one.
@@ -22,13 +30,9 @@ RUN groupmod --new-name agent node && \
2230

2331
# renovate: datasource=npm depName=@earendil-works/pi-coding-agent
2432
ARG PI_VERSION=0.84.3
25-
RUN mkdir -p /opt/pi-sdk && cd /opt/pi-sdk && \
26-
printf '{"name":"hephaestus-pi-sdk","private":true,"dependencies":{"@earendil-works/pi-coding-agent":"%s"}}\n' \
27-
"${PI_VERSION}" > package.json && \
28-
npm install --omit=dev --ignore-scripts --no-audit --no-fund && \
29-
test -d /opt/pi-sdk/node_modules/@earendil-works/pi-coding-agent \
33+
COPY --from=dependencies --chown=1000:1000 /opt/pi-sdk /opt/pi-sdk
34+
RUN test -d /opt/pi-sdk/node_modules/@earendil-works/pi-coding-agent \
3035
|| (echo "Pi SDK not found at /opt/pi-sdk/node_modules" >&2; exit 1) && \
31-
chown -R 1000:1000 /opt/pi-sdk && \
3236
mkdir -p /workspace && chown 1000:1000 /workspace
3337

3438
RUN mkdir -p /tmp/abi-check && ln -sf /opt/pi-sdk/node_modules /tmp/abi-check/node_modules && \

docker/agents/pi/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "hephaestus-pi-sdk",
3+
"private": true,
4+
"version": "0.0.0",
5+
"dependencies": {
6+
"@earendil-works/pi-coding-agent": "0.84.3"
7+
}
8+
}

docker/agents/pi/pnpm-lock.yaml

Lines changed: 1221 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker/postgres/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
# environment (Alpine has no partman package). No shared_preload_libraries: maintenance runs as plain
33
# SQL from the app scheduler, so this stays stock Postgres + one extension. See ADR 0018 and ADR 0038.
44
ARG PG_MAJOR=18
5-
FROM postgres:${PG_MAJOR}-bookworm
5+
FROM postgres:18-bookworm@sha256:1c59e2c3c818eaa0f0628f695b36e7c9e362d6b219b36a54a32df645cbd7e1af AS postgres-18
6+
FROM postgres:17-bookworm@sha256:051f7b7b3abdd564d5d1bd1e8c4b9c1b6e77087d1dd22020ede611c096a272e0 AS postgres-17
7+
FROM postgres-${PG_MAJOR}
68
ARG PG_MAJOR=18
79
# Pinned for reproducibility (PGDG bookworm pool). Bump deliberately.
810
ARG PARTMAN_VERSION=5.5.0-1.pgdg12+1

docs/contributor/ci-cd.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ source validation.
9898
| OpenSSF Scorecard | `main`, branch-protection changes, and weekly | posture findings do not extend the pull-request critical path | code scanning and run artifact |
9999
| Renovate | configured schedule and vulnerability alerts | pull requests require normal review and CI | dependency dashboard |
100100

101+
Renovate opens bounded, independently reviewable update pull requests on weekday mornings; only
102+
major updates wait for an explicit Dependency Dashboard approval. Vulnerability alerts bypass the
103+
normal release-age delay and approval gate. Native managers cover package manifests, lockfiles,
104+
container bases, Maven, and GitHub Actions, while tested regex managers cover version declarations
105+
that have no native manager. The dashboard is an inventory and decision surface, not a queue that
106+
maintainers must drain before routine fixes can start.
107+
108+
Renovate is not the supply-chain inventory by itself: it discovers update coordinates in source,
109+
whereas lockfiles describe resolved package graphs and release SBOMs describe the contents of final
110+
artifacts. Dependency review, image scanning, signed provenance, scheduled rescans, and the release
111+
SBOM therefore remain required layers rather than substitutes for dependency updates.
112+
101113
[Vulnerability remediation](./vulnerability-remediation.mdx) owns the blocking and exception policy.
102114
[Release management](./release-management.mdx#supply-chain-evidence) owns artifact signing, provenance,
103115
SBOM, and release-verification guarantees.

renovate.json

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
{
22
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
3-
"extends": [
4-
"config:best-practices",
5-
":semanticCommitTypeAll(chore)",
6-
":dependencyDashboardApproval"
7-
],
3+
"extends": ["config:best-practices", ":semanticCommitTypeAll(chore)"],
84
"labels": ["dependencies"],
95
"assigneesFromCodeOwners": true,
106
"timezone": "Europe/Berlin",
11-
"schedule": ["before 7am on monday"],
7+
"schedule": ["before 7am every weekday"],
128
"minimumReleaseAge": "3 days",
139
"semanticCommitScope": "deps",
1410
"enabledManagers": [
@@ -37,48 +33,43 @@
3733
"matchManagers": ["maven"],
3834
"matchDepTypes": ["compile", "runtime", "provided", "optional", "parent", "parent-root"],
3935
"matchUpdateTypes": ["minor", "patch", "digest", "pin", "pinDigest"],
40-
"groupName": "Java runtime and framework dependencies",
4136
"labels": ["dependencies", "java"]
4237
},
4338
{
4439
"matchManagers": ["maven"],
4540
"matchDepTypes": ["build", "test"],
4641
"matchUpdateTypes": ["minor", "patch", "digest", "pin", "pinDigest"],
47-
"groupName": "Java developer tooling",
4842
"labels": ["dependencies", "java"]
4943
},
5044
{
5145
"matchFileNames": ["webapp/package.json"],
5246
"matchDepTypes": ["dependencies"],
5347
"matchUpdateTypes": ["minor", "patch"],
54-
"groupName": "Webapp runtime dependencies",
5548
"labels": ["dependencies", "webapp"]
5649
},
5750
{
5851
"matchFileNames": ["webapp/package.json"],
5952
"matchDepTypes": ["devDependencies"],
6053
"matchUpdateTypes": ["minor", "patch"],
61-
"groupName": "Webapp developer tooling",
6254
"labels": ["dependencies", "webapp"]
6355
},
6456
{
6557
"matchFileNames": ["docs/package.json"],
6658
"matchDepTypes": ["dependencies"],
6759
"matchUpdateTypes": ["minor", "patch"],
68-
"groupName": "Documentation runtime dependencies",
6960
"labels": ["dependencies", "docs"]
7061
},
7162
{
7263
"matchFileNames": ["docs/package.json"],
7364
"matchDepTypes": ["devDependencies"],
7465
"matchUpdateTypes": ["minor", "patch"],
75-
"groupName": "Documentation developer tooling",
7666
"labels": ["dependencies", "docs"]
7767
},
7868
{
7969
"matchUpdateTypes": ["major"],
8070
"minimumReleaseAge": "7 days",
81-
"labels": ["dependencies", "breaking"]
71+
"labels": ["dependencies", "breaking"],
72+
"dependencyDashboardApproval": true
8273
},
8374
{
8475
"matchPackageNames": [
@@ -88,8 +79,7 @@
8879
"/^@tanstack/",
8980
"/^org\\.springframework/"
9081
],
91-
"matchUpdateTypes": ["minor"],
92-
"groupName": "Core frameworks"
82+
"matchUpdateTypes": ["minor"]
9383
},
9484
{
9585
"matchManagers": ["github-actions"],
@@ -103,7 +93,8 @@
10393
},
10494
{
10595
"matchUpdateTypes": ["major"],
106-
"groupName": null
96+
"groupName": null,
97+
"dependencyDashboardApproval": true
10798
}
10899
],
109100
"customManagers": [
@@ -135,6 +126,16 @@
135126
],
136127
"versioningTemplate": "semver"
137128
},
129+
{
130+
"description": "Track the OpenAPI Generator CLI distribution",
131+
"customType": "regex",
132+
"managerFilePatterns": ["/^openapitools\\.json$/"],
133+
"matchStrings": ["\"version\": \"(?<currentValue>[^\"]+)\""],
134+
"datasourceTemplate": "github-releases",
135+
"depNameTemplate": "OpenAPITools/openapi-generator",
136+
"extractVersionTemplate": "^v(?<version>.*)$",
137+
"versioningTemplate": "semver"
138+
},
138139
{
139140
"description": "Track release image tags and digests",
140141
"customType": "regex",
@@ -153,5 +154,10 @@
153154
"automerge": false,
154155
"groupName": null
155156
},
156-
"ignorePaths": ["**/node_modules/**", "**/target/**", "**/dist/**", "**/build/**"]
157+
"ignorePaths": ["**/node_modules/**", "**/target/**", "**/dist/**", "**/build/**"],
158+
"prConcurrentLimit": 5,
159+
"branchConcurrentLimit": 10,
160+
"prHourlyLimit": 2,
161+
"osvVulnerabilityAlerts": true,
162+
"dependencyDashboardOSVVulnerabilitySummary": "unresolved"
157163
}

scripts/check-agent-runtime-pins.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { readFileSync } from "node:fs";
22

3+
import { parse } from "yaml";
4+
35
import { asRecord, isRecord, parseJson } from "./lib/json.ts";
46

57
const dockerfile = readFileSync("docker/agents/pi/Dockerfile", "utf8");
8+
const webappDockerfile = readFileSync("webapp/Dockerfile", "utf8");
69
const packageJson = asRecord(parseJson(readFileSync("package.json", "utf8")), "package.json");
10+
const imagePackageJson = asRecord(
11+
parseJson(readFileSync("docker/agents/pi/package.json", "utf8")),
12+
"docker/agents/pi/package.json",
13+
);
714
const devDependencies = isRecord(packageJson.devDependencies) ? packageJson.devDependencies : {};
815
const problems: string[] = [];
916

@@ -26,12 +33,42 @@ if (repoNodeVersion !== nodeVersion) {
2633
`package.json#devEngines.runtime pins Node ${String(repoNodeVersion)} but the agent image pins ${nodeVersion}.`,
2734
);
2835
}
36+
const webappNodeVersion = /^ARG NODE_VERSION=(\S+)$/m.exec(webappDockerfile)?.[1];
37+
if (webappNodeVersion !== nodeVersion) {
38+
problems.push(
39+
`webapp/Dockerfile pins Node ${String(webappNodeVersion)} but the agent image pins ${nodeVersion}.`,
40+
);
41+
}
2942
const packagePiVersion = devDependencies["@earendil-works/pi-coding-agent"];
3043
if (packagePiVersion !== piVersion) {
3144
problems.push(
3245
`package.json pins Pi ${String(packagePiVersion)} but the agent image pins ${piVersion}.`,
3346
);
3447
}
48+
const imageDependencies = isRecord(imagePackageJson.dependencies)
49+
? imagePackageJson.dependencies
50+
: {};
51+
if (imageDependencies["@earendil-works/pi-coding-agent"] !== piVersion) {
52+
problems.push(
53+
`docker/agents/pi/package.json pins Pi ${String(imageDependencies["@earendil-works/pi-coding-agent"])} but the agent image pins ${piVersion}.`,
54+
);
55+
}
56+
57+
const imageLock = asRecord(
58+
parse(readFileSync("docker/agents/pi/pnpm-lock.yaml", "utf8")),
59+
"docker/agents/pi/pnpm-lock.yaml",
60+
);
61+
const lockImporters = isRecord(imageLock.importers) ? imageLock.importers : {};
62+
const lockRoot = isRecord(lockImporters["."]) ? lockImporters["."] : {};
63+
const lockDependencies = isRecord(lockRoot.dependencies) ? lockRoot.dependencies : {};
64+
const lockedPi = isRecord(lockDependencies["@earendil-works/pi-coding-agent"])
65+
? lockDependencies["@earendil-works/pi-coding-agent"]
66+
: {};
67+
if (lockedPi.specifier !== piVersion || !String(lockedPi.version).startsWith(`${piVersion}(`)) {
68+
problems.push(
69+
`docker/agents/pi/pnpm-lock.yaml does not resolve the image's Pi ${piVersion} dependency.`,
70+
);
71+
}
3572

3673
const javaPin = /private static final String PI_SDK_VERSION = "([^"]+)";/;
3774
for (const path of [

scripts/check-package-manager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ const dockerfile = readFileSync("webapp/Dockerfile", "utf8");
137137
if (!dockerfile.includes(`ghcr.io/pnpm/pnpm:${version}@sha256:`)) {
138138
throw new Error(`webapp/Dockerfile must use the digest-pinned pnpm ${version} image`);
139139
}
140-
if (!dockerfile.includes(`pnpm runtime set node ${runtime.version} -g`)) {
140+
if (
141+
!dockerfile.includes(`ARG NODE_VERSION=${runtime.version}`) ||
142+
!dockerfile.includes(["pnpm runtime set node $", "{NODE_VERSION} -g"].join(""))
143+
) {
141144
throw new Error(`webapp/Dockerfile must install Node ${runtime.version} through pnpm`);
142145
}
143146

scripts/renovate-config.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import assert from "node:assert/strict";
2+
import { readFile } from "node:fs/promises";
3+
import { test } from "node:test";
4+
5+
import { isRecord, parseJson } from "./lib/json.ts";
6+
7+
const parsedConfig: unknown = parseJson(await readFile("renovate.json", "utf8"));
8+
assert.ok(isRecord(parsedConfig));
9+
const config = parsedConfig;
10+
assert.ok(Array.isArray(config.extends));
11+
assert.ok(config.extends.every((entry) => typeof entry === "string"));
12+
const extensions = config.extends;
13+
14+
void test("Renovate creates bounded update PRs without a manual dispatch queue", () => {
15+
assert.ok(extensions.includes("config:best-practices"));
16+
assert.ok(!extensions.includes(":dependencyDashboardApproval"));
17+
assert.deepEqual(config.schedule, ["before 7am every weekday"]);
18+
assert.equal(config.prHourlyLimit, 2);
19+
assert.equal(config.prConcurrentLimit, 5);
20+
assert.equal(config.branchConcurrentLimit, 10);
21+
assert.ok(
22+
Array.isArray(config.packageRules) &&
23+
config.packageRules.some(
24+
(rule) =>
25+
isRecord(rule) &&
26+
rule.dependencyDashboardApproval === true &&
27+
Array.isArray(rule.matchUpdateTypes) &&
28+
rule.matchUpdateTypes.includes("major"),
29+
),
30+
);
31+
});
32+
33+
void test("vulnerability remediation bypasses normal update latency", () => {
34+
assert.equal(config.osvVulnerabilityAlerts, true);
35+
assert.equal(config.dependencyDashboardOSVVulnerabilitySummary, "unresolved");
36+
assert.deepEqual(config.vulnerabilityAlerts, {
37+
enabled: true,
38+
minimumReleaseAge: null,
39+
dependencyDashboardApproval: false,
40+
automerge: false,
41+
groupName: null,
42+
labels: ["security", "dependencies"],
43+
});
44+
});
45+
46+
void test("every custom manager extracts a dependency from its real source", async () => {
47+
const sources = new Map<string, string[]>([
48+
["Track Dockerfile ARG version pins", ["docker/agents/pi/Dockerfile", "webapp/Dockerfile"]],
49+
[
50+
"Track release security tool versions",
51+
[".github/actions/setup-release-security-tools/action.yml"],
52+
],
53+
["Track the Zizmor CLI version", [".github/workflows/cicd.yml"]],
54+
["Track the OpenAPI Generator CLI distribution", ["openapitools.json"]],
55+
["Track release image tags and digests", ["security/release-images.json"]],
56+
]);
57+
assert.ok(Array.isArray(config.customManagers));
58+
assert.ok(config.customManagers.every(isRecord));
59+
assert.deepEqual(
60+
new Set(config.customManagers.map((manager) => manager.description)),
61+
new Set(sources.keys()),
62+
);
63+
for (const manager of config.customManagers) {
64+
if (typeof manager.description !== "string") throw new TypeError("manager description");
65+
const description = manager.description;
66+
assert.ok(Array.isArray(manager.matchStrings));
67+
const files = sources.get(description);
68+
assert.ok(files);
69+
const contents = await Promise.all(files.map((file) => readFile(file, "utf8")));
70+
for (const pattern of manager.matchStrings) {
71+
if (typeof pattern !== "string") throw new TypeError("manager match pattern");
72+
assert.ok(
73+
contents.some((content) => new RegExp(pattern, "m").test(content)),
74+
`${description} no longer matches its source`,
75+
);
76+
}
77+
}
78+
});

0 commit comments

Comments
 (0)