fix: resolve dev cd artifact promotion boundary
This commit is contained in:
@@ -52,6 +52,19 @@ latest-main 发布时它可以等于当前 target ref;artifact 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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user