fix: isolate PaC validation by selected consumer

This commit is contained in:
Codex
2026-07-14 17:17:20 +02:00
parent 0ef9832ab0
commit 4e2e7f6268
7 changed files with 272 additions and 54 deletions
@@ -4,7 +4,8 @@ import { resolve } from "node:path";
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
import { renderMirrorBootstrap } from "./platform-infra-gitea-render";
import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
import { readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig } from "./platform-infra-pipelines-as-code";
import { parsePacConfigDocument, readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code";
import { materializeYamlComposition } from "./yaml-composition";
const mirror: GiteaMirrorRepository = {
key: "widgets-nc01",
@@ -20,6 +21,11 @@ function fixtureConfig(repositories: GiteaMirrorRepository[]): GiteaConfig {
return { sourceAuthority: { repositories } } as unknown as GiteaConfig;
}
function pacConfigDocument(): Record<string, unknown> {
const source = Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record<string, unknown>;
return structuredClone(materializeYamlComposition(source, { label: "config/platform-infra/pipelines-as-code.yaml" }).value);
}
test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => {
const selected = resolvePacBootstrapMirrorRepository(fixtureConfig([mirror]), "nc01", {
id: "widgets-nc01",
@@ -87,6 +93,7 @@ test("unselected production validation is a non-blocking warning while selected
const warning = warnings.find((item) => item.consumer === "selfmedia-production-nc01");
expect(warning).toEqual({
code: "pac-unselected-consumer-invalid",
object: { kind: "consumer", id: "selfmedia-production-nc01" },
consumer: "selfmedia-production-nc01",
configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.selfmedia-production-nc01",
blocking: false,
@@ -95,6 +102,47 @@ test("unselected production validation is a non-blocking warning while selected
expect(() => validatePacConfig(config, new Set(["selfmedia-production-nc01"]))).toThrow();
});
test("unselected parse and repository errors remain warnings while selected objects fail closed", () => {
const invalidConsumerDocument = pacConfigDocument();
const invalidConsumer = (invalidConsumerDocument.consumers as Array<Record<string, unknown>>).find((item) => item.id === "selfmedia-production-nc01");
if (invalidConsumer === undefined) throw new Error("production consumer fixture is required");
invalidConsumer.namespace = 42;
const development = parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-nc01" });
expect(development.consumers.some((consumer) => consumer.id === "selfmedia-production-nc01")).toBe(false);
expect(development.validationWarnings).toContainEqual({
code: "pac-unselected-consumer-parse-invalid",
object: { kind: "consumer", id: "selfmedia-production-nc01" },
consumer: "selfmedia-production-nc01",
configPath: expect.stringMatching(/#consumers\[\d+\]$/u),
blocking: false,
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
});
expect(() => parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-production-nc01" })).toThrow("namespace");
const invalidRepositoryDocument = pacConfigDocument();
const invalidRepository = (invalidRepositoryDocument.repositories as Array<Record<string, unknown>>).find((item) => item.id === "pikaoa-nc01");
if (invalidRepository === undefined) throw new Error("pikaoa repository fixture is required");
invalidRepository.cloneUrl = "https://gitea.example.invalid/mirrors/pikaoa.git";
const isolated = parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "selfmedia-nc01" });
expect(isolated.validationWarnings).toContainEqual({
code: "pac-unselected-repository-invalid",
object: { kind: "repository", id: "pikaoa-nc01" },
consumer: null,
configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-nc01",
blocking: false,
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
});
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-nc01")).toBe(false);
expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl");
const duplicateSelectedRepository = pacConfigDocument();
const repositories = duplicateSelectedRepository.repositories as Array<Record<string, unknown>>;
const selfmediaRepository = repositories.find((item) => item.id === "selfmedia-nc01");
if (selfmediaRepository === undefined) throw new Error("selfmedia repository fixture is required");
repositories.push(structuredClone(selfmediaRepository));
expect(() => parsePacConfigDocument(duplicateSelectedRepository, { consumerId: "selfmedia-nc01" })).toThrow("must match exactly one YAML object");
});
test("Gitea mirror bootstrap defaults to a visible dry-run instead of an empty refusal", () => {
const rendered = renderMirrorBootstrap({
ok: true,
@@ -138,7 +138,7 @@ export function renderPacBootstrap(result: Record<string, unknown>): RenderedCli
...(warnings.length === 0 ? [] : [
"",
"NON-BLOCKING WARNINGS",
...table(["CONSUMER", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [stringValue(item.consumer), boolText(item.blocking), stringValue(item.code), stringValue(item.configPath)])),
...table(["OBJECT", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [`${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`, boolText(item.blocking), stringValue(item.code), stringValue(item.configPath)])),
]),
"",
"NEXT",
@@ -19,6 +19,7 @@ import {
pacSourceArtifactSafeError,
pacValueEvidence,
pacSourceArtifactYaml,
optionalPipelineRunWorkspaces,
parsePacSourceArtifactOptions,
sourceArtifactRuntimeAlignment,
} from "./platform-infra-pipelines-as-code-source-artifact";
@@ -95,6 +96,12 @@ function runtimeInput(commit: string | null = sourceCommit): Record<string, unkn
}
describe("PaC source artifact CLI contract", () => {
test("omits empty PipelineRun workspaces and preserves declared bindings", () => {
expect(optionalPipelineRunWorkspaces([])).toEqual({});
const workspaces = [{ name: "source", emptyDir: {} }];
expect(optionalPipelineRunWorkspaces(workspaces)).toEqual({ workspaces });
});
test("verify-runtime requires and normalizes a full source commit", () => {
expect(() => parsePacSourceArtifactOptions([
"verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo",
@@ -428,7 +428,7 @@ export function renderPacSourceArtifactResult(result: Record<string, unknown>):
`embedded=status:${text(embedded?.status)} name:${text(embedded?.name)} hash:${shortSha(embedded?.canonicalSha256)} provenance:${text(embedded?.provenanceAligned)} drift:${formatDrift(embeddedDrift)}`,
`runtimeReason=live:${text(live?.reason)} embedded:${text(embedded?.reason)}`,
`written=${Array.isArray(files.written) && files.written.length > 0 ? files.written.join(",") : "-"}`,
...warnings.map((warning) => `warning=consumer:${text(warning.consumer)} blocking:${text(warning.blocking)} code:${text(warning.code)} config:${text(warning.configPath)}`),
...warnings.map((warning) => `warning=object:${text(record(warning.object).kind)}:${text(record(warning.object).id)} blocking:${text(warning.blocking)} code:${text(warning.code)} config:${text(warning.configPath)}`),
];
if (typeof result.next === "string") lines.push(`next=${result.next}`);
return renderedCliResult(result.ok !== false, String(result.action), `${lines.join("\n")}\n`);
@@ -645,6 +645,7 @@ function copySourceWorktreeForRender(sourceWorktree: string, destination: string
}
function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
const workspaces = pipelineRunWorkspaces(binding, desiredSpec);
return {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
@@ -666,13 +667,14 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
pipelineSpec: desiredSpec,
taskRunTemplate: taskRunTemplate(binding),
params: pipelineRunParams(binding, desiredSpec),
workspaces: pipelineRunWorkspaces(binding, desiredSpec),
...optionalPipelineRunWorkspaces(workspaces),
},
};
}
function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec");
const workspaces = pipelineRunWorkspaces(binding, desiredSpec);
const pipelinePath = requiredString(binding.consumer.sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath");
const annotations = {
...pipelineRunAnnotations(binding, provenance),
@@ -706,12 +708,16 @@ function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<s
timeouts: { pipeline: binding.repository.params.pipeline_timeout },
taskRunTemplate: taskRunTemplate(binding),
pipelineRef: { name: binding.consumer.pipeline },
workspaces: pipelineRunWorkspaces(binding, desiredSpec),
...optionalPipelineRunWorkspaces(workspaces),
params: pipelineRunParams(binding, desiredSpec),
},
};
}
export function optionalPipelineRunWorkspaces(workspaces: readonly Record<string, unknown>[]): Record<string, unknown> {
return workspaces.length === 0 ? {} : { workspaces };
}
function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: PacSourceArtifactProvenance): Record<string, string> {
const branch = requiredParam(binding, "source_branch");
return {
+202 -45
View File
@@ -143,8 +143,13 @@ export interface PacConfig {
}
export interface PacConfigValidationWarning {
readonly code: "pac-unselected-consumer-invalid";
readonly consumer: string;
readonly code:
| "pac-unselected-consumer-parse-invalid"
| "pac-unselected-consumer-invalid"
| "pac-unselected-repository-parse-invalid"
| "pac-unselected-repository-invalid";
readonly object: { readonly kind: "consumer" | "repository"; readonly id: string };
readonly consumer: string | null;
readonly configPath: string;
readonly blocking: false;
readonly evidence: string;
@@ -439,6 +444,13 @@ function help(scope: string | null): Record<string, unknown> {
export function readPacConfig(selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {}): PacConfig {
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
return parsePacConfigDocument(root, selection);
}
export function parsePacConfigDocument(
root: Record<string, unknown>,
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {},
): PacConfig {
const release = y.objectField(root, "release", "");
const deliveryProvenance = y.objectField(root, "deliveryProvenance", "");
const provenanceController = y.objectField(deliveryProvenance, "controller", "deliveryProvenance");
@@ -454,13 +466,19 @@ export function readPacConfig(selection: { readonly consumerId?: string | null;
const exporterFailure = y.objectField(observability, "exporterFailure", "observability");
const admin = y.objectField(gitea, "admin", "gitea");
const webhook = y.objectField(gitea, "webhook", "gitea");
const repositories = root.repositories === undefined
? [parseRepository(y.objectField(root, "repository", ""), "repository")]
: y.arrayOfRecords(root.repositories, "repositories").map((item, index) => parseRepository(item, `repositories[${index}]`));
const consumers = root.consumers === undefined
? [parseConsumer(y.objectField(root, "consumer", ""), "consumer", repositories[0]?.id ?? "default")]
: y.arrayOfRecords(root.consumers, "consumers").map((item, index) => parseConsumer(item, `consumers[${index}]`, repositories[0]?.id ?? "default"));
const defaults = y.objectField(root, "defaults", "");
const repositoryEntries = pacConfigObjectEntries(root.repositories, root.repository, "repositories", "repository");
const consumerEntries = pacConfigObjectEntries(root.consumers, root.consumer, "consumers", "consumer");
const defaultRepositoryRef = rawPacObjectId(repositoryEntries[0]?.value, repositoryEntries[0]?.path ?? "repository", "repository");
const defaultConsumerId = typeof defaults.consumerId === "string" && defaults.consumerId.length > 0
? defaults.consumerId
: rawPacObjectId(consumerEntries[0]?.value, consumerEntries[0]?.path ?? "consumer", "consumer");
const selectedConsumerIds = selectedPacConsumerIds(defaultConsumerId, selection);
assertSelectedRawObjectsUnique(consumerEntries, selectedConsumerIds, "consumer");
const selectedRepositoryRefs = selectedPacRepositoryRefs(consumerEntries, selectedConsumerIds, defaultRepositoryRef);
assertSelectedRawObjectsUnique(repositoryEntries, selectedRepositoryRefs, "repository");
const parsedRepositories = parsePacRepositories(repositoryEntries, selectedRepositoryRefs);
const parsedConsumers = parsePacConsumers(consumerEntries, selectedConsumerIds, defaultRepositoryRef);
const parsed: PacConfig = {
version: y.integerField(root, "version", ""),
kind: "platform-infra-pipelines-as-code",
@@ -472,7 +490,7 @@ export function readPacConfig(selection: { readonly consumerId?: string | null;
},
defaults: {
targetId: y.stringField(defaults, "targetId", "defaults"),
consumerId: typeof defaults.consumerId === "string" && defaults.consumerId.length > 0 ? defaults.consumerId : consumers[0]?.id ?? "default",
consumerId: defaultConsumerId,
},
display: {
timeZone: y.stringField(display, "timeZone", "display"),
@@ -538,23 +556,138 @@ export function readPacConfig(selection: { readonly consumerId?: string | null;
branch: y.stringField(webhook, "branch", "gitea.webhook"),
},
},
repositories,
consumers,
validationWarnings: [],
repositories: parsedRepositories.values,
consumers: parsedConsumers.values,
validationWarnings: [...parsedRepositories.warnings, ...parsedConsumers.warnings],
};
const selectedConsumerIds = selectedPacConsumerIds(parsed, selection);
parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds);
parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds, parsed.validationWarnings);
return parsed;
}
function selectedPacConsumerIds(
config: PacConfig,
defaultConsumerId: string,
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean },
): ReadonlySet<string> {
if (typeof selection.consumerId === "string" && selection.consumerId.length > 0) {
return new Set([selection.consumerId.toLowerCase()]);
}
return selection.selectDefault === true ? new Set([config.defaults.consumerId.toLowerCase()]) : new Set();
return selection.selectDefault === true ? new Set([defaultConsumerId.toLowerCase()]) : new Set();
}
interface PacConfigObjectEntry {
readonly value: unknown;
readonly path: string;
}
function pacConfigObjectEntries(plural: unknown, singular: unknown, pluralLabel: string, singularLabel: string): PacConfigObjectEntry[] {
if (plural === undefined) {
if (singular === undefined) throw new Error(`${configLabel}.${singularLabel} must be an object`);
return [{ value: singular, path: singularLabel }];
}
if (!Array.isArray(plural)) throw new Error(`${configLabel}.${pluralLabel} must be an array`);
return plural.map((value, index) => ({ value, path: `${pluralLabel}[${index}]` }));
}
function rawPacObjectId(value: unknown, path: string, kind: "consumer" | "repository"): string {
if (!isRecord(value)) return path;
if (typeof value.id === "string" && value.id.length > 0) return value.id;
if (kind === "repository" && typeof value.name === "string" && value.name.length > 0) return value.name;
if (kind === "consumer" && typeof value.node === "string" && value.node.length > 0 && typeof value.lane === "string" && value.lane.length > 0) {
return `${value.node}-${value.lane}`;
}
return path;
}
function rawPacObjectRefs(entry: PacConfigObjectEntry, kind: "consumer" | "repository"): string[] {
if (!isRecord(entry.value)) return [entry.path.toLowerCase()];
const refs = [rawPacObjectId(entry.value, entry.path, kind)];
if (kind === "repository" && typeof entry.value.name === "string" && entry.value.name.length > 0) refs.push(entry.value.name);
return [...new Set(refs.map((value) => value.toLowerCase()))];
}
function assertSelectedRawObjectsUnique(
entries: readonly PacConfigObjectEntry[],
selectedRefs: ReadonlySet<string>,
kind: "consumer" | "repository",
): void {
for (const selectedRef of selectedRefs) {
const matches = entries.filter((entry) => rawPacObjectRefs(entry, kind).includes(selectedRef));
if (matches.length !== 1) {
throw new Error(`selected Pipelines-as-Code ${kind} ${selectedRef} must match exactly one YAML object; matched ${matches.length}`);
}
}
}
function selectedPacRepositoryRefs(
consumerEntries: readonly PacConfigObjectEntry[],
selectedConsumerIds: ReadonlySet<string>,
defaultRepositoryRef: string,
): ReadonlySet<string> {
const refs = new Set<string>();
for (const entry of consumerEntries) {
if (!rawPacObjectRefs(entry, "consumer").some((value) => selectedConsumerIds.has(value))) continue;
const repositoryRef = isRecord(entry.value) && typeof entry.value.repositoryRef === "string" && entry.value.repositoryRef.length > 0
? entry.value.repositoryRef
: defaultRepositoryRef;
refs.add(repositoryRef.toLowerCase());
}
return refs;
}
function parsePacRepositories(
entries: readonly PacConfigObjectEntry[],
selectedRepositoryRefs: ReadonlySet<string>,
): { readonly values: PacRepository[]; readonly warnings: PacConfigValidationWarning[] } {
const values: PacRepository[] = [];
const warnings: PacConfigValidationWarning[] = [];
for (const entry of entries) {
const id = rawPacObjectId(entry.value, entry.path, "repository");
try {
if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`);
values.push(parseRepository(entry.value, entry.path));
} catch (error) {
if (rawPacObjectRefs(entry, "repository").some((value) => selectedRepositoryRefs.has(value))) throw error;
warnings.push(pacConfigWarning("pac-unselected-repository-parse-invalid", "repository", id, entry.path, error));
}
}
return { values, warnings };
}
function parsePacConsumers(
entries: readonly PacConfigObjectEntry[],
selectedConsumerIds: ReadonlySet<string>,
defaultRepositoryRef: string,
): { readonly values: PacConsumer[]; readonly warnings: PacConfigValidationWarning[] } {
const values: PacConsumer[] = [];
const warnings: PacConfigValidationWarning[] = [];
for (const entry of entries) {
const id = rawPacObjectId(entry.value, entry.path, "consumer");
try {
if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`);
values.push(parseConsumer(entry.value, entry.path, defaultRepositoryRef));
} catch (error) {
if (selectedConsumerIds.has(id.toLowerCase())) throw error;
warnings.push(pacConfigWarning("pac-unselected-consumer-parse-invalid", "consumer", id, entry.path, error));
}
}
return { values, warnings };
}
function pacConfigWarning(
code: PacConfigValidationWarning["code"],
kind: PacConfigValidationWarning["object"]["kind"],
id: string,
path: string,
error: unknown,
): PacConfigValidationWarning {
return {
code,
object: { kind, id },
consumer: kind === "consumer" ? id : null,
configPath: `${configLabel}#${path}`,
blocking: false,
evidence: pacValueEvidence(error instanceof Error ? error.message : String(error)),
};
}
function parseRepository(repository: Record<string, unknown>, path: string): PacRepository {
@@ -846,45 +979,51 @@ function parseTarget(record: Record<string, unknown>, index: number): PacTarget
}
export function validatePacConfig(config: PacConfig, selectedConsumerIds: ReadonlySet<string>): PacConfigValidationWarning[] {
return validateConfig(config, selectedConsumerIds);
return validateConfig(config, selectedConsumerIds, []);
}
function validateConfig(config: PacConfig, selectedConsumerIds: ReadonlySet<string>): PacConfigValidationWarning[] {
function validateConfig(
config: PacConfig,
selectedConsumerIds: ReadonlySet<string>,
initialWarnings: readonly PacConfigValidationWarning[],
): PacConfigValidationWarning[] {
if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`);
resolveTarget(config, config.defaults.targetId);
if (config.release.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.release.waitTimeoutSeconds must fit the 60s trans budget`);
if (config.closeout.gitOpsMirrorFlush.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.closeout.gitOpsMirrorFlush.waitTimeoutSeconds must fit the 60s trans budget`);
resolveConsumer(config, config.defaults.consumerId);
if (selectedConsumerIds.has(config.defaults.consumerId.toLowerCase())) resolveConsumer(config, config.defaults.consumerId);
const warnings: PacConfigValidationWarning[] = [...initialWarnings];
const selectedRepositoryRefs = new Set(config.consumers
.filter((consumer) => selectedConsumerIds.has(consumer.id.toLowerCase()))
.map((consumer) => consumer.repositoryRef.toLowerCase()));
const ids = new Set<string>();
for (const repository of config.repositories) {
if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`);
ids.add(repository.id);
if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`);
if (isInternalRepositoryMatchUrl(repository.url)) throw new Error(`${configLabel}.repositories.${repository.id}.url must be the public Gitea URL used in webhook payloads; keep internal service URLs in cloneUrl/params.git_read_url`);
if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`);
try {
if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`);
if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`);
if (isInternalRepositoryMatchUrl(repository.url)) throw new Error(`${configLabel}.repositories.${repository.id}.url must be the public Gitea URL used in webhook payloads; keep internal service URLs in cloneUrl/params.git_read_url`);
if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`);
ids.add(repository.id);
} catch (error) {
if (selectedRepositoryRefs.has(repository.id.toLowerCase()) || selectedRepositoryRefs.has(repository.name.toLowerCase())) throw error;
warnings.push(pacConfigWarning("pac-unselected-repository-invalid", "repository", repository.id, `repositories.${repository.id}`, error));
}
}
const consumerIds = new Set<string>();
const markerValues = new Set<string>();
const reservedServiceAccounts = new Set<string>();
const warnings: PacConfigValidationWarning[] = [];
for (const consumer of config.consumers) {
if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`);
consumerIds.add(consumer.id);
try {
if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`);
validateConsumerConfig(config, consumer, markerValues, reservedServiceAccounts);
consumerIds.add(consumer.id);
if (consumer.deliveryProvenance !== null) {
markerValues.add(consumer.deliveryProvenance.markerValue);
reservedServiceAccounts.add(`${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`);
}
} catch (error) {
if (selectedConsumerIds.has(consumer.id.toLowerCase())) throw error;
warnings.push({
code: "pac-unselected-consumer-invalid",
consumer: consumer.id,
configPath: `${configLabel}#consumers.${consumer.id}`,
blocking: false,
evidence: pacValueEvidence(error instanceof Error ? error.message : String(error)),
});
warnings.push(pacConfigWarning("pac-unselected-consumer-invalid", "consumer", consumer.id, `consumers.${consumer.id}`, error));
}
}
if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`);
@@ -977,24 +1116,38 @@ function resolveTarget(config: PacConfig, targetId: string | null): PacTarget {
function resolveConsumer(config: PacConfig, consumerId: string | null): PacConsumer {
const id = consumerId ?? config.defaults.consumerId;
const consumer = config.consumers.find((item) => item.id.toLowerCase() === id.toLowerCase());
if (consumer === undefined) throw new Error(`unknown Pipelines-as-Code consumer ${id}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`);
return consumer;
const matches = config.consumers.filter((item) => item.id.toLowerCase() === id.toLowerCase());
if (matches.length !== 1) throw new Error(`Pipelines-as-Code consumer ${id} must resolve exactly once; matched ${matches.length}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`);
return matches[0] as PacConsumer;
}
function resolveRepository(config: PacConfig, repositoryRef: string): PacRepository {
const repository = config.repositories.find((item) => item.id === repositoryRef || item.name === repositoryRef);
if (repository === undefined) throw new Error(`unknown Pipelines-as-Code repository ${repositoryRef}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`);
return repository;
const matches = config.repositories.filter((item) => item.id === repositoryRef || item.name === repositoryRef);
if (matches.length !== 1) throw new Error(`Pipelines-as-Code repository ${repositoryRef} must resolve exactly once; matched ${matches.length}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`);
return matches[0] as PacRepository;
}
function consumerParams(repository: PacRepository, consumer: PacConsumer): Record<string, string> {
return { ...repository.params, ...consumer.params };
}
function validPacConsumers(config: PacConfig): PacConsumer[] {
const invalid = new Set(config.validationWarnings.map((warning) => warning.consumer.toLowerCase()));
return config.consumers.filter((consumer) => !invalid.has(consumer.id.toLowerCase()));
export function validPacConsumers(config: PacConfig): PacConsumer[] {
const invalidConsumers = new Set(config.validationWarnings
.filter((warning) => warning.object.kind === "consumer")
.map((warning) => warning.object.id.toLowerCase()));
const invalidRepositories = new Set(config.validationWarnings
.filter((warning) => warning.object.kind === "repository")
.map((warning) => warning.object.id.toLowerCase()));
for (const repository of config.repositories) {
if (invalidRepositories.has(repository.id.toLowerCase()) || invalidRepositories.has(repository.name.toLowerCase())) {
invalidRepositories.add(repository.id.toLowerCase());
invalidRepositories.add(repository.name.toLowerCase());
}
}
return config.consumers.filter((consumer) => (
!invalidConsumers.has(consumer.id.toLowerCase())
&& !invalidRepositories.has(consumer.repositoryRef.toLowerCase())
));
}
function plan(options: CommonOptions): Record<string, unknown> {
@@ -2432,8 +2585,8 @@ function renderPacConfigWarnings(warnings: readonly Record<string, unknown>[]):
return [
"",
"NON-BLOCKING WARNINGS",
...table(["CONSUMER", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [
stringValue(item.consumer),
...table(["OBJECT", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [
`${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`,
boolText(item.blocking),
stringValue(item.code),
stringValue(item.configPath),
@@ -2955,6 +3108,10 @@ function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[] : [];
}