Merge pull request #2873 from pikasTech/fix/sentinel-gitea-publish
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / platform-infra-temporal-nc01- Failed
Pipelines as Code CI / unidesk-host- Failed

fix: 收窄通用 PaC 发布输入
This commit is contained in:
Lyon
2026-07-22 21:16:33 +08:00
committed by GitHub
8 changed files with 80 additions and 5 deletions
@@ -1168,6 +1168,20 @@ templates:
argoApplication: "hwlab-web-probe-sentinel-${nodeLower}"
deliveryObservation:
runtimeEvidence: required
releaseInputPaths:
- config
- config.json
- scripts
- src
- package.json
- bun.lock
- bun.lockb
releaseExcludePaths:
- config/platform-infra/pipelines-as-code.yaml
- scripts/native/cicd
- scripts/src/hwlab-node-web-sentinel-cicd*
- scripts/src/platform-infra-pac-*
- scripts/src/platform-infra-pipelines-as-code*
closeoutGitOpsMirrorFlush: false
closeoutGitOpsMirrorLane: v03
hwlabV03:
@@ -106,6 +106,10 @@ test("actual renderer replaces unexpanded inputs without changing business exit"
expect(source).not.toContain("GIT_SSH_COMMAND");
expect(source).toContain("chmod -R a+rwX \"$worktree\" \"$meta_dir\"");
expect(source).not.toContain("chmod -R a+rwX /workspace");
expect(source).toContain("CONTEXT_EXCLUDE_PATHS_B64");
const contextExcludes = /context_exclude_paths_b64='([^']+)'/u.exec(source);
expect(contextExcludes).not.toBeNull();
expect(JSON.parse(Buffer.from(contextExcludes![1]!, "base64").toString("utf8"))).toContain("scripts/src/platform-infra-pipelines-as-code*");
const publish = readFileSync(join(result.outputDir, "publish.sh"), "utf8");
expect(publish).toContain("UNIDESK_PAC_CONSUMER='sentinel-nc01-v03'");
expect(publish).toContain("feature-config-schema-validation");
@@ -10,6 +10,7 @@ import {
} from "../../src/hwlab-node-web-sentinel-cicd-jobs";
import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config";
import { renderPacFeatureConfigSchemaStage } from "../../src/pac-feature-config-schema-stage";
import { readPacConfig, resolveConsumer } from "../../src/platform-infra-pipelines-as-code/config";
const options = parseOptions(process.argv.slice(2));
const sourceAuthority = options.sourceAuthority === "gitea-snapshot" ? "gitea-snapshot" : fail("--source-authority must be gitea-snapshot");
@@ -26,6 +27,8 @@ const state = loadSentinelCicdState(
},
);
const outputDir = resolve(options.outputDir);
const pacConfig = readPacConfig({ consumerId: options.repositoryId });
const pacConsumer = resolveConsumer(pacConfig, options.repositoryId);
mkdirSync(outputDir, { recursive: true });
const otelConfig = loadCicdOtelRuntimeConfig(options.repositoryId);
const otelSetup = [
@@ -51,7 +54,7 @@ writeExecutable("source.sh", [
otelSetup,
`incoming_traceparent=${shellQuote(process.env.TRACEPARENT ?? "")}`,
`incoming_tracestate=${shellQuote(process.env.TRACESTATE ?? "")}`,
sentinelPublishSourceShell(state, options.pipelineRun),
sentinelPublishSourceShell(state, options.pipelineRun, pacConsumer.releaseExcludePaths),
"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"));
@@ -579,12 +579,18 @@ export function sentinelImageBuildProxyEnv(state: SentinelCicdState): Array<{ na
];
}
export function sentinelPublishSourceShell(state: SentinelCicdState, jobName: string): string {
export function sentinelPublishSourceShell(state: SentinelCicdState, jobName: string, contextExcludePaths: readonly string[] = []): string {
const monitorWeb = record(state.image.monitorWeb);
const checkoutPathsB64 = Buffer.from(JSON.stringify(arrayAt(state.cicd, "source.checkoutPaths").map((item) => {
if (typeof item !== "string" || item.length === 0 || item.startsWith("/") || item.includes("..")) throw new Error("source.checkoutPaths must contain safe relative paths");
return item;
})), "utf8").toString("base64");
const contextExcludePathsB64 = Buffer.from(JSON.stringify(contextExcludePaths.map((item) => {
if (item.length === 0 || item.startsWith("/") || item.includes("..") || (item.includes("*") && !item.endsWith("*"))) {
throw new Error("releaseExcludePaths must contain safe relative paths with only an optional trailing wildcard");
}
return item;
})), "utf8").toString("base64");
const dockerfileB64 = Buffer.from(state.image.dockerfilePreview, "utf8").toString("base64");
const envReuseMode = stringAt(monitorWeb, "envReuseMode");
const envReuseNodeDepsPath = stringAt(monitorWeb, "envReuseNodeDepsPath");
@@ -598,6 +604,7 @@ export function sentinelPublishSourceShell(state: SentinelCicdState, jobName: st
`source_commit=${shellQuote(state.sourceHead.commit ?? "")}`,
`source_stage_ref=${shellQuote(state.sourceHead.stageRef ?? "")}`,
`checkout_paths_b64=${shellQuote(checkoutPathsB64)}`,
`context_exclude_paths_b64=${shellQuote(contextExcludePathsB64)}`,
`dockerfile_b64=${shellQuote(dockerfileB64)}`,
`env_reuse_mode=${shellQuote(envReuseMode)}`,
`env_reuse_node_deps_path=${shellQuote(envReuseNodeDepsPath)}`,
@@ -708,6 +715,11 @@ export function sentinelPublishSourceShell(state: SentinelCicdState, jobName: st
".env",
".env.*",
"EOF_DOCKERIGNORE",
"CONTEXT_EXCLUDE_PATHS_B64=\"$context_exclude_paths_b64\" node <<'NODE'",
"const fs = require('node:fs');",
"const paths = JSON.parse(Buffer.from(process.env.CONTEXT_EXCLUDE_PATHS_B64 || '', 'base64').toString('utf8'));",
"if (paths.length > 0) fs.appendFileSync('.dockerignore', `${paths.join('\\n')}\\n`);",
"NODE",
"context_ignore_entries=$(wc -l < .dockerignore | tr -d ' ')",
"write_meta context_ignore_entries \"$context_ignore_entries\"",
"touch /workspace/image-build.log /workspace/build-metadata.json /workspace/buildkitd.log",
@@ -482,6 +482,23 @@ test("materialization-only release consumers never inherit shared repository ima
)).toThrow("materializationPaths must not be empty when materializationOnly is true");
});
test("Sentinel release scope and image context share YAML-owned path exclusions", () => {
const pac = readPacConfig({ consumerId: "sentinel-nc01-v03" });
const consumer = resolveConsumer(pac, "sentinel-nc01-v03");
expect(consumer.releaseInputPaths).toContain("scripts");
expect(consumer.releaseExcludePaths).toContain("scripts/src/platform-infra-pipelines-as-code*");
expect(() => validateConsumerConfig(
pac,
{ ...consumer, releaseExcludePaths: ["../outside"] },
new Set<string>(),
new Set<string>(),
)).toThrow("releaseExcludePaths must contain safe repository-relative paths");
const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8");
expect(remote).toContain("UNIDESK_PAC_MANUAL_RELEASE_INPUT_PATHS_JSON");
expect(remote).toContain("UNIDESK_PAC_MANUAL_RELEASE_EXCLUDE_PATHS_JSON");
expect(remote).toContain("releaseExcludePaths.some((inputPath) => pathMatches(changedPath, inputPath))");
});
test("PaC failed or expanded plan never points next to trigger", () => {
const identity = `sha256:${"d".repeat(64)}`;
const args = ["plan", "NC01", "hwlab-nc01-v03", "a".repeat(40), "b".repeat(40), identity] as const;
@@ -542,9 +542,15 @@ const crypto = require('node:crypto');
const changedPaths = fs.readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean);
const domain = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const materializationPaths = JSON.parse(process.env.UNIDESK_PAC_MANUAL_MATERIALIZATION_PATHS_JSON || '[]');
const effectiveChangedPaths = materializationPaths.length === 0
? changedPaths
: changedPaths.filter((changedPath) => materializationPaths.some((inputPath) => changedPath === inputPath || changedPath.startsWith(`${inputPath}/`)));
const releaseInputPaths = JSON.parse(process.env.UNIDESK_PAC_MANUAL_RELEASE_INPUT_PATHS_JSON || '[]');
const releaseExcludePaths = JSON.parse(process.env.UNIDESK_PAC_MANUAL_RELEASE_EXCLUDE_PATHS_JSON || '[]');
const selectedInputPaths = materializationPaths.length > 0 ? materializationPaths : releaseInputPaths;
const pathMatches = (changedPath, inputPath) => inputPath.endsWith('*')
? changedPath.startsWith(inputPath.slice(0, -1))
: changedPath === inputPath || changedPath.startsWith(`${inputPath}/`);
const effectiveChangedPaths = changedPaths.filter((changedPath) =>
(selectedInputPaths.length === 0 || selectedInputPaths.some((inputPath) => pathMatches(changedPath, inputPath)))
&& !releaseExcludePaths.some((inputPath) => pathMatches(changedPath, inputPath)));
const configuredImages = JSON.parse(process.env.UNIDESK_PAC_MANUAL_IMAGE_REPOSITORIES_JSON || '[]');
const affectedServices = domain?.affectedServices || (effectiveChangedPaths.length > 0 ? [process.env.UNIDESK_PAC_MANUAL_RUNTIME_SERVICE].filter(Boolean) : []);
const rolloutServices = domain?.rolloutServices || affectedServices;
@@ -610,6 +616,8 @@ const plan = {
changedPaths: effectiveChangedPaths,
sourceChangedPathCount: changedPaths.length,
materializationPaths,
releaseInputPaths,
releaseExcludePaths,
artifactCatalog: domain?.artifactCatalog || null,
rangeBaseline: {
mode: process.env.BASE_SOURCE,
@@ -1566,6 +1566,8 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress
UNIDESK_PAC_MANUAL_RUNTIME_SERVICE: manualRelease?.runtimeService ?? "",
UNIDESK_PAC_MANUAL_RENDERER: consumer.sourceArtifact?.renderer ?? "generic",
UNIDESK_PAC_MANUAL_MATERIALIZATION_PATHS_JSON: JSON.stringify(consumer.materializationPaths),
UNIDESK_PAC_MANUAL_RELEASE_INPUT_PATHS_JSON: JSON.stringify(consumer.releaseInputPaths),
UNIDESK_PAC_MANUAL_RELEASE_EXCLUDE_PATHS_JSON: JSON.stringify(consumer.releaseExcludePaths),
UNIDESK_PAC_MANUAL_NODE_MODULES: manualRelease?.planningNodeModules ?? "",
UNIDESK_PAC_MANUAL_ARTIFACT_CATALOG_PATH: manualRelease?.artifactCatalogPath ?? "",
UNIDESK_PAC_MANUAL_GITOPS_READ_URL: manualRelease?.gitopsReadUrl ?? "",
@@ -266,6 +266,8 @@ export interface PacConsumer {
params: Record<string, string>;
materializationOnly: boolean;
materializationPaths: string[];
releaseInputPaths: string[];
releaseExcludePaths: string[];
closeoutGitOpsMirrorFlush: boolean;
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
sourceArtifact: PacSourceArtifactSpec | null;
@@ -714,6 +716,12 @@ export function parseConsumer(consumer: Record<string, unknown>, path: string, d
materializationPaths: consumer.materializationPaths === undefined
? []
: y.stringArrayField(consumer, "materializationPaths", path),
releaseInputPaths: consumer.releaseInputPaths === undefined
? []
: y.stringArrayField(consumer, "releaseInputPaths", path),
releaseExcludePaths: consumer.releaseExcludePaths === undefined
? []
: y.stringArrayField(consumer, "releaseExcludePaths", path),
closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const),
sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`),
@@ -1132,6 +1140,13 @@ export function validateConsumerConfig(
if (consumer.materializationOnly && consumer.materializationPaths.length === 0) {
throw new Error(`${configLabel}.consumers.${consumer.id}.materializationPaths must not be empty when materializationOnly is true`);
}
for (const [field, paths] of [["releaseInputPaths", consumer.releaseInputPaths], ["releaseExcludePaths", consumer.releaseExcludePaths]] as const) {
for (const inputPath of paths) {
if (inputPath.startsWith("/") || inputPath.includes("\0") || inputPath.split("/").includes("..")) {
throw new Error(`${configLabel}.consumers.${consumer.id}.${field} must contain safe repository-relative paths`);
}
}
}
if (consumer.id.startsWith("sentinel-")) {
if (repository.params.otel_traces_endpoint !== config.observability.tracesEndpoint) {
throw new Error(`${configLabel}.repositories.${repository.id}.params.otel_traces_endpoint must match observability.tracesEndpoint`);