fix: prevent v02 cicd full rebuild regressions

This commit is contained in:
Codex
2026-05-31 05:32:05 +08:00
parent bca1fcbe43
commit 6da43e5a8e
3 changed files with 206 additions and 20 deletions
+84 -14
View File
@@ -31,6 +31,17 @@ export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package-lock.json"
]);
export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([
"dependencies",
"optionalDependencies",
"peerDependencies",
"overrides",
"resolutions",
"engines",
"packageManager",
"type"
]);
export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([
"internal/protocol/",
"internal/build-metadata.mjs"
@@ -56,8 +67,7 @@ const serviceSpecificPaths = Object.freeze({
"internal/cloud/",
"internal/db/",
"internal/audit/",
"tools/",
"skills/"
"skills/hwlab-agent-runtime/"
],
"hwlab-cloud-web": [
"web/hwlab-cloud-web/",
@@ -66,7 +76,13 @@ const serviceSpecificPaths = Object.freeze({
"internal/dev-entrypoint/cloud-web-routes.mjs"
],
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
"hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"],
"hwlab-agent-worker": [
"cmd/hwlab-agent-worker/",
"internal/agent/",
"internal/db/",
"internal/audit/",
"skills/hwlab-agent-runtime/"
],
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
@@ -102,6 +118,7 @@ export async function createG14CiPlan(options = {}) {
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
const serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
@@ -109,14 +126,15 @@ export async function createG14CiPlan(options = {}) {
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
...DEFAULT_RUNTIME_DEP_PATHS
]);
], { semanticPackageJson: true });
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
const services = [];
for (const model of componentModels) {
const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths);
const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths);
const runtimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const rawRuntimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const runtimeDepMatches = rawRuntimeDepMatches.filter((item) => semanticRuntimeDepPaths.has(item));
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
const envReuse = lane === "v02" && envReuseServices.has(model.serviceId);
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
@@ -129,7 +147,10 @@ export async function createG14CiPlan(options = {}) {
...model.sharedPaths,
bootSh
]) : [];
const envMatches = envReuse ? matchingPaths(normalizedChangedPaths, envInputPaths) : [];
const envMatches = envReuse
? matchingPaths(normalizedChangedPaths, envInputPaths)
.filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item))
: [];
const codeMatches = envReuse ? matchingPaths(normalizedChangedPaths, codeInputPaths) : [];
const relevantEnvMatches = envMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
const relevantCodeMatches = codeMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
@@ -141,7 +162,8 @@ export async function createG14CiPlan(options = {}) {
].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)));
const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null;
const environmentInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, envInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
semanticPackageJson: true
}) : null;
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
@@ -149,7 +171,7 @@ export async function createG14CiPlan(options = {}) {
const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady || catalogRecord?.environmentInputHash !== environmentInputHash : null;
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null;
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
const catalogReuse = envReuse ? envReuseCandidate(catalogRecord, envChanged) : reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0);
const affected = envReuse
@@ -164,7 +186,8 @@ export async function createG14CiPlan(options = {}) {
]);
const componentCommitId = await lastCommitForPaths(repoRoot, targetRef, componentInputPaths);
const componentInputHash = await hashGitPaths(repoRoot, targetRef, componentInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
semanticPackageJson: true
});
const buildArgsHash = stableHash({
serviceId: model.serviceId,
@@ -273,6 +296,46 @@ export function componentModelsForServices(serviceIds) {
});
}
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
const result = new Set();
for (const filePath of changedPaths) {
if (filePath === "package-lock.json") {
result.add(filePath);
continue;
}
if (filePath !== "package.json") continue;
if (await packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath);
}
return result;
}
export async function packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath = "package.json") {
const [before, after] = await Promise.all([
readJsonFromGit(repoRoot, baseRef, filePath, null),
readJsonFromGit(repoRoot, targetRef, filePath, null)
]);
if (!before || !after) return true;
return stableJson(pickRuntimePackageJson(before)) !== stableJson(pickRuntimePackageJson(after));
}
function pickRuntimePackageJson(packageJson) {
if (!packageJson || typeof packageJson !== "object") return null;
const picked = {};
for (const field of DEFAULT_RUNTIME_PACKAGE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(packageJson, field)) picked[field] = packageJson[field];
}
return picked;
}
async function readJsonFromGit(repoRoot, ref, filePath, fallback) {
try {
const value = await gitValue(repoRoot, ["show", `${ref}:${filePath}`]);
return JSON.parse(value);
} catch {
return fallback;
}
}
export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
if (lane !== "v02") return new Set();
const configured = deployJson?.lanes?.v02?.envReuseServices;
@@ -360,13 +423,19 @@ export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) {
const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean));
if (normalizedPaths.length === 0) return stableHash({ empty: true });
const output = await gitValue(repoRoot, ["ls-tree", "-r", targetRef, "--", ...normalizedPaths]).catch(() => "");
const lines = output.split("\n")
const lines = (await Promise.all(output.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => {
.map(async (line) => {
const filePath = line.slice(line.indexOf("\t") + 1);
return !options.skipPath?.(filePath);
})
if (options.skipPath?.(filePath)) return null;
if (options.semanticPackageJson === true && filePath === "package.json") {
const packageJson = await readJsonFromGit(repoRoot, targetRef, filePath, null);
return `100644 blob ${stableHash(pickRuntimePackageJson(packageJson))}\t${filePath}`;
}
return line;
})))
.filter(Boolean)
.sort();
return stableHash({ entries: lines });
}
@@ -393,7 +462,8 @@ function resolveServiceIds({ deployJson, options }) {
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
try {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;