fix: resolve dev cd artifact promotion boundary

This commit is contained in:
Code Queue Review
2026-05-23 12:51:02 +00:00
parent ff91f77427
commit 83d98ebf07
3 changed files with 667 additions and 38 deletions
+19 -4
View File
@@ -52,6 +52,19 @@ latest-main 发布时它可以等于当前 target refartifact merge commit
commander 可以始终从最新 `origin/main` 控制入口执行正式 DEV CD,而不需要
checkout promotion parent 或手工拼装发布环境。
状态面和事务报告会显式展示这条边界:`target.controlRef` 是最新
`origin/main` 控制入口,`target.promotionCommit` 是从 `deploy/deploy.json`
解析出的真实 promotion commit`target.artifactBoundary` 记录最近一次触碰
`deploy/deploy.json``deploy/artifact-catalog.dev.json`
`deploy/k8s/base/workloads.yaml``reports/dev-gate/dev-artifacts.json`
first-parent artifact merge commit,以及该 merge commit 的 promotion parent
和 artifact/report parent。`artifactCatalog``artifactReport``deployJson`
分别记录 hash、commit、digest 计数和 publish source。若 deploy/catalog/report
或 artifact merge parent 不能同时指向同一个 promotion commit,状态降级为
`degraded`,真实 `--apply` 在获取 Lease 前以 artifact-boundary blocker 停止。
这保证 `deploy/deploy.json` 仍是唯一发布控制源;Lease 只串行化事务,不保存第
二套 desired-state。
Lease 只是发布平面的互斥锁,不是 desired-state 来源。Lease annotations
至少记录 `promotionCommit``deployJsonHash``ownerTaskId`
`transactionId``phase``startedAt``updatedAt``ttlSeconds`
@@ -69,10 +82,12 @@ previous holder 保留在审计 annotations/report 字段中。未过期 Lease
`scripts/dev-cd-apply.mjs` 同时提供只读状态面。无参数、`--status`、或
`--dry-run` 都返回紧凑 JSON,覆盖 target ref/commit、
`deploy/deploy.json` commit/hash、当前 Lease 状态摘要、以及公开
`16666/16667` live health identity 摘要。这些模式 host commander 和
runner 都可以执行,但必须保持只读:不获取或替换 Lease,不 publish
artifact,不执行 `kubectl apply/rollout`,不写 report,不修改
`deploy.json`/catalog,也不读取或打印 Secret value。kubectl 或 kubeconfig
`16666/16667` live health identity 摘要。状态面还包含 `liveDelta`,用于对比
当前公开 live commit 与 `target.promotionCommit`;这只是 rollout 口径,不会在
status/dry-run 中执行 rollout。这些模式 host commander 和 runner 都可以执行,
但必须保持只读:不获取或替换 Lease,不 publish artifact,不执行
`kubectl apply/rollout`,不写 report,不修改 `deploy.json`/catalog,也不读取或
打印 Secret value。kubectl 或 kubeconfig
不可用时,状态面应返回 `lock.status=unavailable` 和脱敏原因,而不是把
只读观测失败升级成写路径 blocker。
+306 -32
View File
@@ -18,13 +18,21 @@ const defaultNamespace = "hwlab-dev";
const defaultLockName = "hwlab-dev-cd-lock";
const defaultTtlSeconds = 3600;
const defaultReportPath = "reports/dev-gate/dev-cd-apply.json";
const artifactCatalogPath = "deploy/artifact-catalog.dev.json";
const artifactReportPath = "reports/dev-gate/dev-artifacts.json";
const artifactDesiredStatePaths = [
"deploy/artifact-catalog.dev.json",
"deploy/deploy.json",
"deploy/k8s/base/workloads.yaml",
"reports/dev-gate/dev-artifacts.json"
];
const runtimeProvisioningReportPath = "reports/dev-gate/dev-runtime-provisioning-report.json";
const runtimeMigrationReportPath = "reports/dev-gate/dev-runtime-migration-report.json";
const runtimePostflightReportPath = "reports/dev-gate/dev-runtime-postflight-report.json";
const runtimeJobReportPrefix = "/tmp/hwlab-dev-runtime-report";
const browserLiveUrl = "http://74.48.78.17:16666/health/live";
const apiLiveUrl = `${DEV_ENDPOINT}/health/live`;
const digestPattern = /^sha256:[a-f0-9]{64}$/u;
const lockAnnotationPrefix = "hwlab.pikastech.local";
const lockAnnotationFields = {
promotionCommit: `${lockAnnotationPrefix}/promotionCommit`,
@@ -291,6 +299,87 @@ async function readDeployJson(repoRoot) {
};
}
async function readJsonFileSummary(repoRoot, relativePath, summarize) {
try {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
const json = JSON.parse(raw);
return {
path: relativePath,
hash: sha256(raw),
exists: true,
...summarize(json)
};
} catch (error) {
if (error?.code === "ENOENT") {
return {
path: relativePath,
hash: null,
exists: false,
error: "missing"
};
}
throw error;
}
}
function servicesArray(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === "object") return Object.values(value);
return [];
}
function summarizeArtifactCatalog(catalog) {
const services = servicesArray(catalog?.services);
const digestCounts = { sha256: 0, notPublished: 0, invalid: 0 };
for (const service of services) {
if (digestPattern.test(service?.digest ?? "")) digestCounts.sha256 += 1;
else if (service?.digest === "not_published") digestCounts.notPublished += 1;
else digestCounts.invalid += 1;
}
return {
kind: catalog?.kind ?? null,
commitId: catalog?.commitId ?? null,
artifactState: catalog?.artifactState ?? null,
ciPublished: catalog?.publish?.ciPublished ?? null,
registryVerified: catalog?.publish?.registryVerified ?? null,
provenance: catalog?.publish?.provenance ?? null,
serviceCount: services.length,
digestCounts
};
}
function summarizeArtifactReport(report) {
const publish = report?.artifactPublish ?? {};
const services = servicesArray(publish.services);
const requiredServices = services.filter((service) => service?.artifactRequired !== false);
const publishedRequiredServices = requiredServices.filter((service) => service?.status === "published");
const digestCounts = { sha256: 0, notPublished: 0, invalid: 0 };
for (const service of services) {
if (digestPattern.test(service?.digest ?? "")) digestCounts.sha256 += 1;
else if (service?.digest === "not_published") digestCounts.notPublished += 1;
else digestCounts.invalid += 1;
}
return {
commitId: report?.commitId ?? null,
status: report?.status ?? null,
sourceCommitId: publish.sourceCommitId ?? null,
registryPrefix: publish.registryPrefix ?? null,
serviceCount: publish.serviceCount ?? services.length,
requiredServiceCount: publish.requiredServiceCount ?? requiredServices.length,
publishedCount: publish.publishedCount ?? publishedRequiredServices.length,
digestCounts,
generatedAt: publish.generatedAt ?? null
};
}
async function readArtifactEvidence(repoRoot) {
const [catalog, report] = await Promise.all([
readJsonFileSummary(repoRoot, artifactCatalogPath, summarizeArtifactCatalog),
readJsonFileSummary(repoRoot, artifactReportPath, summarizeArtifactReport)
]);
return { catalog, report };
}
async function resolveTargetRef(ctx, targetRef) {
const target = await ctx.runCommand("git", ["rev-parse", "--verify", `${targetRef}^{commit}`], {
cwd: ctx.repoRoot,
@@ -325,6 +414,130 @@ async function resolveTargetRef(ctx, targetRef) {
};
}
async function gitOutput(ctx, args, { allowFailure = false, timeoutMs = 10000 } = {}) {
const result = await ctx.runCommand("git", args, {
cwd: ctx.repoRoot,
timeoutMs
});
if (result.code !== 0) {
if (allowFailure) return null;
throw new DevCdApplyError("git command failed while resolving DEV CD provenance", {
code: "git-provenance-failed",
command: shellCommand("git", args),
stderr: redactSensitiveText(result.stderr || result.stdout)
});
}
return result.stdout.trim();
}
async function commitParents(ctx, commitId) {
const line = await gitOutput(ctx, ["rev-list", "--parents", "-n", "1", commitId], { allowFailure: true });
if (!line) return [];
return line.split(/\s+/u).slice(1);
}
async function shortGitCommit(ctx, commitId) {
const value = await gitOutput(ctx, ["rev-parse", "--short=7", commitId], { allowFailure: true });
return value || shortCommit(commitId);
}
async function filesChangedBetween(ctx, left, right) {
const output = await gitOutput(ctx, ["diff-tree", "--no-commit-id", "--name-only", "-r", left, right], {
allowFailure: true
});
if (!output) return [];
return output.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean);
}
async function latestArtifactMergeCommit(ctx, controlCommit) {
const output = await gitOutput(ctx, [
"log",
"--first-parent",
"--format=%H",
"-n",
"1",
controlCommit,
"--",
...artifactDesiredStatePaths
], { allowFailure: true });
return output?.split(/\r?\n/u).find(Boolean) ?? null;
}
async function resolveArtifactBoundary(ctx, control, promotion, deployBefore, artifactEvidence) {
const mergeCommitId = await latestArtifactMergeCommit(ctx, control.commitId);
if (!mergeCommitId) {
return {
status: "unavailable",
reason: "No first-parent commit touching artifact desired-state files was found.",
desiredStatePaths: artifactDesiredStatePaths
};
}
const parents = await commitParents(ctx, mergeCommitId);
const mergeShortCommitId = await shortGitCommit(ctx, mergeCommitId);
const firstParent = parents[0] ?? null;
const secondParent = parents[1] ?? null;
const promotionParentMatches = firstParent ? commitMatches(firstParent, promotion.commitId) : false;
const changedPaths = firstParent ? await filesChangedBetween(ctx, firstParent, mergeCommitId) : [];
const touchedDesiredStatePaths = artifactDesiredStatePaths.filter((entry) => changedPaths.includes(entry));
const artifactCommitId = secondParent || mergeCommitId;
const artifactShortCommitId = await shortGitCommit(ctx, artifactCommitId);
const artifactParents = secondParent ? await commitParents(ctx, secondParent) : [];
const artifactParentMatches = artifactParents.some((parent) => commitMatches(parent, promotion.commitId));
const catalogCommitMatches = commitMatches(artifactEvidence.catalog.commitId, promotion.commitId);
const reportSource = artifactEvidence.report.sourceCommitId ?? artifactEvidence.report.commitId;
const reportCommitMatches = commitMatches(reportSource, promotion.commitId);
const deployCommitMatches = commitMatches(deployBefore.commitId, promotion.commitId);
const isMergeCommit = parents.length >= 2;
const status = (
deployCommitMatches &&
catalogCommitMatches &&
reportCommitMatches &&
(
promotionParentMatches ||
artifactParentMatches ||
commitMatches(mergeCommitId, promotion.commitId)
)
) ? "pass" : "degraded";
return {
status,
controlCommit: {
commitId: control.commitId,
shortCommitId: control.shortCommitId
},
artifactMergeCommit: {
commitId: mergeCommitId,
shortCommitId: mergeShortCommitId,
isMergeCommit,
parents,
promotionParent: firstParent,
artifactParent: secondParent,
promotionParentMatches,
changedPaths: touchedDesiredStatePaths
},
artifactCommit: {
commitId: artifactCommitId,
shortCommitId: artifactShortCommitId,
parents: artifactParents,
promotionParentMatches: artifactParentMatches
},
desiredState: {
deployCommitId: deployBefore.commitId,
deployJsonHash: deployBefore.hash,
catalogCommitId: artifactEvidence.catalog.commitId,
catalogHash: artifactEvidence.catalog.hash,
reportCommitId: artifactEvidence.report.commitId,
reportSourceCommitId: artifactEvidence.report.sourceCommitId,
reportHash: artifactEvidence.report.hash,
deployCommitMatches,
catalogCommitMatches,
reportCommitMatches,
desiredStatePaths: artifactDesiredStatePaths
}
};
}
async function resolveCommitRef(ctx, ref) {
const result = await ctx.runCommand("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
cwd: ctx.repoRoot,
@@ -376,7 +589,7 @@ async function checkPromotionDesiredState(ctx, promotionCommit) {
return compactDesiredStateCheck(result);
}
async function resolveEffectiveTarget(ctx, args, deployBefore) {
async function resolveEffectiveTarget(ctx, args, deployBefore, artifactEvidence = null) {
const control = await resolveTargetRef(ctx, args.targetRef);
if (deployJsonMatchesTarget(deployBefore, control)) {
return {
@@ -388,7 +601,10 @@ async function resolveEffectiveTarget(ctx, args, deployBefore) {
},
promotionSource: "target-ref",
publishRequired: true,
desiredStateCheck: null
desiredStateCheck: null,
artifactBoundary: artifactEvidence
? await resolveArtifactBoundary(ctx, control, control, deployBefore, artifactEvidence)
: null
};
}
@@ -404,7 +620,10 @@ async function resolveEffectiveTarget(ctx, args, deployBefore) {
},
promotionSource: "target-ref",
publishRequired: true,
desiredStateCheck
desiredStateCheck,
artifactBoundary: artifactEvidence
? await resolveArtifactBoundary(ctx, control, promotion, deployBefore, artifactEvidence)
: null
};
}
@@ -414,7 +633,7 @@ async function resolveEffectiveTarget(ctx, args, deployBefore) {
shortCommitId: promotion.shortCommitId,
headCommitId: control.headCommitId,
headShortCommitId: control.headShortCommitId,
headMatchesTarget: true,
headMatchesTarget: control.headMatchesTarget,
controlRef: {
ref: control.ref,
commitId: control.commitId,
@@ -422,7 +641,10 @@ async function resolveEffectiveTarget(ctx, args, deployBefore) {
},
promotionSource: "deploy-json",
publishRequired: false,
desiredStateCheck
desiredStateCheck,
artifactBoundary: artifactEvidence
? await resolveArtifactBoundary(ctx, control, promotion, deployBefore, artifactEvidence)
: null
};
}
@@ -1441,6 +1663,24 @@ function compactLiveReport(live) {
};
}
function buildLiveDelta(live, target) {
const expectedCommit = target?.shortCommitId ?? target?.commitId ?? null;
const endpoints = (live?.endpoints ?? []).map((endpoint) => ({
id: endpoint.id,
observedCommit: endpoint.observedCommit ?? null,
expectedCommit,
matchesPromotion: expectedCommit ? commitMatches(endpoint.observedCommit, expectedCommit) : null,
status: endpoint.status
}));
const mismatched = endpoints.filter((endpoint) => endpoint.matchesPromotion === false);
return {
status: expectedCommit && mismatched.length === 0 && endpoints.length > 0 ? "pass" : expectedCommit ? "mismatch" : "unknown",
expectedCommit,
mismatched: mismatched.length,
endpoints
};
}
async function readDeployLockStatus({ ctx, args, kubectlContext }) {
const get = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "get", "lease", args.lockName, "-o", "json"], {
timeoutMs: 15000
@@ -1516,7 +1756,8 @@ function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) {
actions.push("deploy/deploy.json does not match the target ref and the deploy-json promotion desired-state check failed; refresh artifacts/catalog or choose an explicit published promotion.");
}
if (!target.headMatchesTarget) {
actions.push(`Checkout or fast-forward to ${target.ref} ${target.shortCommitId} before apply.`);
const controlShortCommit = target.controlRef?.shortCommitId ?? target.shortCommitId;
actions.push(`Checkout or fast-forward to ${target.ref} ${controlShortCommit} before apply.`);
}
if (deployBefore.commitId !== target.shortCommitId && deployBefore.commitId !== target.commitId) {
actions.push(`deploy/deploy.json commitId is ${deployBefore.commitId}; target ref is ${target.shortCommitId}.`);
@@ -1531,6 +1772,10 @@ function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) {
if (liveVerify.status !== "pass") {
actions.push("Inspect live endpoint identity/health before treating DEV as converged.");
}
const liveDelta = buildLiveDelta(liveVerify, target);
if (liveDelta.status === "mismatch") {
actions.push(`Live DEV is still serving ${liveDelta.mismatched} endpoint(s) that do not match promotion ${target.shortCommitId}; host commander should run the controlled DEV rollout after the apply report is ready.`);
}
if (actions.length === 0) {
actions.push(target.publishRequired === false
? "Run --apply --confirm-dev --confirmed-non-production --write-report when ready to apply the published deploy.json promotion without republishing HEAD."
@@ -1542,7 +1787,8 @@ function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) {
async function runDevCdStatus(args, ctx, stdout) {
const generatedAt = ctx.now().toISOString();
const deployBefore = await readDeployJson(ctx.repoRoot);
const target = await resolveEffectiveTarget(ctx, args, deployBefore);
const artifactEvidence = await readArtifactEvidence(ctx.repoRoot);
const target = await resolveEffectiveTarget(ctx, args, deployBefore, artifactEvidence);
const kubectlContext = await resolveKubectlContext(args, ctx.env, ctx.runCommand);
const lockRead = await readDeployLockStatus({ ctx, args, kubectlContext });
const lock = summarizeLockStatus(lockRead, ctx.now());
@@ -1550,11 +1796,15 @@ async function runDevCdStatus(args, ctx, stdout) {
? { status: "not_run", reason: "--skip-live-verify", endpoints: [], summary: { checked: 0 } }
: await verifyDevLive(ctx, { expectedCommit: null });
const deployMatchesTarget = deployJsonMatchesTarget(deployBefore, target);
const liveDelta = buildLiveDelta(liveVerify, target);
const status = (
lock.status === "unavailable" ||
liveVerify.status !== "pass" ||
liveDelta.status === "mismatch" ||
!target.headMatchesTarget ||
!deployMatchesTarget
!deployMatchesTarget ||
target.publishRequired === false &&
target.artifactBoundary?.status === "degraded"
) ? "degraded" : "pass";
const report = {
ok: true,
@@ -1574,12 +1824,15 @@ async function runDevCdStatus(args, ctx, stdout) {
headCommitId: target.headCommitId,
headMatchesTarget: target.headMatchesTarget,
desiredStateCheck: target.desiredStateCheck,
artifactBoundary: target.artifactBoundary,
namespace: args.targetNamespace
},
deployJson: {
...summarizeDeployJson(deployBefore),
matchesTarget: deployMatchesTarget
},
artifactCatalog: artifactEvidence.catalog,
artifactReport: artifactEvidence.report,
kubectl: {
kubeconfigSource: kubectlContext.kubeconfigSource,
kubeconfig: kubectlContext.kubeconfig ? "<set>" : "<unset>",
@@ -1587,6 +1840,7 @@ async function runDevCdStatus(args, ctx, stdout) {
},
lock,
live: compactLiveReport(liveVerify),
liveDelta,
nextActions: buildStatusNextActions({ target, deployBefore, lock, liveVerify }),
fullOutputHint: "Use --apply --confirm-dev --confirmed-non-production --write-report for the full transaction report; use --full-output only when stdout needs the full JSON."
};
@@ -1743,6 +1997,7 @@ function buildReport({
headCommitId: target.headCommitId,
headMatchesTarget: target.headMatchesTarget,
desiredStateCheck: target.desiredStateCheck,
artifactBoundary: target.artifactBoundary,
namespace: args.targetNamespace,
apiEndpoint: DEV_ENDPOINT,
browserEndpoint: "http://74.48.78.17:16666/"
@@ -1759,14 +2014,14 @@ function buildReport({
steps,
liveBefore,
liveVerify,
reportPaths: {
transaction: args.writeReport ? args.reportPath : null,
artifacts: artifactReportPath,
deployApply: "reports/dev-gate/dev-deploy-report.json",
runtimeProvisioning: runtimeProvisioningReportPath,
runtimeMigration: runtimeMigrationReportPath,
runtimePostflight: runtimePostflightReportPath
},
reportPaths: {
transaction: args.writeReport ? args.reportPath : null,
artifacts: artifactReportPath,
deployApply: "reports/dev-gate/dev-deploy-report.json",
runtimeProvisioning: runtimeProvisioningReportPath,
runtimeMigration: runtimeMigrationReportPath,
runtimePostflight: runtimePostflightReportPath
},
safety: {
prodTouched: false,
secretValuesRead: false,
@@ -1865,7 +2120,8 @@ export async function runDevCdApply(argv, io = {}) {
try {
deployBefore = await readDeployJson(ctx.repoRoot);
target = await resolveEffectiveTarget(ctx, args, deployBefore);
const artifactEvidence = await readArtifactEvidence(ctx.repoRoot);
target = await resolveEffectiveTarget(ctx, args, deployBefore, artifactEvidence);
if (target.desiredStateCheck?.status === "blocked") {
throw new DevCdApplyError("deploy desired-state does not match the target ref or a published deploy.json promotion", {
code: "desired-state-promotion-check-failed",
@@ -1877,6 +2133,31 @@ export async function runDevCdApply(argv, io = {}) {
}]
});
}
if (target.publishRequired === false && target.artifactBoundary?.status === "degraded") {
blockers.push({
type: "contract_blocker",
scope: "artifact-boundary",
status: "open",
summary: "deploy/deploy.json, deploy/artifact-catalog.dev.json, reports/dev-gate/dev-artifacts.json, and the artifact merge parent must resolve to the same promotion commit before apply."
});
throw new DevCdApplyError("artifact desired-state provenance does not match the deploy.json promotion", {
code: "artifact-boundary-provenance-mismatch",
suppressCatchBlocker: true
});
}
if (!target.headMatchesTarget) {
const controlShortCommit = target.controlRef?.shortCommitId ?? target.shortCommitId;
blockers.push({
type: "contract_blocker",
scope: "target-ref-head",
status: "open",
summary: `HEAD ${target.headShortCommitId} must match target ref ${target.ref} ${controlShortCommit} before DEV CD apply.`
});
throw new DevCdApplyError("checkout is behind the requested DEV CD control ref", {
code: "target-ref-head-mismatch",
suppressCatchBlocker: true
});
}
const kubectlContext = await resolveKubectlContext(args, env, ctx.runCommand);
transaction.kubectlContext = kubectlContext;
liveBefore = {
@@ -1905,15 +2186,6 @@ export async function runDevCdApply(argv, io = {}) {
});
transaction.phases.push({ phase: "publishing", status: "entered", at: ctx.now().toISOString() });
if (!target.headMatchesTarget) {
blockers.push({
type: "contract_blocker",
scope: "target-ref-head",
status: "open",
summary: `HEAD ${target.headShortCommitId} must match target ref ${target.ref} ${target.shortCommitId} before DEV CD publish.`
});
}
const publishSteps = target.publishRequired === false
? [
{
@@ -2123,12 +2395,14 @@ export async function runDevCdApply(argv, io = {}) {
stdout.write(`${JSON.stringify(error.lockFailure, null, 2)}\n`);
return 2;
}
blockers.push({
type: "runtime_blocker",
scope: error.code ?? "dev-cd-apply",
status: "open",
summary: oneLine(error.message)
});
if (!error?.suppressCatchBlocker) {
blockers.push({
type: "runtime_blocker",
scope: error.code ?? "dev-cd-apply",
status: "open",
summary: oneLine(error.message)
});
}
status = "blocked";
if (lockState?.acquired) {
const kubectlContext = await resolveKubectlContext(args, env, ctx.runCommand);
+342 -2
View File
@@ -50,7 +50,8 @@ function parsePatch(args) {
async function makeRepo(options = {}) {
const commitId = options.commitId ?? "abc1234";
const repoRoot = await fsTempDir();
await mkdir(path.join(repoRoot, "deploy"), { recursive: true });
await mkdir(path.join(repoRoot, "deploy/k8s/base"), { recursive: true });
await mkdir(path.join(repoRoot, "reports/dev-gate"), { recursive: true });
await writeFile(
path.join(repoRoot, "deploy/deploy.json"),
`${JSON.stringify({
@@ -58,9 +59,103 @@ async function makeRepo(options = {}) {
environment: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
commitId
commitId,
services: [
{
serviceId: "hwlab-cloud-api",
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
namespace: "hwlab-dev",
profile: "dev",
env: { HWLAB_COMMIT_ID: commitId.slice(0, 7) }
},
{
serviceId: "hwlab-cloud-web",
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
namespace: "hwlab-dev",
profile: "dev",
env: { HWLAB_COMMIT_ID: commitId.slice(0, 7) }
}
]
}, null, 2)}\n`
);
await writeFile(
path.join(repoRoot, "deploy/artifact-catalog.dev.json"),
`${JSON.stringify({
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
environment: "dev",
profile: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
commitId,
artifactState: "published",
publish: {
ciPublished: true,
registryVerified: true,
provenance: "reports/dev-gate/dev-artifacts.json"
},
services: [
{
serviceId: "hwlab-cloud-api",
commitId,
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
imageTag: commitId.slice(0, 7),
digest: "sha256:b3713aa07164a9ff79a436bd583552427de42922835a5baff50484f53d98f296",
publishState: "published",
profile: "dev",
namespace: "hwlab-dev",
artifactRequired: true
},
{
serviceId: "hwlab-cloud-web",
commitId,
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
imageTag: commitId.slice(0, 7),
digest: "sha256:3be80fcfa769d68b0a7ec44e9215b93546d01bf23411533c5cb4888438f2bd84",
publishState: "published",
profile: "dev",
namespace: "hwlab-dev",
artifactRequired: true
}
]
}, null, 2)}\n`
);
await writeFile(
path.join(repoRoot, "reports/dev-gate/dev-artifacts.json"),
`${JSON.stringify({
reportVersion: "v1",
taskId: "dev-artifact-publish",
status: "published",
commitId,
artifactPublish: {
status: "published",
mode: "publish",
sourceCommitId: commitId,
registryPrefix: "127.0.0.1:5000/hwlab",
serviceCount: 2,
requiredServiceCount: 2,
publishedCount: 2,
generatedAt: iso(),
services: [
{
serviceId: "hwlab-cloud-api",
status: "published",
artifactRequired: true,
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
digest: "sha256:b3713aa07164a9ff79a436bd583552427de42922835a5baff50484f53d98f296"
},
{
serviceId: "hwlab-cloud-web",
status: "published",
artifactRequired: true,
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
digest: "sha256:3be80fcfa769d68b0a7ec44e9215b93546d01bf23411533c5cb4888438f2bd84"
}
]
}
}, null, 2)}\n`
);
await writeFile(path.join(repoRoot, "deploy/k8s/base/workloads.yaml"), `# fixture ${commitId}\n`);
return repoRoot;
}
@@ -101,6 +196,8 @@ function makeHttpGetJson() {
const publishedCommit = "abc1234abc1234abc1234abc1234abc1234abc1";
const laterControlCommit = "def5678def5678def5678def5678def5678def5";
const artifactMergeCommit = "fedcba9fedcba9fedcba9fedcba9fedcba9fedc";
const artifactReportCommit = "a77efa1a77efa1a77efa1a77efa1a77efa1a77e";
function makeRunCommand({
heldLock = null,
@@ -108,6 +205,14 @@ function makeRunCommand({
targetCommitId = publishedCommit,
headCommitId = targetCommitId,
extraGitRefs = {},
commitParents = {},
latestArtifactMergeCommit = null,
changedPaths = [
"deploy/artifact-catalog.dev.json",
"deploy/deploy.json",
"deploy/k8s/base/workloads.yaml",
"reports/dev-gate/dev-artifacts.json"
],
desiredStatePromotionStatus = "pass",
runtimeJobLogSuffix = ""
} = {}) {
@@ -120,6 +225,11 @@ function makeRunCommand({
if (command === "git" && args[0] === "rev-parse" && args.includes("origin/main^{commit}")) {
return { code: 0, stdout: `${targetCommitId}\n`, stderr: "" };
}
if (command === "git" && args[0] === "rev-parse" && args.includes("--short=7")) {
const ref = String(args.at(-1) ?? "");
const commitId = extraGitRefs[ref] ?? ref;
return { code: 0, stdout: `${commitId.slice(0, 7)}\n`, stderr: "" };
}
if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) {
return { code: 0, stdout: `${headCommitId}\n`, stderr: "" };
}
@@ -129,6 +239,19 @@ function makeRunCommand({
if (commitId) return { code: 0, stdout: `${commitId}\n`, stderr: "" };
return { code: 1, stdout: "", stderr: `fatal: ambiguous argument '${ref}'` };
}
if (command === "git" && args[0] === "log" && args.includes("--first-parent")) {
return latestArtifactMergeCommit
? { code: 0, stdout: `${latestArtifactMergeCommit}\n`, stderr: "" }
: { code: 1, stdout: "", stderr: "no matching commits" };
}
if (command === "git" && args[0] === "rev-list" && args.includes("--parents")) {
const ref = String(args.at(-1) ?? "");
const parents = commitParents[ref] ?? [];
return { code: 0, stdout: `${[ref, ...parents].join(" ")}\n`, stderr: "" };
}
if (command === "git" && args[0] === "diff-tree") {
return { code: 0, stdout: `${changedPaths.join("\n")}\n`, stderr: "" };
}
if (command === "which" && args[0] === "kubectl") {
return { code: 0, stdout: "/usr/local/bin/kubectl\n", stderr: "" };
}
@@ -333,6 +456,11 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
headCommitId: laterControlCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
@@ -348,6 +476,16 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
assert.equal(status.target.promotionSource, "deploy-json");
assert.equal(status.target.publishRequired, false);
assert.equal(status.target.controlRef.shortCommitId, "def5678");
assert.equal(status.target.artifactBoundary.status, "pass");
assert.equal(status.target.artifactBoundary.artifactMergeCommit.shortCommitId, "fedcba9");
assert.equal(status.target.artifactBoundary.artifactMergeCommit.promotionParentMatches, true);
assert.equal(status.target.artifactBoundary.artifactCommit.shortCommitId, "a77efa1");
assert.equal(status.target.artifactBoundary.artifactCommit.promotionParentMatches, true);
assert.equal(status.artifactCatalog.commitId, "abc1234");
assert.equal(status.artifactCatalog.digestCounts.sha256, 2);
assert.equal(status.artifactReport.sourceCommitId, "abc1234");
assert.equal(status.liveDelta.status, "pass");
assert.equal(status.liveDelta.expectedCommit, "abc1234");
assert.equal(status.target.headMatchesTarget, true);
assert.equal(status.target.desiredStateCheck.status, "pass");
assert.equal(status.deployJson.matchesTarget, true);
@@ -362,6 +500,188 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
assertNoReadOnlySideEffects(commandLog);
});
test("dev-cd status exposes moving origin/main when checkout is behind a deploy-json promotion control ref", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
const commandLog = [];
let output = "";
const code = await runDevCdApply(["--status", "--kubeconfig", "/tmp/kubeconfig"], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
targetCommitId: laterControlCommit,
headCommitId: publishedCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
assert.equal(code, 0);
assert.equal(status.status, "degraded");
assert.equal(status.target.promotionCommit, publishedCommit);
assert.equal(status.target.controlRef.shortCommitId, "def5678");
assert.equal(status.target.headCommitId, publishedCommit);
assert.equal(status.target.headMatchesTarget, false);
assert.match(status.nextActions.join("\n"), /Checkout or fast-forward/u);
assertNoReadOnlySideEffects(commandLog);
});
test("apply stops before lock acquisition when checkout is behind deploy-json promotion control ref", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
const commandLog = [];
let output = "";
const code = await runDevCdApply([
"--apply",
"--confirm-dev",
"--confirmed-non-production",
"--owner-task-id",
"task-moving-main",
"--kubeconfig",
"/tmp/kubeconfig"
], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
targetCommitId: laterControlCommit,
headCommitId: publishedCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const summary = JSON.parse(output);
assert.equal(code, 2);
assert.equal(summary.status, "blocked");
assert.equal(summary.target.controlRef.shortCommitId, "def5678");
assert.equal(summary.blockers.some((blocker) => blocker.scope === "target-ref-head"), true);
assert.equal(commandLog.some((entry) => entry.command.includes("kubectl")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")), false);
});
test("dev-cd status degrades artifact merge provenance when catalog/report do not match deploy-json promotion", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
await writeFile(
path.join(repoRoot, "deploy/artifact-catalog.dev.json"),
`${JSON.stringify({
kind: "hwlab-artifact-catalog",
commitId: "9999999",
artifactState: "published",
publish: { ciPublished: true, registryVerified: true, provenance: "reports/dev-gate/dev-artifacts.json" },
services: []
}, null, 2)}\n`
);
const commandLog = [];
let output = "";
const code = await runDevCdApply(["--status", "--kubeconfig", "/tmp/kubeconfig"], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
targetCommitId: laterControlCommit,
headCommitId: laterControlCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
assert.equal(code, 0);
assert.equal(status.status, "degraded");
assert.equal(status.target.promotionCommit, publishedCommit);
assert.equal(status.target.artifactBoundary.status, "degraded");
assert.equal(status.target.artifactBoundary.desiredState.deployCommitMatches, true);
assert.equal(status.target.artifactBoundary.desiredState.catalogCommitMatches, false);
assert.equal(status.target.artifactBoundary.desiredState.reportCommitMatches, true);
assertNoReadOnlySideEffects(commandLog);
});
test("apply stops before lock acquisition when artifact merge provenance mismatches deploy-json promotion", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
await writeFile(
path.join(repoRoot, "reports/dev-gate/dev-artifacts.json"),
`${JSON.stringify({
status: "published",
commitId: "9999999",
artifactPublish: {
status: "published",
sourceCommitId: "9999999",
serviceCount: 0,
requiredServiceCount: 0,
publishedCount: 0,
services: []
}
}, null, 2)}\n`
);
const commandLog = [];
let output = "";
const code = await runDevCdApply([
"--apply",
"--confirm-dev",
"--confirmed-non-production",
"--owner-task-id",
"task-provenance",
"--kubeconfig",
"/tmp/kubeconfig"
], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
targetCommitId: laterControlCommit,
headCommitId: laterControlCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const summary = JSON.parse(output);
assert.equal(code, 2);
assert.equal(summary.status, "blocked");
assert.equal(summary.blockers.some((blocker) => blocker.scope === "artifact-boundary"), true);
assert.equal(commandLog.some((entry) => entry.command.includes("kubectl")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")), false);
});
test("dev-cd status summarizes held, released, and stale locks without unsafe takeover hints", async () => {
const cases = [
{
@@ -444,9 +764,22 @@ test("dev-cd status reports unavailable kubectl with redacted reason", async ()
if (command === "git" && args[0] === "rev-parse" && args.includes("origin/main^{commit}")) {
return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" };
}
if (command === "git" && args[0] === "rev-parse" && args.includes("--short=7")) {
return { code: 0, stdout: `${String(args.at(-1)).slice(0, 7)}\n`, stderr: "" };
}
if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) {
return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" };
}
if (command === "git" && args[0] === "log" && args.includes("--first-parent")) {
return { code: 1, stdout: "", stderr: "no matching commits" };
}
if (command === "git" && args[0] === "rev-list" && args.includes("--parents")) {
const ref = String(args.at(-1) ?? "");
return { code: 0, stdout: `${ref}\n`, stderr: "" };
}
if (command === "git" && args[0] === "diff-tree") {
return { code: 0, stdout: "", stderr: "" };
}
if (command === "which" && args[0] === "kubectl") {
return { code: 1, stdout: "", stderr: "kubectl missing" };
}
@@ -770,6 +1103,11 @@ test("transaction can apply an already published deploy-json promotion without r
headCommitId: laterControlCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
@@ -783,6 +1121,8 @@ test("transaction can apply an already published deploy-json promotion without r
assert.equal(report.devCdApply.target.promotionCommit, publishedCommit);
assert.equal(report.devCdApply.target.promotionSource, "deploy-json");
assert.equal(report.devCdApply.target.publishRequired, false);
assert.equal(report.devCdApply.target.artifactBoundary.status, "pass");
assert.equal(report.devCdApply.target.artifactBoundary.artifactMergeCommit.shortCommitId, "fedcba9");
assert.deepEqual(
report.devCdApply.steps.map((step) => step.id),
[