// 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> { 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> { 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> { 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> { 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> { 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, }; }