Merge pull request #1754 from pikasTech/fix/1753-pac-yaml-trailing-space
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix: 稳定 PaC source artifact YAML 空白
This commit is contained in:
Lyon
2026-07-11 14:13:17 +08:00
committed by GitHub
8 changed files with 151 additions and 1 deletions
@@ -0,0 +1,5 @@
# R1.1 报告
- 复现输入包含空 `metadata`、空数组和嵌套空数组。
- Bun YAML 输出 `metadata: ``spec: ``items: ` 等尾随空格。
- `git diff --no-index --check` 稳定以非零状态报告 trailing whitespace。
@@ -0,0 +1,5 @@
# R1.2 报告
- 在通用 `pacSourceArtifactYaml()` 中仅规范 block scalar 外的 mapping 容器头空白。
- 未修改 AgentRun 或 HWLAB consumer 文件及渲染职责。
- 输出前重新解析并逐字段对比原对象;任何漂移均以脱敏摘要失败闭合。
@@ -0,0 +1,7 @@
# R1.3 报告
- 定向测试:26 项通过,0 项失败,共 297 个断言。
- AgentRun 与 HWLAB 首次生成均成功,第二次写入均为 `mutation=false`
- 三个真实生成文件均可解析、无行尾空格、EOF 恰好一个换行。
- 两个 consumer 的 `source-artifact check` 均返回 aligned。
- 多行 script 的行尾双空格、tab 与伪结构文本保持字节一致。
@@ -0,0 +1,7 @@
# R1.4 报告
- 交付分支:`fix/1753-pac-yaml-trailing-space`
- 关联 issuehttps://github.com/pikasTech/unidesk/issues/1753
- PRhttps://github.com/pikasTech/unidesk/pull/1754
- 受控 preflight`MERGEABLE``CLEAN``readyForCommanderMerge=true`
- 不执行合并或部署。
@@ -0,0 +1,5 @@
# R1 总报告
- 通用 PaC source artifact YAML serializer 已仅在结构表示层消除 Bun 容器头行尾空格。
- 多行 scalar 内容、YAML 结构、一个 EOF 换行和重复生成幂等均已验证。
- AgentRun 与 HWLAB consumer 无需手工修补。
@@ -0,0 +1,25 @@
# PaC Source Artifact YAML 空白稳定性
- 来源:https://github.com/pikasTech/unidesk/issues/1753
- 范围:通用 PaC source artifact serializer;禁止 consumer 手工修补。
## R1 [completed]
修复 PaC source artifact YAML serializer 的行尾空格,关联 https://github.com/pikasTech/unidesk/issues/1753,完成任务后将详细报告写入[任务报告](./details/pac-source-artifact-yaml-whitespace/R1_Task_Report.md)。
### R1.1 [completed]
复现空 mapping 行尾空格和 git diff --check 失败,关联 https://github.com/pikasTech/unidesk/issues/1753,完成任务后将详细报告写入[任务报告](./details/pac-source-artifact-yaml-whitespace/R1.1_Task_Report.md)。
### R1.2 [completed]
在通用 serializer 修复稳定 YAML 输出并保持结构不变,完成任务后将详细报告写入[任务报告](./details/pac-source-artifact-yaml-whitespace/R1.2_Task_Report.md)。
### R1.3 [completed]
验证 AgentRun/HWLAB 生成、EOF 和二次写入幂等,完成任务后将详细报告写入[任务报告](./details/pac-source-artifact-yaml-whitespace/R1.3_Task_Report.md)。
### R1.4 [completed]
提交非 draft PR、运行受控 preflight 并留下审查证据,完成任务后将详细报告写入[任务报告](./details/pac-source-artifact-yaml-whitespace/R1.4_Task_Report.md)。
@@ -11,6 +11,7 @@ import {
canonicalSha256,
canonicalizePacPipelineSpec,
firstPacSourceArtifactDrift,
pacSourceArtifactSafeError,
pacValueEvidence,
pacSourceArtifactYaml,
parsePacSourceArtifactOptions,
@@ -270,6 +271,75 @@ describe("source artifact serialization", () => {
expect(first).toContain("apiVersion: tekton.dev/v1\nkind: Pipeline\n");
expect(Bun.YAML.parse(first)).toEqual(manifest);
});
test("empty mappings and sequences have stable whitespace without changing structure", () => {
const manifest = {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
metadata: {},
spec: { params: [], workspaces: [], pipelineSpec: { tasks: [] } },
};
const serialized = pacSourceArtifactYaml(manifest);
expect(serialized).not.toMatch(/[\t ]+$/m);
expect(serialized.endsWith("\n")).toBe(true);
expect(serialized.endsWith("\n\n")).toBe(false);
expect(Bun.YAML.parse(serialized)).toEqual(manifest);
});
test("multi-line script scalar preserves trailing spaces and tabs exactly", () => {
const script = [
"#!/bin/sh ",
"printf 'mapping-looking scalar: ' ",
"metadata: ",
" {}",
"printf 'tab follows'\t",
"",
].join("\n");
const manifest = {
apiVersion: "tekton.dev/v1",
kind: "Pipeline",
metadata: { name: "scalar-round-trip" },
spec: { tasks: [{ name: "build", taskSpec: { steps: [{ name: "build", script }] } }] },
};
const serialized = pacSourceArtifactYaml(manifest);
const parsed = Bun.YAML.parse(serialized) as typeof manifest;
expect(parsed).toEqual(manifest);
expect(parsed.spec.tasks[0]?.taskSpec.steps[0]?.script).toBe(script);
expect(Buffer.from(parsed.spec.tasks[0]?.taskSpec.steps[0]?.script ?? "")).toEqual(Buffer.from(script));
expect(serialized.endsWith("\n")).toBe(true);
expect(serialized.endsWith("\n\n")).toBe(false);
});
test("round-trip failure exposes only secret-safe drift evidence", () => {
const secret = "ROUND_TRIP_SCRIPT_SECRET_DO_NOT_PRINT";
let reads = 0;
const spec: Record<string, unknown> = {};
Object.defineProperty(spec, "script", {
enumerable: true,
get: () => {
reads += 1;
return reads <= 2 ? secret : "changed-after-serialization";
},
});
let thrown: unknown;
try {
pacSourceArtifactYaml({ apiVersion: "tekton.dev/v1", kind: "Pipeline", spec });
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
const safe = pacSourceArtifactSafeError(thrown);
const serializedError = JSON.stringify({
message: thrown instanceof Error ? thrown.message : String(thrown),
direct: thrown,
safe,
});
expect(safe.code).toBe("source-artifact-yaml-round-trip-failed");
expect(serializedError).not.toContain(secret);
expect(serializedError).not.toContain("changed-after-serialization");
expect(serializedError).toContain("sha256");
});
});
describe("runtime source-commit selection", () => {
@@ -1032,7 +1032,33 @@ function parseYamlRecord(text: string, label: string): Record<string, unknown> {
}
export function pacSourceArtifactYaml(value: Record<string, unknown>): string {
return `${Bun.YAML.stringify(value, null, 2).trim()}\n`;
const lines = Bun.YAML.stringify(value, null, 2).split("\n");
let blockScalarHeaderIndent: number | null = null;
const normalized = lines.map((line) => {
const content = line.trim();
const indent = line.length - line.trimStart().length;
if (blockScalarHeaderIndent !== null) {
if (content.length === 0 || indent > blockScalarHeaderIndent) return line;
blockScalarHeaderIndent = null;
}
const structuralLine = line.replace(/:[\t ]+$/u, ":");
if (/(?:^|:[\t ]+|-[\t ]+)[|>](?:[1-9][+-]?|[+-][1-9]?|[+-])?[\t ]*(?:#.*)?$/u.test(structuralLine)) {
blockScalarHeaderIndent = indent;
}
return structuralLine;
}).join("\n");
const withSingleEofNewline = `${normalized.replace(/\n+$/u, "")}\n`;
const roundTrip = parseYamlRecord(withSingleEofNewline, "serialized source artifact");
const roundTripDrift = firstCanonicalDrift(value, roundTrip, "$");
if (roundTripDrift !== null) {
throw new PacSourceArtifactError("source-artifact-yaml-round-trip-failed", {
driftPath: roundTripDrift.path,
expectedEvidence: roundTripDrift.expected,
actualEvidence: roundTripDrift.actual,
});
}
return withSingleEofNewline;
}
function requiredParam(binding: PacSourceArtifactBinding, key: string): string {