From 3039195c5b3c5ebc3882588295d2c585521786eb Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 05:50:34 +0000 Subject: [PATCH] feat: add gitea actions cicd poc plan --- config/cicd-gitea-actions-poc.yaml | 312 ++++++++++++++++ scripts/src/cicd-gitea-actions-poc.ts | 488 ++++++++++++++++++++++++++ scripts/src/cicd.ts | 21 +- scripts/src/help.ts | 7 +- 4 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 config/cicd-gitea-actions-poc.yaml create mode 100644 scripts/src/cicd-gitea-actions-poc.ts diff --git a/config/cicd-gitea-actions-poc.yaml b/config/cicd-gitea-actions-poc.yaml new file mode 100644 index 00000000..ff8c327b --- /dev/null +++ b/config/cicd-gitea-actions-poc.yaml @@ -0,0 +1,312 @@ +# SPEC: GH-1548 Gitea mirror/Actions visibility and controlled Docker builder POC +apiVersion: unidesk.pikapython.com/v1alpha1 +kind: CicdGiteaActionsPoc +metadata: + id: gitea-actions-builder-poc + owner: UniDesk + issue: https://github.com/pikasTech/unidesk/issues/1548 + specRef: GH-1548 + version: draft-2026-07-05-p0-gitea-actions-builder-poc + +spec: + scope: + phase: p1-spec-p2-poc + productionFollowerReplacement: false + rolloutEnabled: false + rolloutMode: diagnostic-only + preferredTarget: agentrun-jd01-v02 + decisionGate: env-reuse-must-pass-before-replacement + + sourceAuthority: + mode: immutable-snapshot-ref + allowMutableBranchAsCiSource: false + allowHostWorktree: false + existingMirrorRef: config/cicd-branch-followers.yaml#followers.agentrun-jd01-v02.nativeStatus.source.gitMirrorReadUrl + giteaMirror: + enabledForPoc: true + role: internal-github-upstream-mirror + namespace: devops-infra + serviceName: gitea-http + internalBaseUrl: http://gitea-http.devops-infra.svc.cluster.local:3000 + snapshotRefPrefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2 + mirrorLagStatus: required + forceSyncEntry: gitea-ui-or-controlled-cli + repositories: + - key: agentrun + repository: pikasTech/agentrun + upstreamBranch: v0.2 + gitopsBranch: jd01-v0.2-gitops + - key: unidesk + repository: pikasTech/unidesk + upstreamBranch: master + + actions: + enabledForPoc: true + role: visibility-and-event-orchestration + workflowSource: + repository: pikasTech/agentrun + path: .gitea/workflows/unidesk-agentrun-jd01-v02.yaml + runner: + mode: dedicated-act-runner + trustBoundary: internal-only + label: unidesk-ci-builder + credentialsRef: + sourceRef: cicd/gitea-actions-runner.env + targetKey: token + allowRuntimeNamespaceToken: false + trigger: + primary: actions-to-tekton-eventlistener + fallback: actions-to-unidesk-controlled-api + payloadMustInclude: + - sourceCommit + - snapshotRef + - repository + - branch + - reusePlanArtifactRef + logPolicy: + defaultBoundedSummary: true + fullLogRequiresDrillDown: true + secretRedaction: sourceRef-presence-fingerprint-only + + runtimePlane: + dockerAllowed: false + buildAllowed: false + dockerSocketAllowed: false + hostWorktreeAllowed: false + sourceAuthority: immutable-snapshot-ref + deployMode: gitops-argo-pull-built-image + statusAuthority: + - argo-application + - kubernetes-workload-status + - runtime-health + - provenance-artifact + + buildPlane: + dockerAllowed: true + buildAllowed: true + dockerScope: ci-build-plane-only + mode: controlled-docker-or-buildkit-builder + engineCandidates: + - native-docker-daemon + - buildkit + selectedEngineForPoc: buildkit + forbidMasterServer: true + forbidRuntimeNode: true + endpoint: + kind: buildkit + ref: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.buildkitImage + registry: + ref: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.registryPrefix + credentials: + registry: + sourceRef: cicd/registry-builder.env + targetKey: REGISTRY_AUTH + builder: + sourceRef: cicd/buildkit-builder.env + targetKey: BUILDKIT_AUTH + cache: + policyRef: config/cicd-gitea-actions-poc.yaml#spec.buildPlane.cache + mode: yaml-first-buildkit-cache + gcRequired: true + provenance: + required: true + fields: + - sourceCommit + - snapshotRef + - envIdentity + - imageDigest + - recipeHash + - baseImageDigest + - reuseDecision + - builderId + - actionsRunId + - pipelineRunName + + reuse: + p0NoRegression: true + sourceTruth: gitops/reuse.ymal + sourceRead: immutable-snapshot + existingParser: scripts/src/cicd-reuse-config.ts + existingAgentRunPlanner: scripts/src/cicd-agentrun-reuse.ts + ciConsumptionRequired: true + requiredDecisions: + - skipImageBuild + - reuseEnvImage + requiredArtifacts: + - affectedServices + - buildServices + - reusedServices + - skipImageBuild + - reuseEnvImage + - artifactProvenanceAudit + noRegressionChecks: + - runtime-reuse-hit-skips-rollout + - env-unchanged-skips-env-image-build + - ci-builder-consumes-reuse-plan + - runtime-status-links-env-image-provenance + + tekton: + enabledForPoc: true + triggerMode: eventlistener-or-controlled-pipelinerun + eventListener: + namespace: agentrun-ci + name: gitea-actions-agentrun-jd01-v02 + serviceAccountRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.serviceAccountName + pipeline: + namespaceRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.namespace + nameRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipeline + runPrefixRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipelineRunPrefix + sourceParameters: + - sourceCommit + - snapshotRef + - reusePlanArtifactRef + + argo: + enabledForPoc: true + role: runtime-closeout + namespaceRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.gitops.argoNamespace + applicationRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.gitops.argoApplication + statusOnlyFromNativeObjects: true + + budgets: + endToEndSeconds: 120 + sourceSyncSeconds: 20 + actionsDispatchSeconds: 20 + reusePlanSeconds: 20 + buildOrReuseSeconds: 70 + gitopsArgoCloseoutSeconds: 50 + statusSeconds: 35 + + targets: + - id: agentrun-jd01-v02 + enabled: true + repository: pikasTech/agentrun + branch: v0.2 + node: JD01 + lane: jd01-v02 + baseline: + currentBranchFollowerSeconds: 105.7 + budgetRef: config/cicd-gitea-actions-poc.yaml#spec.budgets.endToEndSeconds + source: + branchRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source.branch + snapshotRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.source.sourceSnapshot.stageRefPrefix + currentMirrorReadUrlRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.gitMirror.readUrl + actions: + workflowRef: config/cicd-gitea-actions-poc.yaml#spec.actions.workflowSource + tekton: + pipelineRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipeline + pipelineRunPrefixRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.ci.pipelineRunPrefix + argo: + applicationRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.gitops.argoApplication + runtime: + namespaceRef: config/agentrun.yaml#controlPlane.lanes.jd01-v02.runtime.namespace + workload: Deployment/agentrun-mgr + closeout: + healthPath: /health + requiredEvidence: + - sourceCommit + - snapshotRef + - actionsRunId + - pipelineRunName + - builderJobName + - gitopsRevision + - argoHealth + - runtimeTargetSha + - imageDigest + - envIdentity + - reuseDecision + + stages: + - id: mirror-sync + owner: gitea-mirror + statusAuthority: gitea-repository-and-snapshot-ref + output: immutableSnapshotRef + - id: actions-dispatch + owner: gitea-actions + statusAuthority: actions-run-api + output: actionsRunId + - id: reuse-plan + owner: unidesk-cli + statusAuthority: reuse-plan-artifact + output: affectedServices-buildServices-reusedServices + - id: build-or-reuse + owner: controlled-builder-plane + statusAuthority: provenance-artifact + output: imageDigest-or-reuseDecision + - id: tekton-pipelinerun + owner: tekton + statusAuthority: pipelineRun-taskRun-status + output: pipelineRunName + - id: gitops-publish + owner: unidesk-cli-or-tekton + statusAuthority: gitops-commit + output: gitopsRevision + - id: argo-closeout + owner: argo-cd + statusAuthority: argo-application + output: sync-health + - id: runtime-provenance + owner: unidesk-status + statusAuthority: k8s-native-status-and-health + output: runtimeTargetSha-health-provenance + + componentSurvey: + - component: gitea-mirror + role: internal-source-mirror + maturity: candidate + directReuse: yes-for-visible-pull-mirror + docs: https://docs.gitea.com/usage/repo-mirror + risk: must-map-existing-snapshot-and-flush-semantics + - component: gitea-actions + role: visibility-event-orchestration + maturity: production-capable-candidate + directReuse: yes-as-visibility-layer-not-final-truth + docs: https://docs.gitea.com/usage/actions/overview + risk: github-actions-compatibility-gaps-and-runner-trust-boundary + - component: tekton-triggers + role: actions-to-pipelinerun-bridge + maturity: candidate + directReuse: yes-for-eventlistener-to-pipelinerun + docs: https://tekton.dev/docs/triggers/eventlisteners/ + risk: eventlistener-rbac-and-ingress-exposure-must-be-bounded + - component: argo-cd + role: gitops-runtime-closeout + maturity: existing-production-component + directReuse: keep-existing + docs: https://argo-cd.readthedocs.io/en/stable/core_concepts/ + risk: actions-must-not-parse-human-argo-output + + statusProjection: + mode: bounded-summary + defaultOutputMustNotDump: true + requiredFields: + - target + - sourceCommit + - snapshotRef + - giteaMirrorLag + - actionsRunId + - pipelineRunName + - builderJobName + - buildServices + - reusedServices + - skipImageBuild + - reuseEnvImage + - imageDigest + - gitopsRevision + - argoSync + - argoHealth + - runtimeTargetSha + - health + - elapsedSeconds + drillDown: + actionsRun: gitea-actions-run + tekton: cicd-pipelinerun + builder: cicd-builder-job + argo: argo-application + runtime: cicd-runtime + + next: + plan: bun scripts/cli.ts cicd gitea-actions-poc plan + status: bun scripts/cli.ts cicd gitea-actions-poc status + existingFollowerStatus: bun scripts/cli.ts cicd branch-follower status --follower agentrun-jd01-v02 + pocIssue: https://github.com/pikasTech/unidesk/issues/1548 diff --git a/scripts/src/cicd-gitea-actions-poc.ts b/scripts/src/cicd-gitea-actions-poc.ts new file mode 100644 index 00000000..83af38ce --- /dev/null +++ b/scripts/src/cicd-gitea-actions-poc.ts @@ -0,0 +1,488 @@ +// SPEC: GH-1548 Gitea mirror/Actions visibility and controlled Docker builder POC. +// Responsibility: read-only YAML-first plan/status for the proposed CI/CD governance split. +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { rootPath, type UniDeskConfig } from "./config"; +import { renderMachine } from "./cicd-render"; +import type { RenderedCliResult } from "./output"; + +const DEFAULT_CONFIG_PATH = "config/cicd-gitea-actions-poc.yaml"; + +type OutputMode = "text" | "json" | "yaml"; +type Action = "plan" | "status" | "help"; + +interface Options { + action: Action; + configPath: string; + output: OutputMode; + targetId: string | null; +} + +interface LoadedPoc { + configPath: string; + root: Record; + spec: Record; + errors: string[]; + warnings: string[]; +} + +export function cicdGiteaActionsPocHelp(): unknown { + return { + command: "cicd gitea-actions-poc plan|status", + output: "text by default; use --json, --raw, or -o json|yaml for machine output", + usage: [ + "bun scripts/cli.ts cicd gitea-actions-poc plan", + "bun scripts/cli.ts cicd gitea-actions-poc status", + "bun scripts/cli.ts cicd gitea-actions-poc plan --target agentrun-jd01-v02", + ], + config: DEFAULT_CONFIG_PATH, + issue: "https://github.com/pikasTech/unidesk/issues/1548", + description: "Read-only P1/P2 plan for replacing branch-follower responsibilities with Gitea mirror, Gitea Actions visibility, controlled Docker/BuildKit builder plane, existing Tekton, existing Argo CD, and bounded UniDesk status while preserving env reuse.", + }; +} + +export async function runGiteaActionsPocCommand(_config: UniDeskConfig | null, args: string[], alias = "gitea-actions-poc"): Promise { + const options = parseOptions(args); + const command = `cicd ${alias}${options.action === "help" ? "" : ` ${options.action}`}`; + if (options.action === "help") return renderMachine(command, cicdGiteaActionsPocHelp(), options.output === "yaml" ? "yaml" : "json"); + const loaded = loadPoc(options.configPath); + const payload = options.action === "plan" ? buildPlan(loaded, options) : buildStatus(loaded, options); + if (options.output === "json") return renderMachine(command, payload, "json", payload.ok !== false); + if (options.output === "yaml") return renderMachine(command, payload, "yaml", payload.ok !== false); + return { + ok: payload.ok !== false, + command, + renderedText: options.action === "plan" ? renderPlanHuman(payload) : renderStatusHuman(payload), + contentType: "text/plain", + }; +} + +function parseOptions(args: string[]): Options { + const actionToken = args[0]; + const action: Action = actionToken === undefined || isHelpToken(actionToken) ? "help" : parseAction(actionToken); + const options: Options = { + action, + configPath: DEFAULT_CONFIG_PATH, + output: "text", + targetId: null, + }; + for (let index = action === "help" ? 0 : 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined || isHelpToken(arg)) { + options.action = "help"; + continue; + } + if (arg === "--json" || arg === "--raw") { + options.output = "json"; + continue; + } + if (arg === "-o" || arg === "--output") { + const value = args[index + 1]; + if (value === undefined) throw new Error(`${arg} requires text, json, or yaml`); + options.output = parseOutput(value, arg); + index += 1; + continue; + } + if (arg.startsWith("-o=")) { + options.output = parseOutput(arg.slice(3), "-o"); + continue; + } + if (arg.startsWith("--output=")) { + options.output = parseOutput(arg.slice("--output=".length), "--output"); + continue; + } + if (arg === "--config") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error("--config requires a path"); + options.configPath = value; + index += 1; + continue; + } + if (arg.startsWith("--config=")) { + options.configPath = arg.slice("--config=".length); + continue; + } + if (arg === "--target") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error("--target requires a target id"); + options.targetId = value; + index += 1; + continue; + } + if (arg.startsWith("--target=")) { + options.targetId = arg.slice("--target=".length); + continue; + } + if (arg === "--confirm") throw new Error("cicd gitea-actions-poc is read-only in GH-1548 P1/P2; --confirm is not accepted"); + if (arg === "--dry-run" || arg === "--all") continue; + throw new Error(`unsupported cicd gitea-actions-poc option: ${arg}`); + } + return options; +} + +function parseAction(value: string): Action { + if (value === "plan" || value === "status") return value; + if (isHelpToken(value)) return "help"; + if (value === "apply" || value === "run-once" || value === "trigger-current") { + throw new Error(`cicd gitea-actions-poc ${value} is intentionally unavailable; GH-1548 first stage is read-only plan/status`); + } + throw new Error("cicd gitea-actions-poc usage: cicd gitea-actions-poc plan|status [--target ] [--config ]"); +} + +function parseOutput(value: string, flag: string): OutputMode { + if (value === "text" || value === "json" || value === "yaml") return value; + throw new Error(`${flag} must be text, json, or yaml`); +} + +function isHelpToken(value: string): boolean { + return value === "help" || value === "--help" || value === "-h"; +} + +function loadPoc(configPath: string): LoadedPoc { + const absolutePath = isAbsolute(configPath) ? configPath : rootPath(configPath); + if (!existsSync(absolutePath)) throw new Error(`${configPath} does not exist`); + const root = record(Bun.YAML.parse(readFileSync(absolutePath, "utf8")), configPath); + const spec = record(root.spec, `${configPath}.spec`); + const errors: string[] = []; + const warnings: string[] = []; + validateRoot(root, spec, configPath, errors, warnings); + return { configPath, root, spec, errors, warnings }; +} + +function validateRoot(root: Record, spec: Record, configPath: string, errors: string[], warnings: string[]): void { + if (stringOrNull(root.kind) !== "CicdGiteaActionsPoc") errors.push(`${configPath}.kind must be CicdGiteaActionsPoc`); + const scope = optionalRecord(spec.scope); + if (scope?.productionFollowerReplacement !== false) errors.push(`${configPath}.spec.scope.productionFollowerReplacement must be false for first-stage POC`); + if (scope?.rolloutEnabled !== false) errors.push(`${configPath}.spec.scope.rolloutEnabled must be false for first-stage POC`); + const runtimePlane = optionalRecord(spec.runtimePlane); + if (runtimePlane?.dockerAllowed !== false) errors.push(`${configPath}.spec.runtimePlane.dockerAllowed must be false`); + if (runtimePlane?.buildAllowed !== false) errors.push(`${configPath}.spec.runtimePlane.buildAllowed must be false`); + if (runtimePlane?.dockerSocketAllowed !== false) errors.push(`${configPath}.spec.runtimePlane.dockerSocketAllowed must be false`); + const buildPlane = optionalRecord(spec.buildPlane); + if (buildPlane?.dockerAllowed !== true) errors.push(`${configPath}.spec.buildPlane.dockerAllowed must be true for the controlled builder POC`); + if (buildPlane?.forbidMasterServer !== true) errors.push(`${configPath}.spec.buildPlane.forbidMasterServer must be true`); + if (buildPlane?.forbidRuntimeNode !== true) errors.push(`${configPath}.spec.buildPlane.forbidRuntimeNode must be true`); + const reuse = optionalRecord(spec.reuse); + if (reuse?.p0NoRegression !== true) errors.push(`${configPath}.spec.reuse.p0NoRegression must be true`); + if (reuse?.ciConsumptionRequired !== true) errors.push(`${configPath}.spec.reuse.ciConsumptionRequired must be true`); + const decisions = stringArray(reuse?.requiredDecisions); + if (!decisions.includes("skipImageBuild")) errors.push(`${configPath}.spec.reuse.requiredDecisions must include skipImageBuild`); + if (!decisions.includes("reuseEnvImage")) errors.push(`${configPath}.spec.reuse.requiredDecisions must include reuseEnvImage`); + const targets = arrayRecords(spec.targets); + if (targets.length === 0) errors.push(`${configPath}.spec.targets must declare at least one POC target`); + const enabledTargets = targets.filter((target) => target.enabled !== false); + if (enabledTargets.length === 0) warnings.push(`${configPath}.spec.targets has no enabled target`); + if (arrayRecords(spec.componentSurvey).length === 0) warnings.push(`${configPath}.spec.componentSurvey is empty; component maturity evidence will be invisible`); +} + +function buildPlan(loaded: LoadedPoc, options: Options): Record { + const targets = selectedTargets(loaded, options); + const scope = optionalRecord(loaded.spec.scope); + return { + ok: loaded.errors.length === 0 && targets.errors.length === 0, + action: "plan", + configPath: loaded.configPath, + issue: stringOrNull(optionalRecord(loaded.root.metadata)?.issue), + specRef: stringOrNull(optionalRecord(loaded.root.metadata)?.specRef), + phase: stringOrNull(scope?.phase), + rolloutEnabled: scope?.rolloutEnabled === true, + productionFollowerReplacement: scope?.productionFollowerReplacement === true, + sourceAuthority: compactSourceAuthority(optionalRecord(loaded.spec.sourceAuthority)), + planes: [ + compactRuntimePlane(optionalRecord(loaded.spec.runtimePlane)), + compactBuildPlane(optionalRecord(loaded.spec.buildPlane)), + ], + reuse: compactReuse(optionalRecord(loaded.spec.reuse)), + targets: targets.items.map(compactTarget), + stages: arrayRecords(loaded.spec.stages).map(compactStage), + componentSurvey: arrayRecords(loaded.spec.componentSurvey).map(compactComponent), + statusProjection: compactStatusProjection(optionalRecord(loaded.spec.statusProjection)), + budgets: loaded.spec.budgets ?? null, + errors: [...loaded.errors, ...targets.errors], + warnings: loaded.warnings, + valuesRedacted: true, + next: nextCommands(loaded, targets.items[0]), + }; +} + +function buildStatus(loaded: LoadedPoc, options: Options): Record { + const plan = buildPlan(loaded, options); + const targets = arrayRecords(plan.targets); + return { + ...plan, + action: "status", + statusSource: "config-only", + statusMode: "declared-poc-not-applied", + checks: targets.map((target) => ({ + target: target.id, + source: "declared", + giteaMirror: "not-applied", + actionsRun: "not-applied", + tekton: "existing-component", + builderPlane: "declared-controlled-docker", + argo: "existing-component", + runtimePlane: "zero-docker-required", + reuse: "p0-required-not-yet-proven-in-poc", + })), + }; +} + +function selectedTargets(loaded: LoadedPoc, options: Options): { items: Record[]; errors: string[] } { + const targets = arrayRecords(loaded.spec.targets); + if (options.targetId === null) return { items: targets, errors: [] }; + const selected = targets.filter((target) => stringOrNull(target.id) === options.targetId); + return selected.length > 0 ? { items: selected, errors: [] } : { items: [], errors: [`target ${options.targetId} not found in ${loaded.configPath}`] }; +} + +function compactSourceAuthority(value: Record | null): Record { + const mirror = optionalRecord(value?.giteaMirror); + return { + mode: stringOrNull(value?.mode), + allowMutableBranchAsCiSource: value?.allowMutableBranchAsCiSource === true, + allowHostWorktree: value?.allowHostWorktree === true, + existingMirrorRef: stringOrNull(value?.existingMirrorRef), + giteaMirrorEnabledForPoc: mirror?.enabledForPoc === true, + giteaInternalBaseUrl: stringOrNull(mirror?.internalBaseUrl), + snapshotRefPrefix: stringOrNull(mirror?.snapshotRefPrefix), + repositories: arrayRecords(mirror?.repositories).map((repo) => `${repo.repository ?? "-"}@${repo.upstreamBranch ?? "-"}`), + }; +} + +function compactRuntimePlane(value: Record | null): Record { + return { + plane: "runtime", + dockerAllowed: value?.dockerAllowed === true, + buildAllowed: value?.buildAllowed === true, + dockerSocketAllowed: value?.dockerSocketAllowed === true, + hostWorktreeAllowed: value?.hostWorktreeAllowed === true, + sourceAuthority: stringOrNull(value?.sourceAuthority), + deployMode: stringOrNull(value?.deployMode), + statusAuthority: stringArray(value?.statusAuthority), + }; +} + +function compactBuildPlane(value: Record | null): Record { + return { + plane: "ci-build", + dockerAllowed: value?.dockerAllowed === true, + buildAllowed: value?.buildAllowed === true, + dockerScope: stringOrNull(value?.dockerScope), + mode: stringOrNull(value?.mode), + engineCandidates: stringArray(value?.engineCandidates), + selectedEngineForPoc: stringOrNull(value?.selectedEngineForPoc), + forbidMasterServer: value?.forbidMasterServer === true, + forbidRuntimeNode: value?.forbidRuntimeNode === true, + endpointRef: stringOrNull(optionalRecord(value?.endpoint)?.ref), + registryRef: stringOrNull(optionalRecord(value?.registry)?.ref), + provenanceRequired: optionalRecord(value?.provenance)?.required === true, + provenanceFields: stringArray(optionalRecord(value?.provenance)?.fields), + }; +} + +function compactReuse(value: Record | null): Record { + return { + p0NoRegression: value?.p0NoRegression === true, + sourceTruth: stringOrNull(value?.sourceTruth), + sourceRead: stringOrNull(value?.sourceRead), + existingParser: stringOrNull(value?.existingParser), + existingAgentRunPlanner: stringOrNull(value?.existingAgentRunPlanner), + ciConsumptionRequired: value?.ciConsumptionRequired === true, + requiredDecisions: stringArray(value?.requiredDecisions), + requiredArtifacts: stringArray(value?.requiredArtifacts), + noRegressionChecks: stringArray(value?.noRegressionChecks), + }; +} + +function compactTarget(value: Record): Record { + return { + id: stringOrNull(value.id), + enabled: value.enabled !== false, + repository: stringOrNull(value.repository), + branch: stringOrNull(value.branch), + node: stringOrNull(value.node), + lane: stringOrNull(value.lane), + baselineSeconds: optionalRecord(value.baseline)?.currentBranchFollowerSeconds ?? null, + budgetRef: stringOrNull(optionalRecord(value.baseline)?.budgetRef), + snapshotRef: stringOrNull(optionalRecord(value.source)?.snapshotRef), + mirrorReadUrlRef: stringOrNull(optionalRecord(value.source)?.currentMirrorReadUrlRef), + workflowRef: stringOrNull(optionalRecord(value.actions)?.workflowRef), + pipelineRef: stringOrNull(optionalRecord(value.tekton)?.pipelineRef), + argoApplicationRef: stringOrNull(optionalRecord(value.argo)?.applicationRef), + runtimeNamespaceRef: stringOrNull(optionalRecord(value.runtime)?.namespaceRef), + workload: stringOrNull(optionalRecord(value.runtime)?.workload), + healthPath: stringOrNull(optionalRecord(value.closeout)?.healthPath), + requiredEvidence: stringArray(optionalRecord(value.closeout)?.requiredEvidence), + }; +} + +function compactStage(value: Record): Record { + return { + id: stringOrNull(value.id), + owner: stringOrNull(value.owner), + statusAuthority: stringOrNull(value.statusAuthority), + output: stringOrNull(value.output), + }; +} + +function compactComponent(value: Record): Record { + return { + component: stringOrNull(value.component), + role: stringOrNull(value.role), + maturity: stringOrNull(value.maturity), + directReuse: stringOrNull(value.directReuse), + docs: stringOrNull(value.docs), + risk: stringOrNull(value.risk), + }; +} + +function compactStatusProjection(value: Record | null): Record { + return { + mode: stringOrNull(value?.mode), + defaultOutputMustNotDump: value?.defaultOutputMustNotDump === true, + requiredFields: stringArray(value?.requiredFields), + drillDown: optionalRecord(value?.drillDown) ?? {}, + }; +} + +function nextCommands(loaded: LoadedPoc, firstTarget: Record | undefined): Record { + const declared = optionalRecord(loaded.spec.next); + const targetId = stringOrNull(firstTarget?.id) ?? "agentrun-jd01-v02"; + return { + plan: stringOrNull(declared?.plan) ?? "bun scripts/cli.ts cicd gitea-actions-poc plan", + status: stringOrNull(declared?.status) ?? "bun scripts/cli.ts cicd gitea-actions-poc status", + existingFollowerStatus: stringOrNull(declared?.existingFollowerStatus) ?? `bun scripts/cli.ts cicd branch-follower status --follower ${targetId}`, + pocIssue: stringOrNull(declared?.pocIssue), + }; +} + +function renderPlanHuman(payload: Record): string { + const next = optionalRecord(payload.next); + const planes = arrayRecords(payload.planes); + const reuse = optionalRecord(payload.reuse); + const statusProjection = optionalRecord(payload.statusProjection); + const errors = stringArray(payload.errors); + const warnings = stringArray(payload.warnings); + return [ + `CI/CD GITEA-ACTIONS POC PLAN (${payload.ok === false ? "blocked" : "ok"})`, + "", + table(["TARGET", "SOURCE", "NODE/LANE", "BASELINE", "SNAPSHOT", "TEKTON", "ARGO", "RUNTIME"], arrayRecords(payload.targets).map((target) => [ + target.id, + `${target.repository ?? "-"}@${target.branch ?? "-"}`, + `${target.node ?? "-"}/${target.lane ?? "-"}`, + target.baselineSeconds === null ? "-" : `${target.baselineSeconds}s`, + target.snapshotRef, + target.pipelineRef, + target.argoApplicationRef, + target.workload, + ])), + "", + "SOURCE AUTHORITY", + sourceAuthorityLine(optionalRecord(payload.sourceAuthority)), + "", + table(["PLANE", "DOCKER", "BUILDS", "MODE", "ENGINE", "MASTER", "RUNTIME_NODE", "AUTHORITY"], planes.map((plane) => [ + plane.plane, + boolText(plane.dockerAllowed), + boolText(plane.buildAllowed), + plane.mode ?? plane.deployMode ?? "-", + plane.selectedEngineForPoc ?? "-", + plane.forbidMasterServer === true ? "forbidden" : "-", + plane.forbidRuntimeNode === true ? "forbidden" : "-", + plane.sourceAuthority ?? plane.endpointRef ?? "-", + ])), + "", + "REUSE CONTRACT", + `source=${reuse?.sourceTruth ?? "-"} p0=${boolText(reuse?.p0NoRegression)} ciConsumption=${boolText(reuse?.ciConsumptionRequired)} decisions=${stringArray(reuse?.requiredDecisions).join(",") || "-"}`, + `artifacts=${stringArray(reuse?.requiredArtifacts).join(",") || "-"}`, + "", + table(["STAGE", "OWNER", "STATUS_AUTHORITY", "OUTPUT"], arrayRecords(payload.stages).map((stage) => [stage.id, stage.owner, stage.statusAuthority, stage.output])), + "", + table(["COMPONENT", "ROLE", "MATURITY", "DIRECT_REUSE", "RISK"], arrayRecords(payload.componentSurvey).map((component) => [component.component, component.role, component.maturity, component.directReuse, component.risk])), + "", + "STATUS FIELDS", + stringArray(statusProjection?.requiredFields).join(", ") || "-", + warnings.length === 0 ? "" : `\nWARNINGS\n${warnings.map((item) => `- ${item}`).join("\n")}`, + errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`, + "", + "NEXT", + `status: ${next?.status ?? "-"}`, + `existing-follower: ${next?.existingFollowerStatus ?? "-"}`, + `issue: ${next?.pocIssue ?? payload.issue ?? "-"}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function renderStatusHuman(payload: Record): string { + const next = optionalRecord(payload.next); + const errors = stringArray(payload.errors); + return [ + `CI/CD GITEA-ACTIONS POC STATUS (${payload.ok === false ? "blocked" : "declared-only"})`, + "", + `statusSource=${payload.statusSource ?? "-"} mode=${payload.statusMode ?? "-"}`, + "", + table(["TARGET", "SOURCE", "GITEA", "ACTIONS", "TEKTON", "BUILDER", "ARGO", "RUNTIME", "REUSE"], arrayRecords(payload.checks).map((check) => [ + check.target, + check.source, + check.giteaMirror, + check.actionsRun, + check.tekton, + check.builderPlane, + check.argo, + check.runtimePlane, + check.reuse, + ])), + errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`, + "", + "NEXT", + `plan: ${next?.plan ?? "-"}`, + `existing-follower: ${next?.existingFollowerStatus ?? "-"}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function sourceAuthorityLine(value: Record | null): string { + return [ + `mode=${value?.mode ?? "-"}`, + `mutableBranch=${boolText(value?.allowMutableBranchAsCiSource)}`, + `hostWorktree=${boolText(value?.allowHostWorktree)}`, + `giteaEnabled=${boolText(value?.giteaMirrorEnabledForPoc)}`, + `snapshot=${value?.snapshotRefPrefix ?? "-"}`, + ].join(" "); +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); + return value as Record; +} + +function optionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : []; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function boolText(value: unknown): string { + return value === true ? "true" : value === false ? "false" : "-"; +} + +function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string { + const normalized = rows.map((row) => headers.map((_, index) => cell(row[index]))); + const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0))); + const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd(); + return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n"); +} + +function cell(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + const text = String(value).replace(/\s+/gu, " "); + return text.length > 96 ? `${text.slice(0, 93)}...` : text; +} diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index af033850..e4dc327f 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -1,13 +1,26 @@ -// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. -// Responsibility: thin CI/CD top-level route entry; branch-follower logic lives in responsibility modules. +// SPEC: PJ2026-01060703 CI/CD branch follower and GH-1548 Gitea Actions POC. +// Responsibility: thin CI/CD top-level route entry; subcommand logic lives in responsibility modules. import type { UniDeskConfig } from "./config"; +import { renderMachine } from "./cicd-render"; import type { RenderedCliResult } from "./output"; import { cicdHelp as branchFollowerHelp, runCicdCommand as runBranchFollowerCommand } from "./cicd-branch-follower"; +import { cicdGiteaActionsPocHelp, runGiteaActionsPocCommand } from "./cicd-gitea-actions-poc"; export function cicdHelp(): unknown { - return branchFollowerHelp(); + return { + command: "cicd branch-follower|gitea-actions-poc", + output: "text by default for subcommands; top-level help is json", + subcommands: [ + branchFollowerHelp(), + cicdGiteaActionsPocHelp(), + ], + }; } export async function runCicdCommand(config: UniDeskConfig | null, args: string[]): Promise { - return await runBranchFollowerCommand(config, args); + const top = args[0]; + if (top === undefined || top === "help" || top === "--help" || top === "-h") return renderMachine("cicd", cicdHelp(), "json"); + if (top === "branch-follower") return await runBranchFollowerCommand(config, args); + if (top === "gitea-actions-poc" || top === "gitea-builder-poc") return await runGiteaActionsPocCommand(config, args.slice(1), top); + throw new Error("cicd usage: cicd branch-follower|gitea-actions-poc"); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b9b640a2..6e35a083 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -59,6 +59,7 @@ export function rootHelp(): unknown { { command: "decision show ", description: "Show one Decision Center record." }, { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." }, { command: "cicd branch-follower plan|apply|status|run-once|events|logs", description: "Deploy and inspect the YAML-first Kubernetes branch follower for HWLAB v0.3, AgentRun v0.2, and web-probe sentinel master without using host worktrees as source authority." }, + { command: "cicd gitea-actions-poc plan|status", description: "Inspect the GH-1548 Gitea mirror/Actions visibility and controlled Docker/BuildKit builder-plane POC plan while keeping runtime plane 0 Docker and env reuse as a P0 no-regression contract." }, { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, @@ -734,15 +735,17 @@ function webProbeHelpSummary(): unknown { function cicdHelpSummary(): unknown { return { - command: "cicd branch-follower plan|apply|status|run-once|events|logs", + command: "cicd branch-follower ... | gitea-actions-poc plan|status", output: "text by default; use --json, --raw, or -o json|yaml for machine output", usage: [ "bun scripts/cli.ts cicd branch-follower plan", "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", "bun scripts/cli.ts cicd branch-follower status", "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", + "bun scripts/cli.ts cicd gitea-actions-poc plan", + "bun scripts/cli.ts cicd gitea-actions-poc status", ], - description: "YAML-first Kubernetes branch follower for three CI/CD running planes, with K8s state and adapter drill-down visibility.", + description: "YAML-first Kubernetes branch follower plus the GH-1548 read-only Gitea mirror/Actions and controlled builder-plane POC.", }; }