Merge pull request #606 from pikasTech/fix/v02-cicd-package-script-fastlane
fix: 防止 v0.2 CI/CD 全量重建退化
This commit is contained in:
@@ -133,7 +133,7 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B
|
||||
| 剪裁 fast-path 探针 | 约 50s | `prepare-source` 约 11s;`gitops-promote` 约 7s;`runtime-ready` 约 17s | runtime 有实际变化时的合理预算。 |
|
||||
| P1 no-op runtime skip | 约 37s | `prepare-source` 约 10s;`plan-artifacts` 约 6s;`collect-artifacts` 约 7s;`gitops-promote` 约 8s;`runtime-ready` 跳过 | source-only 且 runtime identity-only 变化时的目标预算。 |
|
||||
|
||||
当前预算判定:source-only、所有 service 都复用 artifact、GitOps runtime 只发生 source identity 变化时,总耗时应接近 40s;超过 50s 需要先查是否误触发 `runtime-ready`、是否发生 GitHub 直连、是否恢复了 `npm ci` 或无效 preflight。真正需要 rollout 的 code-only 变更允许约 50s,因为 `runtime-ready` 必须等待 Argo 与 workload 收敛。涉及 BuildKit publish、env image rebuild、registry push 或真实 runtime 滚动时,不适用 40s 预算,应按 affected service 的 build 耗时单独测量。
|
||||
当前预算判定:source-only、所有 service 都复用 artifact、GitOps runtime 只发生 source identity 变化时,总耗时应接近 40s;超过 50s 需要先查是否误触发 `runtime-ready`、是否发生 GitHub 直连、是否恢复了 `npm ci` 或无效 preflight。真正需要 rollout 的 code-only 变更允许约 50s,因为 `runtime-ready` 必须等待 Argo 与 workload 收敛。涉及 BuildKit publish、env image rebuild、registry push 或真实 runtime 滚动时,不适用 40s 预算,应按 affected service 的 build 耗时单独测量。混合变更必须按 service 作用域裁剪:只改 `package.json` 的 `scripts`、旧门禁入口、短连接 CLI、CLI 测试、非 runtime 文档或 device-pod host asset 时,不得触发无关 runtime service 全量 build;若同一变更确实同时改了 `internal/cloud/**`、device-pod code 和 skill bundle,则只允许对应 service build/rollout,其他 service 必须复用 catalog digest。
|
||||
|
||||
关键阶段预算如下:
|
||||
|
||||
@@ -153,7 +153,9 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B
|
||||
|
||||
Git 读写都走 `devops-infra` 本地 mirror/relay。读路径用 HTTP mirror checkout `v0.2` source 和 `v0.2-gitops` catalog;写路径把 promotion 推到 mirror/relay 的 `v0.2-gitops`,Argo 也从 mirror/relay 读取。这样 source clone、catalog fetch、GitOps clone/push 都落在集群内网络和本地磁盘,GitHub 只通过手动 `sync`/`flush` 进入或离开本地 mirror,不在 CI 关键路径内。
|
||||
|
||||
env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT` 和 `HWLAB_BOOT_SH` 表示本次代码启动身份。code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有 `environmentInputHash` 变化时才进入 env rebuild。
|
||||
env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT` 和 `HWLAB_BOOT_SH` 表示本次代码启动身份。code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有真实 env 输入变化或缺少可复用 env digest 时才进入 env rebuild。`package.json` 只按 dependency/runtime 字段参与 env/runtime hash,`scripts` 只属于开发入口,不得因为清理旧门禁脚本而重建所有 service 或重建 env image。
|
||||
|
||||
planner 必须按 service component model 裁剪 build。`tools/` 下的短连接 CLI 源码不是 runtime service 输入;`skills/` 只有 agent runtime 或 skills bundle 真正消费的子目录才影响对应 runtime;device-pod host CLI asset 属于 skills bundle 或 host 侧分发输入,不得让 cloud-api、agent-worker、gateway、edge-proxy 等服务重建。CI 读取 artifact catalog 时支持 repo 相对路径和绝对路径,便于用真实 catalog 做本地热探测;catalog 缺失才允许 fail closed 到 rebuild,不得把“诊断命令没读到 catalog”误判为生产路径必须全量构建。
|
||||
|
||||
P1 no-op runtime skip 只跳过无实际 runtime 变化的等待。planner 输出 `buildServices=[]` 且 `rolloutServices=[]` 后,`gitops-promote` 会在写入前对旧 `runtime-v02` 与新 render 结果做归一化比较:忽略 source commit、artifact source commit、boot commit 和等价 commit env/annotation 这类 identity-only 字段。如果归一化结果相同,promotion 输出 `skipped-runtime-unchanged`,把 Tekton result `runtime-ready-required=false` 写出,并跳过 GitOps commit、push、Argo hard refresh 和 `runtime-ready`。如果 workload spec、image digest、env image、SecretRef、Service、Ingress、FRP、ConfigMap 或 rollout service 有实际变化,必须保持 `runtime-ready-required=true` 并等待 runtime 收敛。
|
||||
|
||||
@@ -205,7 +207,7 @@ done
|
||||
| `prepare-source` 回到 20s 以上 | 日志是否出现 `npm ci`、npm registry probe、GitHub URL、SSH setup 或长时间 catalog fetch | 拆掉无效依赖安装和重复探针;读路径必须回到 mirror。 |
|
||||
| `source-clone` 超过 5s | PipelineRun 参数 `git-read-url`、mirror service、PVC I/O、`git-mirror status` | 不要改回 GitHub 直连;先修 mirror/read service。 |
|
||||
| catalog 缺失导致全量 build | `prepare-source` 是否从 `v0.2-gitops` 取到 `deploy/artifact-catalog.v02.json` | 先 `git-mirror sync --confirm`;必要时查 mirror refs/object closure。 |
|
||||
| `buildSkippedCount` 不是 9 | `g14-ci-plan` 的 `affectedServices`、`buildServices`、`rolloutServices` 和 changed path summary | 真实业务变更可以 build;CI/CD 文档或 render-only 变更不应误触发全量 build。 |
|
||||
| `buildSkippedCount` 偏低或突然变成 0 | `g14-ci-plan` 的 `affectedServices`、`buildServices`、`rolloutServices`、catalog 加载状态、`package.json` 字段 diff 和 service component path | 真实业务变更可以 build;脚本清理、CLI/测试/文档、host asset 或 code-only env-reuse 变更不应误触发全量 build。 |
|
||||
| no-op 仍执行 `runtime-ready` | `gitops-promote` 是否输出 `runtime-ready-required=false` 和 `skipped-runtime-unchanged` | 查 runtime 归一化比较;不要直接删除 `runtime-ready`,只修 no-op 判定。 |
|
||||
| mirror `pendingFlush=true` 长期存在 | `bun scripts/cli.ts hwlab g14 git-mirror status` 和最近一次 flush 错误 | 手动 `git-mirror flush --confirm`;flush 不影响已 rollout 本地 revision,但不能静默积压。 |
|
||||
| Argo `Synced/Healthy` 但公网不通 | `hwlab-v02-frpc` 日志、master frps allowlist、`19666/19667` | 修 FRP allowlist 或 frpc;不要把 DEV/PROD 入口当作 v02 证据。 |
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
classifyGlobalChange,
|
||||
componentModelsForServices,
|
||||
createG14CiPlan,
|
||||
matchingPaths
|
||||
matchingPaths,
|
||||
packageRuntimeFieldsChanged
|
||||
} from "./src/g14-ci-plan-lib.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -64,8 +65,7 @@ test("component model uses built-in service paths", () => {
|
||||
"internal/audit/",
|
||||
"internal/cloud/",
|
||||
"internal/db/",
|
||||
"skills/",
|
||||
"tools/"
|
||||
"skills/hwlab-agent-runtime/"
|
||||
]);
|
||||
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
|
||||
});
|
||||
@@ -83,6 +83,67 @@ test("planner scopes cloud-web runtime changes to cloud-web", async () => {
|
||||
assert.deepEqual(cloudWeb.reason, ["component-path-changed"]);
|
||||
});
|
||||
|
||||
test("planner ignores package script cleanup for runtime images", async () => {
|
||||
const repo = await createFixtureRepo({ includeDevicePod: true });
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
scripts: {
|
||||
validate: "node scripts/check-runner.mjs --profile validate"
|
||||
},
|
||||
dependencies: {
|
||||
"@openai/codex": "^0.128.0"
|
||||
}
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "package.json"]);
|
||||
await git(repo, ["commit", "-m", "seed package scripts"]);
|
||||
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
scripts: {
|
||||
validate: "node scripts/check-runner.mjs --profile validate",
|
||||
check: "node scripts/check-runner.mjs --profile check"
|
||||
},
|
||||
dependencies: {
|
||||
"@openai/codex": "^0.128.0"
|
||||
}
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "package.json"]);
|
||||
await git(repo, ["commit", "-m", "change package scripts"]);
|
||||
|
||||
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false);
|
||||
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
|
||||
assert.deepEqual(plan.affectedServices, []);
|
||||
assert.deepEqual(plan.buildServices, []);
|
||||
assert.equal(plan.imageBuildRequired, false);
|
||||
assert.equal(plan.buildSkippedCount, 3);
|
||||
});
|
||||
|
||||
test("planner treats dependency changes as runtime image inputs", async () => {
|
||||
const repo = await createFixtureRepo({ includeDevicePod: true });
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@openai/codex": "^0.128.0"
|
||||
}
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "package.json"]);
|
||||
await git(repo, ["commit", "-m", "seed dependencies"]);
|
||||
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@openai/codex": "^0.129.0"
|
||||
}
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "package.json"]);
|
||||
await git(repo, ["commit", "-m", "change dependencies"]);
|
||||
|
||||
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true);
|
||||
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
|
||||
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"]);
|
||||
assert.equal(plan.imageBuildRequired, true);
|
||||
});
|
||||
|
||||
test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => {
|
||||
const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true });
|
||||
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n");
|
||||
@@ -148,6 +209,54 @@ test("v02 planner marks device-pod code-only change as env reuse rollout", async
|
||||
assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
|
||||
});
|
||||
|
||||
test("v02 planner keeps CLI and host skill changes out of unrelated service builds", async () => {
|
||||
const repo = await createFixtureRepo({ includeDevicePod: true });
|
||||
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
const initialPlan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD",
|
||||
targetRef: initialSha,
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
||||
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"]
|
||||
});
|
||||
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
devicePodEnvironmentInputHash: initialDevicePod.environmentInputHash,
|
||||
devicePodCodeInputHash: initialDevicePod.codeInputHash,
|
||||
devicePodBootCommit: initialPlan.sourceCommitId
|
||||
}), null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
||||
await git(repo, ["commit", "-m", "seed v02 catalog"]);
|
||||
|
||||
await writeFile(path.join(repo, "tools/hwlab-cli/client.test.ts"), "console.log('cli test');\n");
|
||||
await writeFile(path.join(repo, "tools/src/hwlab-cli-lib.ts"), "export const harness = true;\n");
|
||||
await writeFile(path.join(repo, "skills/device-pod-cli/assets/device-host-cli.mjs"), "console.log('host cli');\n");
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
scripts: {
|
||||
validate: "node scripts/check-runner.mjs --profile validate",
|
||||
check: "node scripts/check-runner.mjs --profile check"
|
||||
}
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "."]);
|
||||
await git(repo, ["commit", "-m", "change cli and host asset"]);
|
||||
|
||||
const plan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD~1",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
||||
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"]
|
||||
});
|
||||
assert.deepEqual(plan.buildServices, ["hwlab-agent-skills"]);
|
||||
assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills"]);
|
||||
assert.equal(plan.buildSkippedCount, 3);
|
||||
assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false);
|
||||
assert.equal(plan.services.find((service) => service.serviceId === "hwlab-device-pod").affected, false);
|
||||
});
|
||||
|
||||
async function createFixtureRepo(options = {}) {
|
||||
const deployServices = options.deployServices !== false;
|
||||
const k3sServiceMappings = options.k3sServiceMappings === true;
|
||||
@@ -158,6 +267,10 @@ async function createFixtureRepo(options = {}) {
|
||||
await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true });
|
||||
await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true });
|
||||
await mkdir(path.join(repo, "internal/device-pod"), { recursive: true });
|
||||
await mkdir(path.join(repo, "tools/hwlab-cli"), { recursive: true });
|
||||
await mkdir(path.join(repo, "tools/src"), { recursive: true });
|
||||
await mkdir(path.join(repo, "skills/device-pod-cli/assets"), { recursive: true });
|
||||
await mkdir(path.join(repo, "skills/hwlab-agent-runtime"), { recursive: true });
|
||||
await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true });
|
||||
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
|
||||
await mkdir(path.join(repo, "deploy"), { recursive: true });
|
||||
@@ -181,6 +294,7 @@ async function createFixtureRepo(options = {}) {
|
||||
await writeFile(path.join(repo, "internal/device-pod/executor.ts"), "export const version = 1;\n");
|
||||
await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-device-pod.sh"), "#!/bin/sh\nexec bun cmd/hwlab-device-pod/main.ts\n");
|
||||
await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n");
|
||||
await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n");
|
||||
await git(repo, ["init"]);
|
||||
await git(repo, ["add", "."]);
|
||||
await git(repo, ["commit", "-m", "initial"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user