ci: standardize artifact summary catalog

This commit is contained in:
Codex
2026-05-19 15:34:10 +00:00
parent c90c939eac
commit 76b27c6aa9
7 changed files with 318 additions and 49 deletions
+146 -42
View File
@@ -41,6 +41,7 @@ interface CiPublishBackendCoreOptions {
commit: string;
waitMs: number;
sourceHostPath: string;
dryRun: boolean;
}
interface CiPublishUserServiceArtifactOptions {
@@ -116,6 +117,13 @@ interface ArtifactSummary {
digestRef: string | null;
}
interface ArtifactSummaryContext {
serviceId: string;
commit: string;
repoUrl: string;
dockerfile: string;
}
function stringOption(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
@@ -863,34 +871,112 @@ function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: Pipeli
return wait.ok || wait.exitCode === 0 || wait.stdout.includes("condition=True\tSucceeded\t");
}
function parseArtifactSummaryFromOutput(output: string, serviceId: string, commit: string, repoUrl: string, dockerfile: string): ArtifactSummary {
const fields = new Map<string, string>();
for (const line of output.split(/\r?\n/u)) {
const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim());
if (match !== null) fields.set(match[1], match[2]);
}
const digest = fields.get("user_service_artifact_digest") ?? fields.get("backend_core_artifact_digest") ?? null;
const imageRef = fields.get("user_service_artifact_image") ?? fields.get("backend_core_artifact_image") ?? `127.0.0.1:5000/unidesk/${serviceId}:${commit}`;
const repository = fields.get("user_service_artifact_repository") ?? `127.0.0.1:5000/unidesk/${serviceId}`;
const digestRef = fields.get("user_service_artifact_digest_ref") ?? fields.get("backend_core_artifact_digest_ref") ?? (digest === null || digest.length === 0 ? null : `${repository}@${digest}`);
function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary {
const registry = "127.0.0.1:5000";
const repository = `${registry}/unidesk/${context.serviceId}`;
return {
serviceId: fields.get("user_service_artifact_service_id") ?? serviceId,
sourceCommit: fields.get("user_service_artifact_source_commit") ?? fields.get("backend_core_artifact_source_commit") ?? commit,
sourceRepo: fields.get("user_service_artifact_source_repo") ?? repoUrl,
dockerfile: fields.get("user_service_artifact_dockerfile") ?? dockerfile,
registry: fields.get("user_service_artifact_registry") ?? "127.0.0.1:5000",
serviceId: context.serviceId,
sourceCommit: context.commit,
sourceRepo: context.repoUrl,
dockerfile: context.dockerfile,
registry,
repository,
tag: fields.get("user_service_artifact_tag") ?? commit,
tag: context.commit,
imageRef: `${repository}:${context.commit}`,
digest: null,
digestRef: null,
};
}
function artifactSummaryField(fields: Map<string, string>, suffix: string): string | null {
const value = fields.get(`user_service_artifact_${suffix}`) ?? fields.get(`backend_core_artifact_${suffix}`) ?? null;
return value === null || value.length === 0 ? null : value;
}
function parseArtifactSummaryFromFields(fields: Map<string, string>, context: ArtifactSummaryContext): ArtifactSummary {
const planned = artifactSummaryDefaults(context);
const registry = artifactSummaryField(fields, "registry") ?? planned.registry;
const repository = artifactSummaryField(fields, "repository") ?? `${registry}/unidesk/${context.serviceId}`;
const tag = artifactSummaryField(fields, "tag") ?? planned.tag;
const imageRef = artifactSummaryField(fields, "image") ?? (repository.length > 0 && tag.length > 0 ? `${repository}:${tag}` : planned.imageRef);
const digest = artifactSummaryField(fields, "digest");
const digestRef = artifactSummaryField(fields, "digest_ref") ?? (digest === null || digest.length === 0 || repository.length === 0 ? null : `${repository}@${digest}`);
return {
serviceId: artifactSummaryField(fields, "service_id") ?? planned.serviceId,
sourceCommit: artifactSummaryField(fields, "source_commit") ?? planned.sourceCommit,
sourceRepo: artifactSummaryField(fields, "source_repo") ?? planned.sourceRepo,
dockerfile: artifactSummaryField(fields, "dockerfile") ?? planned.dockerfile,
registry,
repository,
tag,
imageRef,
digest,
digestRef,
};
}
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
if (artifact.serviceId.length === 0 || artifact.sourceCommit.length !== 40 || artifact.imageRef.length === 0 || artifact.tag.length === 0 || artifact.digest === null || artifact.digest.length === 0 || artifact.digestRef === null || artifact.digestRef.length === 0) {
throw new Error(`artifact summary for ${pipelineRun} is incomplete`);
function parseArtifactSummaryFromOutput(output: string, context: ArtifactSummaryContext): ArtifactSummary {
const fields = new Map<string, string>();
for (const line of output.split(/\r?\n/u)) {
const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim());
if (match !== null) fields.set(match[1], match[2]);
}
return parseArtifactSummaryFromFields(fields, context);
}
function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] {
const missing: string[] = [];
if (artifact.serviceId.length === 0) missing.push("serviceId");
if (!/^[0-9a-f]{40}$/u.test(artifact.sourceCommit)) missing.push("sourceCommit");
if (artifact.sourceRepo.length === 0) missing.push("sourceRepo");
if (artifact.dockerfile.length === 0) missing.push("dockerfile");
if (artifact.imageRef.length === 0) missing.push("imageRef");
if (artifact.tag.length === 0) missing.push("tag");
if (artifact.digest === null || artifact.digest.length === 0) missing.push("digest");
if (artifact.digestRef === null || artifact.digestRef.length === 0) missing.push("digestRef");
return missing;
}
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
const missing = missingArtifactSummaryFields(artifact);
if (missing.length > 0) {
throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`);
}
}
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`,
].join("\n"), 60_000, 45_000);
if (result.ok && result.stdout.trim().length > 0) {
try {
const parsed = JSON.parse(result.stdout) as unknown;
const fields = new Map<string, string>();
const list = asRecord(parsed);
const items = Array.isArray(list?.items) ? list.items : [];
for (const item of items) {
const taskRun = asRecord(item);
const status = asRecord(taskRun?.status);
const results = Array.isArray(status?.results) ? status.results : [];
for (const rawResult of results) {
const taskResult = asRecord(rawResult);
const nameValue = asString(taskResult?.name);
const value = asString(taskResult?.value);
if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) {
fields.set(nameValue, value);
}
}
}
if (fields.size > 0) {
const fromResults = parseArtifactSummaryFromFields(fields, context);
if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults;
}
} catch {
// Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun.
}
}
return parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context);
}
async function readPipelineRunLogText(name: string): Promise<string> {
@@ -931,25 +1017,47 @@ async function run(options: CiOptions): Promise<Record<string, unknown>> {
}
async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const plannedArtifact: ArtifactSummary = {
const summaryContext: ArtifactSummaryContext = {
serviceId: "backend-core",
sourceCommit: options.commit,
sourceRepo: options.repoUrl,
dockerfile: "src/components/backend-core/Dockerfile",
registry: "127.0.0.1:5000",
repository: "127.0.0.1:5000/unidesk/backend-core",
tag: options.commit,
imageRef: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`,
digest: null,
digestRef: null,
commit: options.commit,
repoUrl: options.repoUrl,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
return {
ok: true,
mode: "dry-run",
pipeline: "unidesk-backend-core-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
sourceHostPath: options.sourceHostPath,
source: {
ok: true,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: repoSshUrl(options.repoUrl),
commit: options.commit,
dockerfile: "src/components/backend-core/Dockerfile",
sourceHostPath: options.sourceHostPath,
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
next: [
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
],
};
}
const source = await prepareBackendCoreArtifactSource(config, options);
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
const wait = await waitForPipelineRun(name, options.waitMs);
const condition = wait === null ? null : await readPipelineRunCondition(name);
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
const artifact = waitSucceeded && wait !== null
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), "backend-core", options.commit, options.repoUrl, "src/components/backend-core/Dockerfile")
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
@@ -979,18 +1087,13 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
}
async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
const plannedArtifact: ArtifactSummary = {
const summaryContext: ArtifactSummaryContext = {
serviceId: options.serviceId,
sourceCommit: options.commit,
sourceRepo: options.repoUrl,
dockerfile: options.dockerfile,
registry: "127.0.0.1:5000",
repository: `127.0.0.1:5000/unidesk/${options.serviceId}`,
tag: options.commit,
imageRef: `127.0.0.1:5000/unidesk/${options.serviceId}:${options.commit}`,
digest: null,
digestRef: null,
} satisfies ArtifactSummary;
commit: options.commit,
repoUrl: options.repoUrl,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
return {
ok: true,
@@ -1026,7 +1129,7 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
const condition = wait === null ? null : await readPipelineRunCondition(name);
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
const artifact = waitSucceeded && wait !== null
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), options.serviceId, options.commit, options.repoUrl, options.dockerfile)
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
@@ -1334,7 +1437,7 @@ export function ciHelp(): Record<string, unknown> {
command: "bun scripts/cli.ts ci publish-user-service --service <service-id> --commit <full-sha>",
initiallySupportedServices: ["baidu-netdisk", "decision-center"],
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
outputFields: ["imageRef", "tag", "digest", "sourceCommit", "serviceId"],
outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"],
boundary: "artifact producer only; no prod deploy and no production namespace mutation",
},
runDevE2E: {
@@ -1367,7 +1470,8 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
const waitMs = numberOption(args, "--wait-ms", 0);
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit) });
const dryRun = boolFlag(args, "--dry-run");
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit), dryRun });
}
if (action === "publish-user-service") {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));