feat: add selfmedia production release lane

This commit is contained in:
Codex
2026-07-14 13:15:28 +02:00
parent 28c1468088
commit df37c7dc84
11 changed files with 323 additions and 14 deletions
@@ -26,19 +26,30 @@ test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => {
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: `${mirror.gitea.readUrl}/`,
sourceBranch: "main",
});
expect(selected.key).toBe("widgets-nc01");
const releaseMirror = { ...mirror, key: "widgets-release-nc01", upstream: { ...mirror.upstream, branch: "release" } };
expect(resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, releaseMirror]), "NC01", {
id: "widgets-release-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "release",
}).key).toBe("widgets-release-nc01");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "main",
})).toThrow("no exact match");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, { ...mirror }]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
sourceBranch: "main",
})).toThrow("2 exact matches");
});
@@ -6,6 +6,7 @@ export interface PacBootstrapRepositoryIdentity {
readonly owner: string;
readonly repo: string;
readonly cloneUrl: string;
readonly sourceBranch: string;
}
export function resolvePacBootstrapMirrorRepository(
@@ -18,12 +19,13 @@ export function resolvePacBootstrapMirrorRepository(
&& item.gitea.owner === repository.owner
&& item.gitea.name === repository.repo
&& repositoryUrlIdentity(item.gitea.readUrl) === repositoryUrlIdentity(repository.cloneUrl)
&& item.upstream.branch === repository.sourceBranch
));
if (candidates.length !== 1) {
const disposition = candidates.length === 0 ? "no exact match" : `${candidates.length} exact matches`;
throw new Error(
`cannot resolve the YAML-owned Gitea bootstrap repository for PaC repository ${repository.id} on ${targetId}: ${disposition}; `
+ "match targetId, gitea owner/name, and read URL in config/platform-infra/gitea.yaml",
+ "match targetId, gitea owner/name, read URL, and source branch in config/platform-infra/gitea.yaml",
);
}
return candidates[0];
@@ -989,7 +989,10 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<
const repository = resolveRepository(pac, consumer.repositoryRef);
let mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>;
try {
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, {
...repository,
sourceBranch: repository.params.source_branch,
});
} catch (error) {
return pacBootstrapYamlMatchFailure(target.id, consumer.id, error);
}
+43 -4
View File
@@ -94,7 +94,8 @@ export function readSelfMediaConfig(): SelfMediaConfig {
const cutover = record(root.cutover, "cutover");
const deliveryRecords = record(delivery.targets, "delivery.targets");
const cutoverRecords = record(cutover.targets, "cutover.targets");
const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))]));
const resolvedDeliveryRecords = resolveDeliveryRecords(deliveryRecords);
const deliveryTargets = Object.fromEntries(Object.entries(resolvedDeliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, value)]));
const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))]));
const defaultTargetId = text(defaults.targetId, "defaults.targetId");
if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) {
@@ -111,6 +112,12 @@ export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMe
return target;
}
export function resolveSelfMediaDeliveryTargetByConfigRef(configRef: string): SelfMediaDeliveryTarget {
const prefix = `${selfMediaConfigRef}#delivery.targets.`;
if (!configRef.startsWith(prefix)) throw new Error(`selfmedia delivery configRef must start with ${prefix}`);
return resolveSelfMediaDeliveryTarget(configRef.slice(prefix.length));
}
export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget {
const config = readSelfMediaConfig();
const id = targetId ?? config.defaultTargetId;
@@ -151,19 +158,19 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`);
const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`);
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`);
if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`);
const node = text(value.node, `${path}.node`);
if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`);
if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`);
if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`);
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) <= 0) throw new Error(`${path}.deployment.publicExposure.hostPort must be positive`);
validateAccessSecret(deployment, `${path}.deployment`);
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
return {
id,
node: id,
node,
lane: text(value.lane, `${path}.lane`),
route: text(value.route, `${path}.route`),
ci: {
@@ -207,6 +214,38 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
};
}
function resolveDeliveryRecords(records: Record<string, unknown>): Record<string, Record<string, unknown>> {
const resolved: Record<string, Record<string, unknown>> = {};
const resolving = new Set<string>();
const resolveOne = (id: string): Record<string, unknown> => {
if (resolved[id] !== undefined) return resolved[id];
if (resolving.has(id)) throw new Error(`${selfMediaConfigRef}.delivery.targets.${id}.extends contains a cycle`);
const raw = record(records[id], `delivery.targets.${id}`);
resolving.add(id);
const parentId = raw.extends === undefined ? null : text(raw.extends, `delivery.targets.${id}.extends`);
const own = Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "extends"));
const effective = parentId === null ? structuredClone(own) : deepMerge(resolveOne(parentId), own);
resolving.delete(id);
resolved[id] = effective;
return effective;
};
for (const id of Object.keys(records)) resolveOne(id);
return resolved;
}
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const result = structuredClone(base);
for (const [key, value] of Object.entries(override)) {
const current = result[key];
result[key] = isPlainRecord(current) && isPlainRecord(value) ? deepMerge(current, value) : structuredClone(value);
}
return result;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
const secrets = record(deployment.secrets, `${path}.secrets`);
const access = record(secrets.access, `${path}.secrets.access`);
+14 -2
View File
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
import {
resolveSelfMediaDeliveryTarget,
resolveSelfMediaDeliveryTargetByConfigRef,
selfMediaDeliveryConfigRef,
selfMediaEffectiveDeployment,
type SelfMediaDeliveryTarget,
@@ -39,7 +40,7 @@ export interface SelfMediaRenderedPipeline {
}
export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline {
const target = resolveSelfMediaDeliveryTarget(binding.consumer.node);
const target = resolveSelfMediaDeliveryTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
const configRef = selfMediaDeliveryConfigRef(target.id);
if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
throw new Error("selfmedia-runtime requires embedded-pipeline-spec");
@@ -320,7 +321,8 @@ function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Reco
throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`);
}
const output = result.stdout;
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"];
const publicExposure = requiredRecord(effectiveDeployment.publicExposure, "deployment.publicExposure");
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", `hostPort: ${requiredInteger(publicExposure.hostPort, "deployment.publicExposure.hostPort")}`, "automountServiceAccountToken: false"];
for (const marker of required) {
if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`);
}
@@ -354,3 +356,13 @@ function requiredStringArray(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`);
return value as string[];
}
function requiredRecord(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function requiredInteger(value: unknown, path: string): number {
if (!Number.isInteger(value)) throw new Error(`${path} must be an integer`);
return value as number;
}