diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index e7eedff0..fff34b73 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -32,6 +32,7 @@ const catalogPath = "deploy/artifact-catalog.dev.json"; const deployPath = "deploy/deploy.json"; const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); +const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u; const fatalBlockerTypes = new Set([ "contract_blocker", "environment_blocker", @@ -75,6 +76,7 @@ function parseArgs(argv) { reportPath: defaultReportPath, services: [...SERVICE_IDS], servicesExplicit: false, + affectedOnly: true, emitReport: true, quietBuild: false, concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4) @@ -92,6 +94,8 @@ function parseArgs(argv) { args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); args.servicesExplicit = true; } + else if (arg === "--affected-only") args.affectedOnly = true; + else if (arg === "--full-build") args.affectedOnly = false; else if (arg === "--no-report") args.emitReport = false; else if (arg === "--quiet-build") args.quietBuild = true; else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4); @@ -175,6 +179,8 @@ function printHelp() { " --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab", " --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local", " --services LIST comma-separated service IDs; default: all frozen DEV services", + " --affected-only default: build/publish only g14-ci-plan affected services and reuse unchanged catalog digests", + " --full-build compatibility fallback: build/publish every required service", ` --report PATH default: ${defaultReportPath}`, " --no-report print JSON without updating the temporary artifact JSON", " --quiet-build keep docker build quiet; default keeps build output visible", @@ -923,13 +929,14 @@ function sourceStateBlocker(service) { }); } -async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }) { +async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds = new Set() }) { const blockers = []; let registryPrefix = args.registryPrefix; + const buildRequired = buildServiceIds.size > 0 || !args.affectedOnly; if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; - } else { + } else if (buildRequired) { blockers.push(baseImagePreflightBlocker(baseImagePreflight)); } @@ -941,7 +948,7 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre type: "safety_blocker", scope: "registry", summary: error.message, - next: "Use the D601 local/internal registry prefix, for example 127.0.0.1:5000/hwlab." + next: "Use the G14 local/internal registry prefix, for example 127.0.0.1:5000/hwlab." }) ); } @@ -973,13 +980,13 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre } } - if (registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") { + if (buildRequired && registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") { blockers.push( blocker({ type: "network_blocker", scope: "docker-daemon-push-access", summary: registryCapabilities.dimensions.dockerDaemonPushAccess.summary, - next: "Restore Docker daemon access to the D601 local/internal registry before running --publish." + next: "Restore Docker daemon access to the G14 local/internal registry before running --publish." }) ); } @@ -1217,7 +1224,7 @@ async function publishService(artifact) { type: "network_blocker", scope: artifact.serviceId, summary: `docker push failed for ${artifact.image}`, - next: "Verify the D601 local/internal registry is reachable from the Docker daemon and rerun --publish." + next: "Verify the G14 local/internal registry is reachable from the Docker daemon and rerun --publish." }), logTail: tailText(`${result.stdout}\n${result.stderr}`), publishDurationMs: result.durationMs @@ -1256,13 +1263,16 @@ function repositoryFromImageRef(image) { return lastColon === -1 ? image : image.slice(0, lastColon); } +const publishReadyStatuses = new Set(["published", "reused"]); +const buildReadyStatuses = new Set(["built", "published", "published_unverified_digest", "reused"]); + function reportStatus(mode, artifacts, blockers) { const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired); if (mode === "preflight") return blockers.length ? "blocked" : "pass"; if (requiredArtifacts.some((artifact) => artifact.status === "publish_failed" || artifact.status === "build_failed")) return "failed"; if (requiredArtifacts.some((artifact) => artifact.status.startsWith("blocked_"))) return "blocked"; - if (mode === "publish" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => artifact.status === "published")) return "published"; - if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => artifact.status === "built")) return "built"; + if (mode === "publish" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => publishReadyStatuses.has(artifact.status))) return "published"; + if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => buildReadyStatuses.has(artifact.status))) return "built"; return blockers.length ? "blocked" : "pass"; } @@ -1294,9 +1304,21 @@ function artifactNotPublishedReason(artifact, mode, fatalBlocked) { return artifact.notPublishedReason ?? "publish_not_run"; } +function imageTagFromReference(image) { + const value = String(image ?? ""); + const slashIndex = value.lastIndexOf("/"); + const colonIndex = value.lastIndexOf(":"); + return colonIndex > slashIndex ? value.slice(colonIndex + 1) : null; +} + +function repositoryDigestFor(image, digest) { + return image && shaDigestPattern.test(digest ?? "") ? `${repositoryFromImageRef(image)}@${digest}` : null; +} + function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) { return { ...service, + commitId: shortCommit, sourceCommitId: commitId, status, image: imageRef(args.registryPrefix, service.serviceId, shortCommit), @@ -1308,6 +1330,43 @@ function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = }; } +function artifactRecordForCatalogReuse({ service, catalogService, commitId }) { + const image = catalogService?.image ?? null; + const digest = catalogService?.digest ?? "not_published"; + const imageTag = catalogService?.imageTag ?? imageTagFromReference(image); + const catalogCommitId = catalogService?.commitId ?? imageTag ?? null; + if (!image || !imageTag || !shaDigestPattern.test(digest)) { + return { + ...service, + commitId: catalogCommitId, + sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId, + status: "blocked_reuse_unavailable", + image: image ?? null, + imageTag, + buildCreatedAt: catalogService?.buildCreatedAt ?? null, + buildSource: catalogService?.buildSource ?? null, + digest, + repositoryDigest: repositoryDigestFor(image, digest), + reusedFrom: null, + notPublishedReason: "reuse_catalog_digest_unavailable" + }; + } + return { + ...service, + commitId: catalogCommitId, + sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId, + status: "reused", + image, + imageTag, + buildCreatedAt: catalogService?.buildCreatedAt ?? null, + buildSource: catalogService?.buildSource ?? null, + digest, + repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest), + reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId, + notPublishedReason: null + }; +} + function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) { return { version: "v2", @@ -1325,7 +1384,8 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo enabled: artifact.publishEnabled, required: artifact.artifactRequired, artifactScope: artifact.artifactScope, - sourceCommitId: commitId, + commitId: artifact.commitId ?? shortCommit, + sourceCommitId: artifact.sourceCommitId ?? commitId, registryTarget: imageRepository(args.registryPrefix, artifact.serviceId), image, imageTag: artifact.imageTag ?? shortCommit, @@ -1347,6 +1407,7 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo ciAffected: artifact.ciAffected ?? null, ciReason: artifact.ciReason ?? [], reuse: artifact.reuse ?? null, + reusedFrom: artifact.reusedFrom ?? null, notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked) }; }) @@ -1419,7 +1480,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif const serviceInventory = serviceInventoryFromServices(services); const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }); const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length; - const builtCount = requiredArtifacts.filter((artifact) => ["built", "published", "published_unverified_digest"].includes(artifact.status)).length; + const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length; + const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length; return { $schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json", @@ -1502,6 +1564,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImage: args.baseImage ?? null, selectedServiceIds: args.services, + affectedOnly: args.affectedOnly, quietBuild: args.quietBuild, concurrency: args.concurrency, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), @@ -1515,6 +1578,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif disabledServiceCount: serviceInventory.disabledServiceCount, builtCount, publishedCount, + reusedCount, timings: { dockerBuildDurationMs: sumDurations(artifacts, "dockerBuildDurationMs"), cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"), @@ -1524,6 +1588,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif services: artifacts.map((artifact) => ({ serviceId: artifact.serviceId, status: artifact.status, + commitId: artifact.commitId ?? shortCommit, + sourceCommitId: artifact.sourceCommitId ?? commitId, image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit), imageTag: artifact.imageTag ?? shortCommit, buildCreatedAt: artifact.buildCreatedAt ?? null, @@ -1552,6 +1618,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif ciAffected: artifact.ciAffected ?? null, ciReason: artifact.ciReason ?? [], reuse: artifact.reuse ?? null, + reusedFrom: artifact.reusedFrom ?? null, dockerBuildLogTail: artifact.dockerBuildLogTail ?? null, logTail: artifact.logTail ?? null, pushLogTail: artifact.pushLogTail ?? null @@ -1616,8 +1683,15 @@ async function main() { ...(args.servicesExplicit ? { services: args.services } : {}) }); const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan); + const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service])); + const affectedServiceIds = new Set(ciPlan?.affectedServices ?? []); + const buildServiceIds = new Set( + args.affectedOnly + ? services.filter((service) => service.artifactRequired && affectedServiceIds.has(service.serviceId)).map((service) => service.serviceId) + : services.filter((service) => service.artifactRequired).map((service) => service.serviceId) + ); - let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }); + let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds }); let artifacts = services.map((service) => artifactRecord({ args, service, @@ -1625,6 +1699,40 @@ async function main() { shortCommit, status: service.artifactRequired ? "preflight_only" : "not_required" })); + if (args.affectedOnly && (args.mode === "build" || args.mode === "publish")) { + artifacts = services.map((service) => { + if (!service.artifactRequired || buildServiceIds.has(service.serviceId)) { + return artifactRecord({ + args, + service, + commitId, + shortCommit, + status: service.artifactRequired ? "preflight_only" : "not_required" + }); + } + const reused = artifactRecordForCatalogReuse({ + service, + catalogService: catalogByServiceId.get(service.serviceId), + commitId + }); + emit("artifact_reuse", { + serviceId: service.serviceId, + status: reused.status, + image: reused.image, + digest: reused.digest, + reusedFrom: reused.reusedFrom ?? null + }); + if (reused.status.startsWith("blocked_")) { + blockers.push(blocker({ + type: "environment_blocker", + scope: service.serviceId, + summary: `${service.serviceId} cannot be reused because deploy/artifact-catalog.dev.json has no verified digest`, + next: "Run the default full publish once, or refresh the service catalog from a successful publish report before using --affected-only." + })); + } + return reused; + }); + } if ((args.mode === "build" || args.mode === "publish") && hasFatalBlocker(blockers)) { emit("publish_blocked", { @@ -1632,7 +1740,7 @@ async function main() { }); } else if (args.mode === "build" || args.mode === "publish") { const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact])); - await mapWithConcurrency(services.filter((item) => item.artifactRequired), args.concurrency, async (service) => { + await mapWithConcurrency(services.filter((item) => buildServiceIds.has(item.serviceId)), args.concurrency, async (service) => { emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) }); const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }); artifactByServiceId.set(service.serviceId, artifact); @@ -1676,13 +1784,19 @@ async function main() { registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), selectedServiceIds: report.artifactPublish.selectedServiceIds, + affectedOnly: report.artifactPublish.affectedOnly, ciPlan: report.artifactPublish.ciPlan, concurrency: report.artifactPublish.concurrency, + builtCount: report.artifactPublish.builtCount, + publishedCount: report.artifactPublish.publishedCount, + reusedCount: report.artifactPublish.reusedCount, timings: report.artifactPublish.timings, publishPlan: report.artifactPublish.publishPlan, services: report.artifactPublish.services.map((service) => ({ serviceId: service.serviceId, status: service.status, + commitId: service.commitId, + sourceCommitId: service.sourceCommitId, image: service.image, imageTag: service.imageTag, buildCreatedAt: service.buildCreatedAt, @@ -1691,6 +1805,7 @@ async function main() { dockerBuildDurationMs: service.dockerBuildDurationMs, cloudWebBuildDurationMs: service.cloudWebBuildDurationMs, publishDurationMs: service.publishDurationMs, + reusedFrom: service.reusedFrom, logTail: service.logTail, pushLogTail: service.pushLogTail, notPublishedReason: service.notPublishedReason diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index 92bf1c2a..7bfcf290 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -23,6 +23,8 @@ const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.e const digestPattern = /^sha256:[a-f0-9]{64}$/; const commitPattern = /^[a-f0-9]{7,40}$/; +const publishReadyStatuses = new Set(["published", "reused"]); + function parseArgs(argv) { const args = { targetRef: "HEAD", @@ -122,6 +124,19 @@ function commitMatchesTarget(value, target) { return value === target.commitId || value === target.shortCommitId; } +function shortCommitForRecord(record, target) { + const value = record?.commitId ?? record?.imageTag ?? null; + if (typeof value === "string" && commitPattern.test(value)) return value.slice(0, 7); + if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return record.sourceCommitId.slice(0, 7); + return target.shortCommitId; +} + +function revisionForRecord(record, target) { + if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return record.sourceCommitId; + if (typeof record?.commitId === "string" && commitPattern.test(record.commitId)) return record.commitId; + return target.commitId; +} + function assertDevOnlyDeployAndCatalog(deploy, catalog) { assert.equal(deploy.environment, ENVIRONMENT_DEV, "deploy environment must be dev"); assert.equal(deploy.namespace, "hwlab-dev", "deploy namespace must be hwlab-dev"); @@ -160,16 +175,15 @@ function publishRecordsFromReport(report, target) { "publish plan must cover every frozen service" ); assert.equal( - artifactPublish.publishedCount, + (artifactPublish.publishedCount ?? 0) + (artifactPublish.reusedCount ?? 0), requiredServiceIds.length, - "publish report must publish every required enabled service" + "publish report must publish or reuse every required enabled service" ); const records = new Map(); for (const service of artifactPublish.services ?? []) { assert.ok(SERVICE_IDS.includes(service.serviceId), `unknown service ${service.serviceId} in publish report`); const image = parseTaggedImage(service.image, service.serviceId); - assert.equal(image.tag, target.shortCommitId, `${service.serviceId} image tag must match target short commit`); if (!requiredServiceIds.includes(service.serviceId)) { assert.equal(service.artifactRequired, false, `${service.serviceId} disabled service must not be required`); assert.equal(service.digest, "not_published", `${service.serviceId} disabled digest must stay not_published`); @@ -177,11 +191,17 @@ function publishRecordsFromReport(report, target) { continue; } - assert.equal(service.status, "published", `${service.serviceId} status must be published`); + assert.equal(publishReadyStatuses.has(service.status), true, `${service.serviceId} status must be published or reused`); assert.equal(service.artifactRequired, true, `${service.serviceId} required service must state artifactRequired=true`); + if (service.status === "published") { + assert.equal(image.tag, target.shortCommitId, `${service.serviceId} newly published image tag must match target short commit`); + } assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`); records.set(service.serviceId, { serviceId: service.serviceId, + status: service.status, + commitId: service.commitId ?? image.tag, + sourceCommitId: service.sourceCommitId ?? null, image: service.image, imageTag: image.tag, buildCreatedAt: service.buildCreatedAt ?? null, @@ -196,7 +216,8 @@ function publishRecordsFromReport(report, target) { buildArgsHash: service.buildArgsHash ?? null, ciAffected: service.ciAffected ?? null, ciReason: service.ciReason ?? [], - reuse: service.reuse ?? null + reuse: service.reuse ?? null, + reusedFrom: service.reusedFrom ?? null }); } @@ -206,13 +227,15 @@ function publishRecordsFromReport(report, target) { function updateEnvObject(env, service, target, publishRecord) { if (!env || typeof env !== "object" || Array.isArray(env)) return; - if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = target.shortCommitId; + const shortCommitId = shortCommitForRecord(publishRecord, target); + const revision = revisionForRecord(publishRecord, target); + if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = shortCommitId; if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image; if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag; - if (Object.hasOwn(env, "HWLAB_REVISION")) env.HWLAB_REVISION = target.commitId; + if (Object.hasOwn(env, "HWLAB_REVISION")) env.HWLAB_REVISION = revision; if (Object.hasOwn(env, "HWLAB_BUILD_CREATED_AT")) env.HWLAB_BUILD_CREATED_AT = publishRecord?.buildCreatedAt ?? ""; if (Object.hasOwn(env, "HWLAB_BUILD_SOURCE")) env.HWLAB_BUILD_SOURCE = publishRecord?.buildSource ?? ""; - if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = target.shortCommitId; + if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = shortCommitId; if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published"; } @@ -226,14 +249,16 @@ function serviceIdForWorkload(item, container) { function updateEnvList(envList, service, target, publishRecord) { if (!Array.isArray(envList)) return; + const shortCommitId = shortCommitForRecord(publishRecord, target); + const revision = revisionForRecord(publishRecord, target); for (const entry of envList) { - if (entry.name === "HWLAB_COMMIT_ID") entry.value = target.shortCommitId; + if (entry.name === "HWLAB_COMMIT_ID") entry.value = shortCommitId; if (entry.name === "HWLAB_IMAGE") entry.value = service.image; if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag; - if (entry.name === "HWLAB_REVISION") entry.value = target.commitId; + if (entry.name === "HWLAB_REVISION") entry.value = revision; if (entry.name === "HWLAB_BUILD_CREATED_AT") entry.value = publishRecord?.buildCreatedAt ?? ""; if (entry.name === "HWLAB_BUILD_SOURCE") entry.value = publishRecord?.buildSource ?? ""; - if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = target.shortCommitId; + if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = shortCommitId; if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published"; } } @@ -276,11 +301,12 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, const publishRecord = records?.get(serviceId) ?? null; const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId, registryPrefix); const imageTag = publishRecord?.imageTag ?? target.shortCommitId; + const serviceCommitId = shortCommitForRecord(publishRecord, target); deployService.image = image; updateEnvObject(deployService.env, { image, imageTag }, target, publishRecord); - catalogService.commitId = target.shortCommitId; + catalogService.commitId = serviceCommitId; catalogService.image = image; catalogService.imageTag = imageTag; catalogService.buildCreatedAt = publishRecord?.buildCreatedAt ?? null; @@ -295,7 +321,8 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, catalogService.ciAffected = publishRecord?.ciAffected ?? catalogService.ciAffected ?? null; catalogService.ciReason = publishRecord?.ciReason ?? catalogService.ciReason ?? []; catalogService.reuse = publishRecord?.reuse ?? catalogService.reuse ?? null; - catalogService.publishState = publishRecord ? "published" : "skeleton-only"; + catalogService.reusedFrom = publishRecord?.reusedFrom ?? catalogService.reusedFrom ?? null; + catalogService.publishState = publishRecord?.status === "reused" ? "reused" : publishRecord ? "published" : "skeleton-only"; catalogService.publishEnabled = inventory?.publishEnabled ?? required; catalogService.artifactRequired = required; catalogService.artifactScope = required ? "required" : "disabled"; @@ -313,6 +340,7 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, dockerfileHash: catalogService.dockerfileHash, ciAffected: catalogService.ciAffected, ciReason: catalogService.ciReason, + reusedFrom: catalogService.reusedFrom ?? null, publishState: catalogService.publishState, artifactRequired: catalogService.artifactRequired, notPublishedReason: catalogService.notPublishedReason diff --git a/scripts/refresh-artifact-catalog.test.mjs b/scripts/refresh-artifact-catalog.test.mjs index 656e876f..cf4be29d 100644 --- a/scripts/refresh-artifact-catalog.test.mjs +++ b/scripts/refresh-artifact-catalog.test.mjs @@ -92,3 +92,77 @@ test("refresh accepts an absolute publish report path", async () => { assert.equal(payload.services[0].ciAffected, true); assert.equal(payload.services[0].componentInputHash, `${String(1).padStart(64, "a")}`); }); + +test("refresh keeps reused service image tags from the publish report", async () => { + const commitId = await git(["rev-parse", "HEAD"]); + const shortCommitId = await git(["rev-parse", "--short=7", "HEAD"]); + const reusedTag = "d42c77d"; + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-refresh-reused-")); + const reportPath = path.join(tempDir, "dev-artifacts.json"); + await writeFile(reportPath, `${JSON.stringify({ + reportVersion: "v1", + taskId: "g14-artifact-publish", + commitId: shortCommitId, + artifactPublish: { + status: "published", + mode: "publish", + sourceCommitId: commitId, + registryPrefix: "127.0.0.1:5000/hwlab", + serviceCount: SERVICE_IDS.length, + requiredServiceCount: SERVICE_IDS.length, + disabledServiceCount: 0, + publishedCount: 1, + reusedCount: SERVICE_IDS.length - 1, + publishPlan: { + version: "v2", + services: SERVICE_IDS.map((serviceId, index) => ({ + serviceId, + required: true, + status: index === 0 ? "published" : "reused", + digest: digestFor(index), + image: `127.0.0.1:5000/hwlab/${serviceId}:${index === 0 ? shortCommitId : reusedTag}`, + imageTag: index === 0 ? shortCommitId : reusedTag + })) + }, + services: SERVICE_IDS.map((serviceId, index) => ({ + serviceId, + status: index === 0 ? "published" : "reused", + artifactRequired: true, + commitId: index === 0 ? shortCommitId : reusedTag, + sourceCommitId: index === 0 ? commitId : reusedTag, + image: `127.0.0.1:5000/hwlab/${serviceId}:${index === 0 ? shortCommitId : reusedTag}`, + imageTag: index === 0 ? shortCommitId : reusedTag, + digest: digestFor(index), + repositoryDigest: `127.0.0.1:5000/hwlab/${serviceId}@${digestFor(index)}`, + buildCreatedAt: "2026-05-24T00:00:00.000Z", + buildSource: index === 0 ? `pikasTech/HWLAB@${commitId}` : `pikasTech/HWLAB@${reusedTag}`, + componentCommitId: index === 0 ? commitId : reusedTag, + componentInputHash: `${String(index + 1).padStart(64, "d")}`, + dockerfileHash: `${String(index + 1).padStart(64, "e")}`, + buildArgsHash: `${String(index + 1).padStart(64, "f")}`, + ciAffected: index === 0, + ciReason: index === 0 ? ["component-path-changed"] : ["component-inputs-unchanged"], + reusedFrom: index === 0 ? null : `catalog:${reusedTag}` + })) + } + }, null, 2)}\n`); + + const result = await execFileAsync(process.execPath, [ + "scripts/refresh-artifact-catalog.mjs", + "--target-ref", + "HEAD", + "--publish-report", + reportPath, + "--no-write" + ], { + cwd: repoRoot, + timeout: 15000, + maxBuffer: 10 * 1024 * 1024 + }); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "published"); + assert.equal(payload.services[0].imageTag, shortCommitId); + assert.equal(payload.services[1].imageTag, reusedTag); + assert.equal(payload.services[1].publishState, "reused"); + assert.equal(payload.services[1].reusedFrom, `catalog:${reusedTag}`); +}); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 9690b6a5..536e9047 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -159,6 +159,7 @@ export async function createG14CiPlan(options = {}) { plannerMutatesCiJson: false, plannerMutatesDeployJson: false, serviceIdSource: serviceIdResolution.source, + defaultComponentLazyBuild: true, legacyFullBuildFallback: true }, inputs: {