diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index 533acff0..e8f92775 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -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 证据。 | diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 858180ba..0264de2e 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -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"]); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index d91571b8..c2af4633 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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;