Merge remote-tracking branch 'origin/master' into docs/hwlab-ui-closeout

This commit is contained in:
Codex
2026-07-13 15:49:24 +02:00
7 changed files with 411 additions and 22 deletions
@@ -49,3 +49,7 @@
### R3.3
依赖 R3.2,覆盖仓库缺失、ref 缺失、重复 delivery、controller 重启、retry exhaustion 与恢复测试;只用修复 PR merge 产生的新自动事件验收 resource bundle 获取成功,禁止人工 mirror、sync、flush、trigger 或 direct GitHub 补洞,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R3.3_Task_Report.md)。
## R4 [in_progress]
修复 [UniDesk #1933](https://github.com/pikasTech/unidesk/issues/1933) 暴露的 Gitea mirror 默认只读 plan 回归:恢复 repository-scoped targetRepos 计算,保持 mutation authority 不变,补充最小回归验证并由独立 Artificer PR 交付,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R4_Task_Report.md)。
@@ -26,3 +26,7 @@
## R3 [in_progress]
解决 [UniDesk #1923](https://github.com/pikasTech/unidesk/issues/1923):提高 CI/CD 自动链错误暴露能力并接入 OTel,覆盖 GitHub webhook→Gitea snapshot→PaC→Tekton PipelineRun/TaskRun/step→artifact/GitOps→Argo/runtime/health 的 trace context、阶段 span、首断点与脱敏错误投影;让 cicd status、PaC status/history/debug-step 和 sentinel control-plane status 默认有界显示 traceId/error code/phase/exit/下钻。exporter、版本或 commit 漂移只能非阻塞 warning;禁止新增人工交付路径、第二 authority、锁/租约/证据链/状态机,以真实失败 hwlab-web-probe-sentinel-nc01-6gjzl 和新 PR merge 自动事件验收,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R3_Task_Report.md)。
### R3.1 [in_progress]
基于 PR #1925 合并后的真实自动事件继续修复 OTel 运行面:禁止 PaC Repository 参数未就绪时把花括号占位符作为 endpoint;从 owning YAML 在 source snapshot 内解析 OTel 配置并保留 300ms 非阻塞策略,增加安全的原始 fetch cause 投影;恢复 Tempo endpoint 后,用新的普通 PR merge 自动事件证明 collector 接收且 Tempo 可按 traceId 查询,禁止人工触发 PipelineRun,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R3.1_Task_Report.md)。
+234
View File
@@ -0,0 +1,234 @@
import { rootPath } from "../../src/config";
import { readYamlRecord } from "../../src/platform-infra-ops-library";
import { materializeYamlComposition } from "../../src/yaml-composition";
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
export interface CicdOtelRuntimeConfig {
readonly endpoint: string;
readonly endpointHostname: string;
readonly serviceName: string;
readonly samplingRatio: number;
readonly timeoutMs: number;
readonly fallbackCodes: readonly string[];
}
export interface CicdOtelExportWarning {
readonly event: "cicd.otel.export";
readonly warning: true;
readonly blocking: false;
readonly causeCode: string;
readonly endpointHostname: string;
readonly httpStatus?: number;
readonly timedOut?: boolean;
readonly errorType?: string;
readonly timeoutMs: number;
readonly traceId: string;
readonly valuesRedacted: true;
}
export function loadCicdOtelRuntimeConfig(
node: string,
lane: string,
env: Record<string, string | undefined> = process.env,
): CicdOtelRuntimeConfig {
const root = materializeYamlComposition(
readYamlRecord<Record<string, unknown>>(rootPath(pacConfigPath), "platform-infra-pipelines-as-code"),
{ label: "platform-infra-pipelines-as-code", stripTemplateKeys: true },
).value;
const observability = record(root.observability, `${pacConfigPath}#observability`);
const repository = repositoryFor(root.repositories, node, lane);
const params = record(repository.params, `${pacConfigPath}#repositories.${repository.id}.params`);
const fallbackCodes: string[] = [];
const endpoint = authoritativeValue(
[
[params.otel_traces_endpoint, "INVALID_ENDPOINT_PARAM"],
[observability.tracesEndpoint, "INVALID_ENDPOINT_AUTHORITY"],
],
validEndpoint,
fallbackCodes,
);
validateInput(env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, endpoint, validEndpoint, "ENDPOINT", fallbackCodes);
const endpointUrl = new URL(endpoint);
const serviceName = authoritativeValue(
[
[params.otel_service_name, "INVALID_SERVICE_PARAM"],
[observability.serviceName, "INVALID_SERVICE_AUTHORITY"],
],
validServiceName,
fallbackCodes,
);
validateInput(env.OTEL_SERVICE_NAME, serviceName, validServiceName, "SERVICE", fallbackCodes);
const sampling = record(observability.sampling, `${pacConfigPath}#observability.sampling`);
const samplingRatio = authoritativeNumber(
[
[params.otel_sampling_ratio, "INVALID_SAMPLING_PARAM"],
[sampling.ratio, "INVALID_SAMPLING_AUTHORITY"],
],
(value) => value >= 0 && value <= 1,
fallbackCodes,
);
validateNumberInput(env.OTEL_TRACES_SAMPLER_ARG, samplingRatio, "SAMPLING", fallbackCodes);
const exporterFailure = record(observability.exporterFailure, `${pacConfigPath}#observability.exporterFailure`);
const timeoutMs = authoritativeNumber(
[
[params.otel_exporter_timeout_ms, "INVALID_TIMEOUT_PARAM"],
[exporterFailure.timeoutMs, "INVALID_TIMEOUT_AUTHORITY"],
],
(value) => Number.isInteger(value) && value >= 50 && value <= 2_000,
fallbackCodes,
);
validateNumberInput(env.OTEL_EXPORTER_TIMEOUT_MS, timeoutMs, "TIMEOUT", fallbackCodes);
return {
endpoint: endpointUrl.toString(),
endpointHostname: endpointUrl.hostname,
serviceName,
samplingRatio,
timeoutMs,
fallbackCodes,
};
}
export async function exportCicdOtelPayload(
config: CicdOtelRuntimeConfig,
traceparent: string,
traceId: string,
payload: unknown,
fetcher: typeof fetch = fetch,
): Promise<CicdOtelExportWarning | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetcher(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json", traceparent },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (response.ok) return null;
return warning(config, traceId, `HTTP_${response.status}`, { httpStatus: response.status });
} catch (error) {
return warning(config, traceId, errorCauseCode(error), {
timedOut: error instanceof Error && error.name === "AbortError",
errorType: error instanceof Error ? error.name : "Error",
});
} finally {
clearTimeout(timer);
}
}
function repositoryFor(value: unknown, node: string, lane: string): Record<string, unknown> {
if (!Array.isArray(value)) throw new Error(`${pacConfigPath}#repositories must be an array`);
const expectedId = `sentinel-${node.toLowerCase()}-${lane.toLowerCase()}`;
const repository = value.find((item) => recordOrNull(item)?.id === expectedId);
if (repository === undefined) throw new Error(`${pacConfigPath} has no repository ${expectedId}`);
return record(repository, `${pacConfigPath}#repositories.${expectedId}`);
}
function authoritativeValue(
candidates: ReadonlyArray<readonly [unknown, string]>,
validate: (value: string) => boolean,
fallbackCodes: string[],
): string {
for (const [candidate, code] of candidates) {
if (candidate === undefined || candidate === null || candidate === "") {
fallbackCodes.push(code);
continue;
}
if (typeof candidate === "string" && validate(candidate)) return candidate;
fallbackCodes.push(code);
}
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
}
function authoritativeNumber(
candidates: ReadonlyArray<readonly [unknown, string]>,
validate: (value: number) => boolean,
fallbackCodes: string[],
): number {
for (const [candidate, code] of candidates) {
if (candidate === undefined || candidate === null || candidate === "") {
fallbackCodes.push(code);
continue;
}
const value = typeof candidate === "number" ? candidate : Number(candidate);
if (Number.isFinite(value) && validate(value)) return value;
fallbackCodes.push(code);
}
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
}
function validateInput(
input: string | undefined,
desired: string,
validate: (value: string) => boolean,
label: string,
fallbackCodes: string[],
): void {
if (input === undefined || input === "") return;
if (!validate(input)) fallbackCodes.push(`INVALID_${label}_INPUT`);
else if (input !== desired) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
}
function validateNumberInput(input: string | undefined, desired: number, label: string, fallbackCodes: string[]): void {
if (input === undefined || input === "") return;
const value = Number(input);
if (!Number.isFinite(value)) fallbackCodes.push(`INVALID_${label}_INPUT`);
else if (input !== String(desired)) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
}
function validEndpoint(value: string): boolean {
if (hasTemplatePlaceholder(value)) return false;
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:")
&& url.hostname.length > 0
&& url.username.length === 0
&& url.password.length === 0;
} catch {
return false;
}
}
function validServiceName(value: string): boolean {
return !hasTemplatePlaceholder(value) && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
}
function hasTemplatePlaceholder(value: string): boolean {
return /\{\{[^}]+\}\}|\$\{[^}]+\}/u.test(value);
}
function warning(
config: CicdOtelRuntimeConfig,
traceId: string,
causeCode: string,
details: Pick<CicdOtelExportWarning, "httpStatus" | "timedOut" | "errorType">,
): CicdOtelExportWarning {
return {
event: "cicd.otel.export",
warning: true,
blocking: false,
causeCode,
endpointHostname: config.endpointHostname,
...details,
timeoutMs: config.timeoutMs,
traceId,
valuesRedacted: true,
};
}
function errorCauseCode(error: unknown): string {
if (error instanceof Error && error.name === "AbortError") return "EXPORT_TIMEOUT";
const cause = error instanceof Error && typeof error.cause === "object" && error.cause !== null ? error.cause as Record<string, unknown> : null;
return typeof cause?.code === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(cause.code) ? cause.code : "EXPORT_FAILED";
}
function record(value: unknown, path: string): Record<string, unknown> {
const result = recordOrNull(value);
if (result === null) throw new Error(`${path} must be an object`);
return result;
}
function recordOrNull(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
@@ -0,0 +1,133 @@
import { afterEach, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rootPath } from "../../src/config";
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config";
const roots: string[] = [];
afterEach(() => {
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
});
test("unexpanded Repository inputs fall back to materialized owning YAML", () => {
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}",
OTEL_SERVICE_NAME: "{{otel_service_name}}",
OTEL_TRACES_SAMPLER_ARG: "{{otel_sampling_ratio}}",
OTEL_EXPORTER_TIMEOUT_MS: "{{otel_exporter_timeout_ms}}",
});
expect(config).toMatchObject({
endpoint: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces",
endpointHostname: "otel-collector.platform-infra.svc.cluster.local",
serviceName: "unidesk-cicd-sentinel-nc01",
samplingRatio: 1,
timeoutMs: 300,
});
expect(config.fallbackCodes).toEqual([
"INVALID_ENDPOINT_INPUT",
"INVALID_SERVICE_INPUT",
"INVALID_SAMPLING_INPUT",
"INVALID_TIMEOUT_INPUT",
]);
});
test("valid but mismatched runtime inputs cannot override owning YAML", () => {
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.example.invalid:4318/v1/traces",
OTEL_SERVICE_NAME: "different-service",
OTEL_TRACES_SAMPLER_ARG: "0.5",
OTEL_EXPORTER_TIMEOUT_MS: "600",
});
expect(config.endpointHostname).toBe("otel-collector.platform-infra.svc.cluster.local");
expect(config.serviceName).toBe("unidesk-cicd-sentinel-nc01");
expect(config.samplingRatio).toBe(1);
expect(config.timeoutMs).toBe(300);
expect(config.fallbackCodes).toEqual([
"MISMATCH_ENDPOINT_INPUT",
"MISMATCH_SERVICE_INPUT",
"MISMATCH_SAMPLING_INPUT",
"MISMATCH_TIMEOUT_INPUT",
]);
});
test("legal owning endpoint and non-blocking exporter failure share the bounded helper", async () => {
const config = loadCicdOtelRuntimeConfig("NC01", "v03", {
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces",
OTEL_SERVICE_NAME: "unidesk-cicd-sentinel-nc01",
OTEL_TRACES_SAMPLER_ARG: "1",
OTEL_EXPORTER_TIMEOUT_MS: "300",
});
expect(config.fallbackCodes).toEqual([]);
const warning = await exportCicdOtelPayload(
config,
"00-11111111111111111111111111111111-2222222222222222-01",
"11111111111111111111111111111111",
{ resourceSpans: [] },
async () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ENOTFOUND" } }); },
);
expect(warning).toEqual({
event: "cicd.otel.export",
warning: true,
blocking: false,
causeCode: "ENOTFOUND",
endpointHostname: "otel-collector.platform-infra.svc.cluster.local",
timedOut: false,
errorType: "TypeError",
timeoutMs: 300,
traceId: "11111111111111111111111111111111",
valuesRedacted: true,
});
});
test("actual renderer replaces unexpanded inputs without changing business exit", async () => {
const result = await render({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}" }, false);
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain('"event":"cicd.otel.config"');
expect(result.stderr).toContain('"blocking":false');
expect(result.stderr).toContain('"endpointHostname":"otel-collector.platform-infra.svc.cluster.local"');
expect(result.stderr).not.toContain("{{otel_traces_endpoint}}");
expect(readFileSync(join(result.outputDir, "source.sh"), "utf8")).toContain("http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces");
});
async function render(env: Record<string, string>, traced = true) {
const outputDir = mkdtempSync(join(tmpdir(), "unidesk-render-sentinel-publish-"));
roots.push(outputDir);
const child = Bun.spawn({
cmd: [
process.execPath,
"scripts/native/cicd/render-sentinel-publish-task.ts",
"--node", "NC01",
"--lane", "v03",
"--sentinel", "nc01-web-probe-sentinel",
"--pipeline-run", "hwlab-web-probe-sentinel-nc01-test",
"--source-commit", "a".repeat(40),
"--source-stage-ref", `refs/unidesk/snapshots/test/${"a".repeat(40)}`,
"--source-authority", "gitea-snapshot",
"--output-dir", outputDir,
],
cwd: rootPath(),
env: {
...process.env,
TRACEPARENT: traced ? "00-11111111111111111111111111111111-2222222222222222-01" : "",
OTEL_SERVICE_NAME: "unidesk-cicd-test",
OTEL_TRACES_SAMPLER_ARG: "1",
OTEL_EXPORTER_TIMEOUT_MS: "300",
...env,
},
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
return {
exitCode,
stdout,
stderr,
outputDir,
};
}
@@ -8,6 +8,7 @@ import {
sentinelPublishShell,
sentinelPublishSourceShell,
} from "../../src/hwlab-node-web-sentinel-cicd-jobs";
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config";
const options = parseOptions(process.argv.slice(2));
const sourceAuthority = options.sourceAuthority === "gitea-snapshot" ? "gitea-snapshot" : fail("--source-authority must be gitea-snapshot");
@@ -25,6 +26,13 @@ const state = loadSentinelCicdState(
);
const outputDir = resolve(options.outputDir);
mkdirSync(outputDir, { recursive: true });
const otelConfig = loadCicdOtelRuntimeConfig(options.node, options.lane);
const otelSetup = [
`export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellQuote(otelConfig.endpoint)}`,
`export OTEL_EXPORTER_TIMEOUT_MS=${shellQuote(String(otelConfig.timeoutMs))}`,
`export OTEL_SERVICE_NAME=${shellQuote(otelConfig.serviceName)}`,
`export OTEL_TRACES_SAMPLER_ARG=${shellQuote(String(otelConfig.samplingRatio))}`,
].join("\n");
const traceSetup = [
"if [ -f /workspace/meta/traceparent ]; then TRACEPARENT=$(cat /workspace/meta/traceparent); export TRACEPARENT; fi",
"if [ -f /workspace/meta/tracestate ]; then TRACESTATE=$(cat /workspace/meta/tracestate); export TRACESTATE; fi",
@@ -33,14 +41,16 @@ const proxySetup = sentinelImageBuildProxyEnv(state).map(({ name, value }) => `e
writeExecutable("source.sh", [
"#!/bin/sh",
"set -eu",
otelSetup,
`incoming_traceparent=${shellQuote(process.env.TRACEPARENT ?? "")}`,
`incoming_tracestate=${shellQuote(process.env.TRACESTATE ?? "")}`,
sentinelPublishSourceShell(state, options.pipelineRun),
"if [ -n \"$incoming_traceparent\" ]; then printf '%s' \"$incoming_traceparent\" > /workspace/meta/traceparent; fi",
"if [ -n \"$incoming_tracestate\" ]; then printf '%s' \"$incoming_tracestate\" > /workspace/meta/tracestate; fi",
].join("\n"));
writeExecutable("image-build.sh", ["#!/bin/sh", "set -eu", traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n"));
writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n"));
writeExecutable("image-build.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n"));
writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n"));
emitConfigFallbackWarning();
await emitOuterSpan();
process.stdout.write(`${JSON.stringify({ ok: true, action: "render-sentinel-publish-task", node: options.node, lane: options.lane, sentinel: options.sentinel, pipelineRun: options.pipelineRun, sourceCommit: options.sourceCommit, outputDir, traceId: traceId(process.env.TRACEPARENT), valuesRedacted: true })}\n`);
@@ -80,13 +90,12 @@ function traceId(traceparent: string | undefined): string | null {
}
async function emitOuterSpan(): Promise<void> {
const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
const context = process.env.TRACEPARENT?.match(/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/iu);
if (endpoint === undefined || context === undefined || context === null) return;
if (context === undefined || context === null || !shouldSample(context[1] ?? "", otelConfig.samplingRatio)) return;
const startedAtMs = Number(process.env.CICD_TRACE_STARTED_AT_MS ?? Date.now());
const payload = {
resourceSpans: [{
resource: { attributes: attributes({ "service.name": process.env.OTEL_SERVICE_NAME ?? "unidesk-cicd", "unidesk.node": options.node, "hwlab.lane": options.lane, "unidesk.values_redacted": true }) },
resource: { attributes: attributes({ "service.name": otelConfig.serviceName, "unidesk.node": options.node, "hwlab.lane": options.lane, "unidesk.values_redacted": true }) },
scopeSpans: [{
scope: { name: "unidesk.cicd.pac_outer", version: "1" },
spans: [{
@@ -115,22 +124,20 @@ async function emitOuterSpan(): Promise<void> {
}],
}],
};
const controller = new AbortController();
const timeoutMs = exporterTimeoutMs();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", traceparent: process.env.TRACEPARENT ?? "" }, body: JSON.stringify(payload), signal: controller.signal });
if (!response.ok) process.stderr.write(`${JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, httpStatus: response.status, timeoutMs, traceId: context[1]?.toLowerCase(), valuesRedacted: true })}\n`);
} catch (error) {
process.stderr.write(`${JSON.stringify({ event: "cicd.otel.export", warning: true, blocking: false, timedOut: error instanceof Error && error.name === "AbortError", errorType: error instanceof Error ? error.name : "Error", timeoutMs, traceId: context[1]?.toLowerCase(), valuesRedacted: true })}\n`);
} finally {
clearTimeout(timer);
}
const warning = await exportCicdOtelPayload(otelConfig, process.env.TRACEPARENT ?? "", context[1]?.toLowerCase() ?? "", payload);
if (warning !== null) process.stderr.write(`${JSON.stringify(warning)}\n`);
}
function exporterTimeoutMs(): number {
const value = Number(process.env.OTEL_EXPORTER_TIMEOUT_MS ?? "300");
return Number.isInteger(value) && value >= 50 && value <= 2_000 ? value : 300;
function emitConfigFallbackWarning(): void {
if (otelConfig.fallbackCodes.length === 0) return;
process.stderr.write(`${JSON.stringify({ event: "cicd.otel.config", warning: true, blocking: false, causeCodes: otelConfig.fallbackCodes, endpointHostname: otelConfig.endpointHostname, valuesRedacted: true })}\n`);
}
function shouldSample(traceIdValue: string, ratio: number): boolean {
if (ratio <= 0) return false;
if (ratio >= 1) return true;
const bucket = Number.parseInt(traceIdValue.slice(0, 8), 16) / 0xffffffff;
return bucket < ratio;
}
function attributes(values: Record<string, unknown>): Array<Record<string, unknown>> {
+8 -2
View File
@@ -499,9 +499,13 @@ describe("migrated CLI 执行 guard 与提示清理", () => {
});
test("Gitea 与 mirror 默认 plan 只返回只读 Next", async () => {
const giteaPlan = await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]) as Record<string, unknown>;
const nc01MirrorPlan = await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]) as Record<string, unknown>;
const jd01MirrorPlan = await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "JD01", "--raw"]) as Record<string, unknown>;
const plans = [
await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]),
await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]),
giteaPlan,
nc01MirrorPlan,
jd01MirrorPlan,
] as Record<string, unknown>[];
for (const plan of plans) {
expect(plan.mutation).toBe(false);
@@ -509,6 +513,8 @@ describe("migrated CLI 执行 guard 与提示清理", () => {
expect(JSON.stringify(plan.next)).toContain("status");
expect(JSON.stringify(plan.next)).toContain(PAC_AUTOMATIC_DELIVERY_REFERENCE);
}
expect(JSON.stringify(nc01MirrorPlan.credentials)).toContain("github-upstream:selfmedia-nc01");
expect(JSON.stringify(jd01MirrorPlan.credentials)).not.toContain("github-upstream:selfmedia-nc01");
});
test("PaC history detail 只按唯一 consumer 生成 Next,四类前缀无默认 consumer 回退", () => {
+2 -1
View File
@@ -314,6 +314,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
function mirrorPlan(options: CommonOptions): Record<string, unknown> {
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
const targetRepos = repositoriesForTarget(gitea, target);
return {
ok: true,
action: "platform-infra-gitea-mirror-plan",
@@ -321,7 +322,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
target: targetSummary(target),
sourceAuthority: sourceAuthoritySummary(gitea, target),
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
repositories: targetRepos.map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea, targetRepos),
next: mirrorReadOnlyNextCommands(target.id),
};