Files
pikasTech-unidesk/scripts/src/ci/publish.ts
T
Lyon edfddd2445 fix: YAML-first 治理 CI/CD target (#919)
* docs: specify cicd yaml target governance

* fix: resolve cicd targets from yaml

---------

Co-authored-by: Codex <codex@noreply.local>
2026-06-26 01:14:38 +08:00

430 lines
20 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. publish module for scripts/src/ci.ts.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Moved mechanically from scripts/src/ci.ts:2568-2959 for #903.
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix as posixPath } from "node:path";
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog";
import { runCommand } from "../command";
import { type UniDeskConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
import { jobWithTail, listJobs, readJob, startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import {
artifactRegistryReadonlyResultFromCommand,
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
type ArtifactRegistryReadonlyProbe,
} from "../artifact-registry";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { runSshCommandCapture } from "../ssh";
import type { ArtifactSummaryContext, CiOptions, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflightTransport } from "./types";
import { artifactSummaryDefaults } from "./artifact-summary";
import { publishUserServiceArtifactDirectDocker } from "./direct-docker";
import { blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
import { backendCoreArtifactPipelineRunManifest, backendCoreArtifactSourceHostPath, pipelineRunManifest, userServiceArtifactPipelineRunManifest, userServiceArtifactSourceHostPath } from "./pipelinerun";
import { pipelineRunWaitSucceeded, readPipelineRunCondition, remoteCreatePipelineRun, waitForPipelineRun } from "./pipelinerun-runtime";
import { publishPreflightControlChannelOrder, publishPreflightFailedScopes } from "./preflight";
import { assertArtifactSummaryComplete, publishBackendCorePreflight, publishUserServicePreflight, readArtifactSummaryFromPipelineRun } from "./publish-preflight";
import { localPublishPreflightTransport } from "./remote";
import { backendCoreArtifactRequirements, backendCoreDevApplyPath } from "./runner-preflight";
import { prepareBackendCoreArtifactSource, prepareClaudeqqArtifactSource, prepareUserServiceArtifactSource } from "./source-prepare";
import { d601ProviderId, providerGatewayWsEgressProxyUrl } from "./types";
export async function run(options: CiOptions): Promise<Record<string, unknown>> {
const name = await remoteCreatePipelineRun(pipelineRunManifest(options), options.target);
const wait = await waitForPipelineRun(name, options.waitMs, options.target);
const condition = wait === null ? null : await readPipelineRunCondition(name, options.target);
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
return {
ok: waitSucceeded,
providerId: options.target.providerId,
pipelineRun: name,
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
revision: options.revision,
wait: wait === null ? null : {
ok: waitSucceeded,
dispatchOk: wait.ok,
dispatchStatus: wait.status,
dispatchExitCode: wait.exitCode,
stdoutTail: wait.stdout.slice(-6000),
stderrTail: wait.stderr.slice(-6000),
},
condition,
next: [
`bun scripts/cli.ts ci logs ${name} --target ${options.target.targetId}`,
`bun scripts/cli.ts ci status --target ${options.target.targetId}`,
],
};
}
export async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const summaryContext: ArtifactSummaryContext = {
serviceId: "backend-core",
commit: options.commit,
repoUrl: options.repoUrl,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
return runCiPublishBackendCoreDryRunPreflight(config, [
"publish-backend-core",
"--commit",
options.commit,
"--dry-run",
...(options.waitMs > 0 ? ["--wait-ms", String(options.waitMs)] : []),
], localPublishPreflightTransport, options);
}
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
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
ok: waitSucceeded,
pipelineRun: name,
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
source,
artifact: artifact.imageRef,
artifactSummary: artifact,
boundary: "CI publishes the image to D601 registry; CD must pull it and must not build backend-core",
wait: wait === null ? null : {
ok: waitSucceeded,
dispatchOk: wait.ok,
dispatchStatus: wait.status,
dispatchExitCode: wait.exitCode,
stdoutTail: wait.stdout.slice(-6000),
stderrTail: wait.stderr.slice(-6000),
},
condition,
next: [
`bun scripts/cli.ts ci logs ${name}`,
`bun scripts/cli.ts deploy apply --env prod --service backend-core --commit ${options.commit}`,
],
};
}
export async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
const summaryContext: ArtifactSummaryContext = {
serviceId: options.serviceId,
dockerfile: options.dockerfile,
commit: options.commit,
repoUrl: options.repoUrl,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
if (options.dryRun) {
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, localPublishPreflightTransport);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
missingControlChannels: preflight.missingControlChannels,
controlChannels: preflight.controlChannels,
channels: preflight.channels,
failureClassification: preflight.failureClassification,
failedScopes: publishPreflightFailedScopes(preflight),
recommendedAction: preflight.recommendedAction,
remoteCommandShape: preflight.remoteCommandShape,
controlPlane: preflight.controlPlane,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: plannedRepoFetchUrl,
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
controlledPublish: {
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-user-service-artifact-publish",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
},
boundary: preflight.boundary,
next: preflight.next,
};
}
if (options.transport === "direct-docker" || (options.transport === "auto" && options.serviceId === "code-queue" && options.repoUrl === "https://github.com/pikasTech/unidesk")) {
return publishUserServiceArtifactDirectDocker(options, summaryContext);
}
const source = options.serviceId === "claudeqq"
? await prepareClaudeqqArtifactSource(config, options)
: await prepareUserServiceArtifactSource(config, options);
const name = await remoteCreatePipelineRun(userServiceArtifactPipelineRunManifest(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
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
ok: waitSucceeded,
pipelineRun: name,
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
source,
artifact: artifact.imageRef,
artifactSummary: artifact,
boundary: "CI publishes the user-service image to the D601 registry only; it must not deploy production or mutate the production namespace",
wait: wait === null ? null : {
ok: waitSucceeded,
dispatchOk: wait.ok,
dispatchStatus: wait.status,
dispatchExitCode: wait.exitCode,
stdoutTail: wait.stdout.slice(-6000),
stderrTail: wait.stderr.slice(-6000),
},
condition,
next: [
`bun scripts/cli.ts ci logs ${name}`,
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev/prod deployment input",
],
};
}
export async function runCiPublishUserServiceDryRunPreflight(
config: UniDeskConfig,
args: string[],
transport: PublishPreflightTransport,
): Promise<Record<string, unknown>> {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
if (!args.includes("--dry-run")) throw new Error("publish-user-service preflight requires --dry-run");
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo");
}
const artifact = resolveCatalogArtifact(serviceId);
if (artifact.kind === "source-build" && artifact.serviceId === "backend-core") {
throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services");
}
if (artifact.kind === "upstream-image") {
return blockedArtifactResult(artifact, commit, artifact.blockedReason);
}
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
const summaryContext: ArtifactSummaryContext = {
serviceId,
dockerfile,
commit,
repoUrl: artifact.source.repo,
imageRepository: artifact.image.repository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const options: CiPublishUserServiceArtifactOptions = {
repoUrl: artifact.source.repo,
commit,
waitMs: numberOption(args, "--wait-ms", 0),
serviceId,
dockerfile,
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun: true,
transport: publishTransportOption(stringOption(args, "--transport")),
};
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
missingControlChannels: preflight.missingControlChannels,
controlChannels: preflight.controlChannels,
channels: preflight.channels,
failureClassification: preflight.failureClassification,
failedScopes: publishPreflightFailedScopes(preflight),
recommendedAction: preflight.recommendedAction,
remoteCommandShape: preflight.remoteCommandShape,
controlPlane: preflight.controlPlane,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: plannedRepoFetchUrl,
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
controlledPublish: {
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-user-service-artifact-publish",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
},
boundary: preflight.boundary,
next: preflight.next,
};
}
export async function runCiPublishBackendCoreDryRunPreflight(
config: UniDeskConfig,
args: string[],
transport: PublishPreflightTransport,
resolvedOptions?: CiPublishBackendCoreOptions,
): Promise<Record<string, unknown>> {
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
if (!args.includes("--dry-run")) throw new Error("publish-backend-core preflight requires --dry-run");
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-backend-core reads source repo from CI.json; edit CI.json instead of using --repo");
}
const artifact = resolveCatalogArtifact("backend-core");
if (artifact.kind !== "source-build") throw new Error("backend-core must be modeled as a source-build artifact in CI.json");
if (artifact.status === "blocked") return blockedArtifactResult(artifact, commit, blockedReason(artifact));
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile");
const options: CiPublishBackendCoreOptions = resolvedOptions ?? {
repoUrl: artifact.source.repo,
commit,
waitMs: numberOption(args, "--wait-ms", 0),
sourceHostPath: backendCoreArtifactSourceHostPath(commit),
dockerfile,
imageRepository: artifact.image.repository,
dryRun: true,
};
const summaryContext: ArtifactSummaryContext = {
serviceId: "backend-core",
commit,
repoUrl: options.repoUrl,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const preflight = await publishBackendCorePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
const blockedScopes = publishPreflightFailedScopes(preflight);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-backend-core-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
targetCommit: options.commit,
sourceRepo: options.repoUrl,
serviceId: "backend-core",
providerId: d601ProviderId,
ciRunner: {
providerId: d601ProviderId,
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
wouldBuildOnD601: true,
realBuildRequiresAuthorization: true,
},
registryTarget: plannedArtifact.repository,
wouldBuildOnD601: true,
dryRunBuildStarted: false,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
missingControlChannels: preflight.missingControlChannels,
controlChannels: preflight.controlChannels,
channels: preflight.channels,
failureClassification: preflight.failureClassification,
failedScopes: blockedScopes,
blockedScopes,
recommendedAction: preflight.recommendedAction,
remoteCommandShape: preflight.remoteCommandShape,
remoteControlPlaneCandidate: preflight.controlPlane,
controlPlane: preflight.controlPlane,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: plannedRepoFetchUrl,
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
commit: options.commit,
serviceId: "backend-core",
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
},
sourceAuth: {
repoFetchUrl: plannedRepoFetchUrl,
egressProxy: providerGatewayWsEgressProxyUrl,
gitSshProxy: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
identityRequired: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
},
d601Ci: {
providerId: d601ProviderId,
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
wouldBuildOnD601: true,
dryRunWillStartTekton: false,
dryRunWillCompileRust: false,
dryRunWillPushRegistry: false,
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
artifactRequirements: backendCoreArtifactRequirements(options, plannedArtifact),
controlledPublish: {
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
command: `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
requiresHumanAuthorization: true,
},
devApplyPath: backendCoreDevApplyPath(options, plannedArtifact),
boundary: preflight.boundary,
next: preflight.next,
};
}