diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 61bfe3ef..9e45f49f 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -1,6 +1,6 @@ --- name: unidesk-cicd -description: UniDesk CI/CD 控制面 — `hwlab g14` 和 `agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k3s 部署、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 +description: UniDesk CI/CD 控制面 — `cicd branch-follower`、`hwlab g14`、`hwlab nodes control-plane` 和 `agentrun` 子命令,覆盖自动跟随 branch、PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k8s/k3s 部署、branch follower、自动跟随、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 --- # UniDesk CI/CD @@ -15,6 +15,7 @@ bun scripts/cli.ts hwlab g14 control-plane status --lane v02 bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm --wait bun scripts/cli.ts hwlab g14 git-mirror status --lane v02 bun scripts/cli.ts agentrun control-plane status +bun scripts/cli.ts cicd branch-follower status ``` 按职责读取拆分后的 reference: @@ -24,11 +25,13 @@ bun scripts/cli.ts agentrun control-plane status - HWLAB/AgentRun git-mirror source authority 与 flush: [references/git-mirror.md](references/git-mirror.md)。 - Secret、observability、platform-infra、CI tools image、PipelineRun 清理和 rollout 补记: [references/platform-ops.md](references/platform-ops.md)。 - AgentRun YAML-only lane、v0.1 兼容入口和 AgentRun git-mirror: [references/agentrun.md](references/agentrun.md)。 +- YAML-first K8s branch-follower 自动跟随、状态查询和 no-host-worktree 边界: [references/branch-follower.md](references/branch-follower.md)。 ## P0 边界 - CI/CD、GitOps、rollout、PipelineRun、Argo、git-mirror 和 AgentRun 部署必须走受控 CLI;不要用裸 `kubectl`、`argo`、`tkn`、`curl` 当正式控制入口。 - CI/CD source authority 只能来自 Kubernetes 托管的 git-mirror snapshot:受控命令先同步 GitHub refs 到 k8s git-mirror,再创建/读取不可变 `refs/unidesk/snapshots/.../` stage ref;build/status/publish 只消费该 snapshot,host worktree、本地 `git fetch/pull`、可变 branch ref 或 Pipeline 内直连 GitHub 都不能作为 authoritative source。 +- `cicd branch-follower` 的自动跟随全过程不得读取或挂载 host worktree、target dev dir、`.worktree/*` 或 local git checkout;controller pod 只能用 k8s git-mirror 和 EmptyDir 执行,状态以 K8s ConfigMap/Lease 和 adapter status 为准。 - CI/CD、rollout、publish、image build 和部署链路禁止新引入 Docker 依赖;不得依赖 Docker socket、Docker daemon、host Docker、`docker build`、`docker push` 或等价 Docker-only 路径。 - 正式 CI/CD、publish、image build 和 rollout 必须走 Tekton Task/Pipeline/PipelineRun 承担 CI,并通过 GitOps/Argo 承担部署收敛;普通 Kubernetes Job 只允许用于 bounded helper、source sync、diagnostic、cleanup 或 bootstrap,不得作为正式发布、镜像构建或 rollout 入口。 - 正式 CI/CD 必须提供一键完成入口:同一受控命令应完成 source sync、构建、发布、GitOps/Argo 收敛、runtime provenance 校验和 `/health` 端点验证;不要要求操作者手动串联多个 publish/apply/status 命令才能完成一次交付。 @@ -47,3 +50,4 @@ bun scripts/cli.ts agentrun control-plane status - git-mirror source authority 或 flush:读 [references/git-mirror.md](references/git-mirror.md)。 - Secret、observability、CI tools image、PipelineRun/PV 清理:读 [references/platform-ops.md](references/platform-ops.md)。 - AgentRun v0.1 或 YAML-only lane 部署:读 [references/agentrun.md](references/agentrun.md)。 +- 三运行面 branch follower 自动跟随、`apply/status/run-once/events/logs`:读 [references/branch-follower.md](references/branch-follower.md)。 diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md new file mode 100644 index 00000000..23a4c568 --- /dev/null +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -0,0 +1,51 @@ +# CI/CD Branch Follower + +SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower + +## Entrypoints + +```bash +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 status --live +bun scripts/cli.ts cicd branch-follower run-once --all --dry-run +bun scripts/cli.ts cicd branch-follower run-once --follower --confirm --wait +bun scripts/cli.ts cicd branch-follower events --follower +bun scripts/cli.ts cicd branch-follower logs --follower +``` + +`apply --confirm --wait` is the one-command deploy/update entry for the K8s controller. `status` is the default intermediate-state query; add `--live` only when a fresh adapter status read is needed. + +## Source Authority + +- Follower decisions must not read host source worktrees, target dev directories, `.worktree/*`, local git state, or direct GitHub branch refs. +- Controller pods use EmptyDir and clone UniDesk controller source from the YAML-declared k8s git-mirror read URL, then run the CLI with the mounted registry. +- Runtime source commits, build contexts, publish inputs and closeout status remain owned by each adapter's k8s git-mirror snapshot and runtime objects. +- Dirty, stale, or missing-dependency host worktrees are non-authoritative and must not change observed sha, trigger sha, PipelineRun, GitOps, or status output. + +## YAML Ownership + +`config/cicd-branch-followers.yaml` owns only controller settings and the follower registry: id, adapter, source/target configRefs, command argv, closeout check labels and budgets. + +It must not copy runtime/GitOps/Secret details from owning configs: + +- HWLAB node lanes: `config/hwlab-node-lanes.yaml` +- AgentRun lanes: `config/agentrun.yaml` +- Web sentinel profiles/scenarios/reports/secrets: `config/hwlab-web-probe-sentinel/*.yaml` + +Use configRef summaries in plan/status; do not create a `full.md` or super Markdown index. + +## First Followers + +- `hwlab-jd01-v03`: follows `pikasTech/HWLAB@v0.3`, adapter `hwlab-node-runtime`, trigger `hwlab nodes control-plane trigger-current --node JD01 --lane v03 --confirm --wait`. +- `agentrun-d601-v02`: follows `pikasTech/agentrun@v0.2`, adapter `agentrun-yaml-lane`, trigger `agentrun control-plane trigger-current --node D601 --lane v02 --confirm --wait`. +- `web-probe-sentinel-master`: follows `pikasTech/unidesk@master`, adapter `web-probe-sentinel-cicd`, trigger `web-probe sentinel publish-current --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel --confirm --wait`. + +## Status Contract + +Default `status` output must show follower id, phase, adapter, source branch + observed sha, target sha, last triggered sha, last succeeded sha, in-flight job/PipelineRun, budget source and next drill-down commands. + +State machine phases are `Observed`, `Noop`, `PendingTrigger`, `Triggering`, `ClosingOut`, `Succeeded`, `Failed`, `Superseded`, `Blocked`, and `Skipped`. + +`run-once --dry-run` is read-only: it may query K8s state and adapter status, but it must not write the state ConfigMap or trigger adapters. diff --git a/config/cicd-branch-followers.yaml b/config/cicd-branch-followers.yaml new file mode 100644 index 00000000..725bf3fa --- /dev/null +++ b/config/cicd-branch-followers.yaml @@ -0,0 +1,180 @@ +# SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower +apiVersion: unidesk.pikapython.com/v1alpha1 +kind: CicdBranchFollowerRegistry +metadata: + id: unidesk-cicd-branch-followers + owner: UniDesk + specRef: PJ2026-01060703 + version: draft-2026-07-03-p0-branch-follower + +controller: + namespace: devops-infra + kubeRoute: D601:k3s + fieldManager: unidesk-cicd-branch-follower + serviceAccountName: unidesk-cicd-branch-follower + deploymentName: unidesk-cicd-branch-follower + configMapName: unidesk-cicd-branch-follower-config + stateConfigMapName: unidesk-cicd-branch-follower-state + leaseName: unidesk-cicd-branch-follower + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + labels: + app.kubernetes.io/name: unidesk-cicd-branch-follower + app.kubernetes.io/component: cicd-control-plane + app.kubernetes.io/part-of: unidesk + source: + repository: pikasTech/unidesk + branch: master + gitMirrorReadUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + sourceAuthority: + mode: gitMirrorSnapshot + resolver: k8s-git-mirror + allowHostGit: false + allowHostWorkspace: false + allowGithubDirectInPipeline: false + sourceSnapshot: + stageRefPrefix: refs/unidesk/snapshots/unidesk-controller/{branch} + missingObjectPolicy: fail-fast + refreshPolicy: sync-before-snapshot + loop: + intervalSeconds: 30 + reconcileTimeoutSeconds: 110 + budgets: + applyWaitSeconds: 120 + statusSeconds: 35 + runOnceSeconds: 120 + +followers: + - id: hwlab-jd01-v03 + enabled: true + adapter: hwlab-node-runtime + description: Follow HWLAB v0.3 into the JD01 HWLAB node runtime. + source: + repository: pikasTech/HWLAB + branch: v0.3 + branchRef: config/hwlab-node-lanes.yaml#lanes.v03.sourceBranch + authorityRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.git.readUrl + snapshotPrefix: refs/unidesk/snapshots/hwlab-node-runtime/v0.3 + snapshotRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.git.readUrl + target: + node: JD01 + lane: v03 + namespace: hwlab-v03 + configRefs: + lane: config/hwlab-node-lanes.yaml#lanes.v03 + target: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01 + pipeline: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.pipeline + pipelineRunPrefix: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.pipelineRunPrefix + runtimeNamespace: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.runtime.namespace + budgets: + endToEndSeconds: 120 + statusSeconds: 35 + triggerSeconds: 120 + sourceSyncSeconds: 20 + commands: + plan: + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "trigger-current", "--node", "JD01", "--lane", "v03", "--dry-run", "--raw"] + timeoutSeconds: 35 + status: + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "status", "--node", "JD01", "--lane", "v03", "--raw"] + timeoutSeconds: 35 + trigger: + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "trigger-current", "--node", "JD01", "--lane", "v03", "--confirm", "--wait", "--raw"] + timeoutSeconds: 120 + events: + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "status", "--node", "JD01", "--lane", "v03", "--full"] + timeoutSeconds: 35 + logs: + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "status", "--node", "JD01", "--lane", "v03", "--full"] + timeoutSeconds: 35 + closeout: + checks: ["sourceSnapshot", "pipelineRun", "gitMirrorPostFlush", "gitops", "argo", "runtime", "publicHealth"] + + - id: agentrun-d601-v02 + enabled: true + adapter: agentrun-yaml-lane + description: Follow AgentRun v0.2 into the D601 YAML-only runtime lane. + source: + repository: pikasTech/agentrun + branch: v0.2 + branchRef: config/agentrun.yaml#controlPlane.lanes.v02.source.branch + authorityRef: config/agentrun.yaml#controlPlane.lanes.v02.source.sourceAuthority + snapshotPrefix: refs/unidesk/snapshots/agentrun-yaml-lane/v0.2 + snapshotRef: config/agentrun.yaml#controlPlane.lanes.v02.source.sourceSnapshot.stageRefPrefix + target: + node: D601 + lane: v02 + namespace: agentrun-v02 + configRefs: + lane: config/agentrun.yaml#controlPlane.lanes.v02 + source: config/agentrun.yaml#controlPlane.lanes.v02.source + runtime: config/agentrun.yaml#controlPlane.lanes.v02.runtime + pipeline: config/agentrun.yaml#controlPlane.lanes.v02.ci.pipeline + pipelineRunPrefix: config/agentrun.yaml#controlPlane.lanes.v02.ci.pipelineRunPrefix + argoApplication: config/agentrun.yaml#controlPlane.lanes.v02.gitops.argoApplication + budgets: + endToEndSeconds: 120 + statusSeconds: 35 + triggerSeconds: 120 + sourceSyncSeconds: 20 + commands: + plan: + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "D601", "--lane", "v02", "--dry-run", "--raw"] + timeoutSeconds: 35 + status: + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--raw"] + timeoutSeconds: 35 + trigger: + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", "D601", "--lane", "v02", "--confirm", "--wait", "--raw"] + timeoutSeconds: 120 + events: + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--full"] + timeoutSeconds: 35 + logs: + argv: ["bun", "scripts/cli.ts", "agentrun", "control-plane", "status", "--node", "D601", "--lane", "v02", "--full"] + timeoutSeconds: 35 + closeout: + checks: ["sourceSnapshot", "pipelineRun", "gitops", "argo", "manager", "runtimeHealth"] + + - id: web-probe-sentinel-master + enabled: true + adapter: web-probe-sentinel-cicd + description: Follow UniDesk master into the selected HWLAB web-probe sentinel runtime. + source: + repository: pikasTech/unidesk + branch: master + branchRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.JD01.sentinels.jd01-web-probe-sentinel.cicd.source.branch + authorityRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.JD01.sentinels.jd01-web-probe-sentinel.cicd.sourceAuthority + snapshotPrefix: refs/unidesk/snapshots/web-probe-sentinel/master + snapshotRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.JD01.sentinels.jd01-web-probe-sentinel.cicd.sourceSnapshot.stageRefPrefix + target: + node: JD01 + lane: v03 + namespace: hwlab-v03 + sentinel: jd01-web-probe-sentinel + configRefs: + sentinel: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.JD01.sentinels.jd01-web-probe-sentinel.sentinel + cicd: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.JD01.sentinels.jd01-web-probe-sentinel.cicd + monitorRoot: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.observability.webProbe.monitorRoot + budgets: + endToEndSeconds: 120 + statusSeconds: 35 + triggerSeconds: 120 + sourceSyncSeconds: 20 + commands: + plan: + argv: ["bun", "scripts/cli.ts", "web-probe", "sentinel", "publish-current", "--node", "JD01", "--lane", "v03", "--sentinel", "jd01-web-probe-sentinel", "--dry-run", "--raw"] + timeoutSeconds: 35 + status: + argv: ["bun", "scripts/cli.ts", "web-probe", "sentinel", "control-plane", "status", "--node", "JD01", "--lane", "v03", "--sentinel", "jd01-web-probe-sentinel", "--raw"] + timeoutSeconds: 35 + trigger: + argv: ["bun", "scripts/cli.ts", "web-probe", "sentinel", "publish-current", "--node", "JD01", "--lane", "v03", "--sentinel", "jd01-web-probe-sentinel", "--confirm", "--wait", "--raw"] + timeoutSeconds: 120 + events: + argv: ["bun", "scripts/cli.ts", "web-probe", "sentinel", "control-plane", "status", "--node", "JD01", "--lane", "v03", "--sentinel", "jd01-web-probe-sentinel", "--full"] + timeoutSeconds: 35 + logs: + argv: ["bun", "scripts/cli.ts", "web-probe", "sentinel", "report", "--node", "JD01", "--lane", "v03", "--sentinel", "jd01-web-probe-sentinel", "--latest", "--view", "summary", "--raw"] + timeoutSeconds: 35 + closeout: + checks: ["sourceMirror", "imageRegistry", "gitops", "argo", "runtimeHealthEndpoint", "dashboard", "report"] diff --git a/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md b/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md new file mode 100644 index 00000000..aa2004a4 --- /dev/null +++ b/project-management/PJ2026-01/specs/PJ2026-01060703-cicd-branch-follower.md @@ -0,0 +1,112 @@ +# PJ2026-01060703 CI/CD Branch Follower + +SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower + +## 1. 目标 + +为 UniDesk 建立一个 YAML-first、Kubernetes 原生的 CI/CD branch follower 组件,自动跟随三个首批运行面的上游 branch,并通过单一 CLI 完成部署、触发和中间状态查询。 + +首批 follower: + +- `hwlab-jd01-v03`: 跟随 `pikasTech/HWLAB@v0.3`,触发 `hwlab-node-runtime` 控制面。 +- `agentrun-d601-v02`: 跟随 `pikasTech/agentrun@v0.2`,触发 `agentrun-yaml-lane` 控制面。 +- `web-probe-sentinel-master`: 跟随 `pikasTech/unidesk@master`,触发 YAML 选中的 web-probe sentinel CI/CD。 + +## 2. 强约束 + +- branch observation、source snapshot、build context、publish、closeout 的权威来源只能是 Kubernetes 管理的 git-mirror immutable snapshot 和运行面对象。 +- controller pod、Job、Pipeline、publish 流程不得挂载或读取 host source workspace、开发 worktree、`.worktree/*`、local git checkout 或 direct GitHub fallback。 +- host worktree 只能作为人工 debug 或 post-deploy 验证入口,不能影响 follower 的 sha 决策、PipelineRun、GitOps 或状态判断。 +- 所有可调项进入 YAML;代码只解析 configRef、selector、adapter id、预算和命令,不写隐藏默认。 +- registry 不承载运行面/GitOps/Secret 细节;HWLAB、AgentRun、web-probe sentinel 细节仍归属各自 YAML。 +- 默认 CLI 输出必须是简洁表格和 drill-down 命令;完整 JSON/YAML 只通过 `--json`、`--raw` 或 `-o json|yaml` 输出。 +- 端到端 closeout 预算以 YAML 声明,首批 follower 目标为 120s 内收敛;超预算必须可见,不做无限等待。 + +## 3. YAML 职责边界 + +`config/cicd-branch-followers.yaml` 只声明: + +- controller 的 namespace、kube route、ServiceAccount、Lease、ConfigMap、镜像和循环预算。 +- follower registry:id、enabled、adapter、source ref、target ref、status/trigger/event/log drill-down 命令、closeout check 名称和预算。 +- 对拥有配置的引用:`config/hwlab-node-lanes.yaml`、`config/agentrun.yaml`、`config/hwlab-web-probe-sentinel/*.yaml`。 + +它不声明: + +- HWLAB runtime workload、GitOps path、Secret sourceRef、registry image rewrite 细节。 +- AgentRun manager/runtime/secret/GitOps 细节。 +- Web sentinel scenario/prompt/report/runtime/publicExposure/secret 细节。 + +## 4. CLI + +入口: + +```bash +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 branch-follower events --follower hwlab-jd01-v03 +bun scripts/cli.ts cicd branch-follower logs --follower hwlab-jd01-v03 +``` + +默认视图显示: + +- follower id、adapter、source branch、observed sha、target sha、last triggered sha、last succeeded sha。 +- phase:`Observed`、`Noop`、`PendingTrigger`、`Triggering`、`ClosingOut`、`Succeeded`、`Failed`、`Superseded`。 +- in-flight job、budget source、controller/state age、next drill-down。 + +## 5. Controller + +`apply` 渲染并下发: + +- Namespace。 +- ServiceAccount。 +- Role/RoleBinding。 +- ConfigMap:registry YAML。 +- ConfigMap:follower state。 +- Lease:leader election anchor。 +- Deployment:controller loop。 + +Deployment 使用 EmptyDir 作为工作目录,只从 Kubernetes git-mirror clone UniDesk controller source,并把 mounted registry 覆盖到 cloned repo 的 `config/cicd-branch-followers.yaml` 后执行: + +```bash +bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --controller +``` + +该路径不挂载 host source,不读取 host worktree,不依赖 target dev directory。 + +## 6. Adapter 合同 + +每个 adapter 复用既有受控 CLI: + +- `hwlab-node-runtime`: `hwlab nodes control-plane status|trigger-current --node JD01 --lane v03` +- `agentrun-yaml-lane`: `agentrun control-plane status|trigger-current --node D601 --lane v02` +- `web-probe-sentinel-cicd`: `web-probe sentinel publish-current|control-plane status --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel` + +branch follower 不直接操作 Tekton、Argo、kubectl 或 GitHub。它只通过 adapter 命令读取 compact status 或触发已存在的控制面。 + +## 7. 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Observed + Observed --> Noop: observed == target + Observed --> PendingTrigger: observed != target + PendingTrigger --> Triggering + Triggering --> ClosingOut + ClosingOut --> Succeeded + ClosingOut --> Failed + Triggering --> Superseded: branch advanced + Failed --> PendingTrigger: retryable + Succeeded --> Observed: next loop +``` + +## 8. 验收 + +- `bun scripts/cli.ts cicd branch-follower apply --confirm --wait` 一条 CLI 可完成 controller 更新并等待 K8s 对象 ready。 +- `bun scripts/cli.ts cicd branch-follower status` 可查询 controller、state ConfigMap 和三个 follower 的中间状态。 +- `bun scripts/cli.ts cicd branch-follower run-once --all --dry-run` 对三个首批 follower 给出 Noop/PendingTrigger/Blocked 决策,不发生写操作。 +- 任一 source branch 新 sha 只触发一次;重复 sha 不重复触发;branch advance 时旧 in-flight sha 标记为 Superseded。 +- follower 决策和状态不读取 host worktree;故意弄脏/滞后/缺依赖的目标 dev dir 不影响 observed sha、trigger sha 和 closeout 状态。 +- Secret/API key 只显示对象名、key 名、presence、fingerprint 或 sourceRef,不打印值。 +- 不新增 `full.md` 或超级 Markdown;长期入口在 skill `SKILL.md` 引用拆分后的职责文档。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 7611e9d0..5fb4476c 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -1,4 +1,5 @@ // SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction. +// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. // UniDesk CLI dispatcher with bounded server lifecycle and job drill-down output. import { readConfig } from "./src/config"; import { debugDispatch, debugEgressProxy, debugHealth, debugSshPool, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug"; @@ -267,6 +268,20 @@ async function main(): Promise { return; } + if (top === "cicd") { + const { runCicdCommand } = await import("./src/cicd"); + const result = await runCicdCommand(null, args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + if (isRenderedCliResult(result)) { + emitText(result.renderedText, result.command || commandName); + if (!ok) process.exitCode = 1; + return; + } + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + if (top === "agentrun") { const { runAgentRunCommand } = await import("./src/agentrun"); const agentRunArgs = args.slice(1); diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts new file mode 100644 index 00000000..8c1cb151 --- /dev/null +++ b/scripts/src/cicd.ts @@ -0,0 +1,1586 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. +// Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration. +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { repoRoot, rootPath, type UniDeskConfig } from "./config"; +import { runCommand, type CommandResult } from "./command"; +import { startJob } from "./jobs"; +import type { RenderedCliResult } from "./output"; +import { transPath } from "./hwlab-node/runtime-common"; +import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; +import { + arrayField, + asRecord, + booleanField, + integerField, + readYamlRecord, + recordField, + redactText, + shQuote, + stringArrayField, + stringField, +} from "./platform-infra-ops-library"; + +const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml"; +const SPEC_REF = "PJ2026-01060703"; +const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower"; + +type OutputMode = "human" | "json" | "yaml"; +type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "events" | "logs"; +type BranchFollowerPhase = + | "Observed" + | "Noop" + | "PendingTrigger" + | "Triggering" + | "ClosingOut" + | "Succeeded" + | "Failed" + | "Superseded" + | "Blocked" + | "Skipped"; + +interface ParsedOptions { + action: BranchFollowerAction; + configPath: string; + followerId: string | null; + all: boolean; + confirm: boolean; + dryRun: boolean; + wait: boolean; + controller: boolean; + live: boolean; + noLive: boolean; + full: boolean; + raw: boolean; + output: OutputMode; + limit: number; + tailBytes: number; + timeoutSeconds: number | null; +} + +interface CommandSpec { + argv: string[]; + timeoutSeconds: number; +} + +interface FollowerSpec { + id: string; + enabled: boolean; + adapter: string; + description: string; + source: { + repository: string; + branch: string; + branchRef: string; + authorityRef: string; + snapshotPrefix: string; + snapshotRef: string; + }; + target: { + node: string; + lane: string; + namespace: string; + sentinel: string | null; + configRefs: Record; + }; + budgets: { + endToEndSeconds: number; + statusSeconds: number; + triggerSeconds: number; + sourceSyncSeconds: number; + }; + commands: { + plan: CommandSpec; + status: CommandSpec; + trigger: CommandSpec; + events: CommandSpec; + logs: CommandSpec; + }; + closeoutChecks: string[]; +} + +interface ControllerSpec { + namespace: string; + kubeRoute: string; + fieldManager: string; + serviceAccountName: string; + deploymentName: string; + configMapName: string; + stateConfigMapName: string; + leaseName: string; + image: string; + labels: Record; + source: { + repository: string; + branch: string; + gitMirrorReadUrl: string; + sourceAuthority: { + mode: string; + resolver: string; + allowHostGit: boolean; + allowHostWorkspace: boolean; + allowGithubDirectInPipeline: boolean; + }; + sourceSnapshot: { + stageRefPrefix: string; + missingObjectPolicy: string; + refreshPolicy: string; + }; + }; + loop: { + intervalSeconds: number; + reconcileTimeoutSeconds: number; + }; + budgets: { + applyWaitSeconds: number; + statusSeconds: number; + runOnceSeconds: number; + }; +} + +interface BranchFollowerRegistry { + path: string; + rawText: string; + rawSha256: string; + metadata: { + id: string; + owner: string; + specRef: string; + version: string; + }; + controller: ControllerSpec; + followers: FollowerSpec[]; +} + +interface AdapterSummary { + ok: boolean; + command: string; + exitCode: number | null; + timedOut: boolean; + observedSha: string | null; + targetSha: string | null; + lastTriggeredSha: string | null; + lastSucceededSha: string | null; + pipelineRun: string | null; + inFlightJob: string | null; + aligned: boolean | null; + phase: BranchFollowerPhase; + message: string; + payload: Record | null; + stderrTail: string; + stdoutTail: string; +} + +interface FollowerState { + id: string; + adapter: string; + enabled: boolean; + phase: BranchFollowerPhase; + source: { + repository: string; + branch: string; + branchRef: string; + snapshotPrefix: string; + observedSha: string | null; + }; + target: { + node: string; + lane: string; + namespace: string; + sentinel: string | null; + targetSha: string | null; + }; + lastTriggeredSha: string | null; + lastSucceededSha: string | null; + pipelineRun: string | null; + inFlightJob: string | null; + budgetSource: Record; + controller: { + mode: "local-cli" | "k8s-controller"; + stateConfigMap: string; + leaseName: string; + }; + decision: string; + dryRun: boolean; + updatedAt: string; + warnings: string[]; + next: Record; + command?: Record; +} + +interface K8sStateRead { + ok: boolean; + stateByFollower: Record>; + stateConfigMapPresent: boolean; + deployment: Record | null; + lease: Record | null; + pods: Record | null; + errors: string[]; +} + +export function cicdHelp(): unknown { + return { + command: "cicd branch-follower plan|apply|status|run-once|events|logs", + 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 status --live", + "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", + "bun scripts/cli.ts cicd branch-follower run-once --follower hwlab-jd01-v03 --confirm --wait", + "bun scripts/cli.ts cicd branch-follower events --follower agentrun-d601-v02", + "bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master", + ], + config: DEFAULT_CONFIG_PATH, + spec: `${SPEC_REF} ${SPEC_VERSION}`, + description: "Deploy and inspect the YAML-first Kubernetes branch follower that follows HWLAB v0.3, AgentRun v0.2, and the selected web-probe sentinel master lane without using host worktrees as source authority.", + }; +} + +export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise { + const top = args[0]; + if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json"); + if (top !== "branch-follower") { + throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|events|logs"); + } + const options = parseOptions(args.slice(1)); + const command = commandLabel(options); + if (options.action === "help") return renderMachine(command, cicdHelp(), "json"); + const registry = readRegistry(options.configPath); + switch (options.action) { + case "plan": + return renderResult(command, buildPlan(registry, options), options); + case "apply": + return renderResult(command, await applyController(registry, options), options); + case "status": + return renderResult(command, await buildStatus(registry, options), options); + case "run-once": + return renderResult(command, await runOnce(registry, options), options); + case "events": + case "logs": + return renderResult(command, runFollowerDrillDown(registry, options), options); + case "help": + return renderMachine(command, cicdHelp(), "json"); + } +} + +function parseOptions(args: string[]): ParsedOptions { + const actionToken = args[0]; + if (actionToken === undefined || isHelpToken(actionToken)) { + return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1)); + } + if (!["plan", "apply", "status", "run-once", "events", "logs"].includes(actionToken)) { + throw new Error(`cicd branch-follower unknown action: ${actionToken}`); + } + const action = actionToken as BranchFollowerAction; + const options = defaultOptions(action, []); + const rest = args.slice(1); + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index] ?? ""; + if (isHelpToken(arg)) { + options.action = "help"; + } else if (arg === "--config") { + options.configPath = valueOption(rest, ++index, arg); + } else if (arg === "--follower" || arg === "--target") { + options.followerId = simpleId(valueOption(rest, ++index, arg), arg); + } else if (arg === "--all") { + options.all = true; + } else if (arg === "--confirm") { + options.confirm = true; + } else if (arg === "--dry-run") { + options.dryRun = true; + } else if (arg === "--wait") { + options.wait = true; + } else if (arg === "--controller") { + options.controller = true; + } else if (arg === "--live") { + options.live = true; + } else if (arg === "--no-live") { + options.noLive = true; + } else if (arg === "--full") { + options.full = true; + } else if (arg === "--raw" || arg === "--json") { + options.raw = true; + options.full = true; + options.output = "json"; + } else if (arg === "-o" || arg === "--output") { + const value = valueOption(rest, ++index, arg); + if (value !== "json" && value !== "yaml" && value !== "wide" && value !== "text") throw new Error(`${arg} must be json, yaml, wide, or text`); + options.output = value === "wide" || value === "text" ? "human" : value; + if (value === "json" || value === "yaml") { + options.raw = true; + options.full = true; + } + } else if (arg === "--limit") { + options.limit = positiveInt(valueOption(rest, ++index, arg), arg); + } else if (arg === "--tail-bytes" || arg === "--tail") { + options.tailBytes = positiveInt(valueOption(rest, ++index, arg), arg); + } else if (arg === "--timeout-seconds") { + options.timeoutSeconds = positiveInt(valueOption(rest, ++index, arg), arg); + } else { + throw new Error(`unsupported cicd branch-follower option: ${arg}`); + } + } + if (options.confirm && options.dryRun) throw new Error("cicd branch-follower accepts only one of --confirm or --dry-run"); + if (options.action === "apply" && !options.confirm) options.dryRun = true; + if (options.action === "run-once" && !options.confirm) options.dryRun = true; + if (options.action === "run-once" && options.confirm && !options.all && options.followerId === null) { + throw new Error("run-once --confirm requires --all or --follower "); + } + return options; +} + +function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions { + return { + action, + configPath: DEFAULT_CONFIG_PATH, + followerId: null, + all: false, + confirm: false, + dryRun: false, + wait: false, + controller: false, + live: false, + noLive: false, + full: false, + raw: false, + output: "human", + limit: 20, + tailBytes: 12000, + timeoutSeconds: null, + }; +} + +function valueOption(args: string[], index: number, option: string): string { + const value = args[index]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`); + return value; +} + +function simpleId(value: string, option: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${option} must be a simple id`); + return value; +} + +function positiveInt(value: string, option: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); + return parsed; +} + +function isHelpToken(value: string): boolean { + return value === "-h" || value === "--help" || value === "help"; +} + +function readRegistry(configPath: string): BranchFollowerRegistry { + const absolute = isAbsolute(configPath) ? configPath : rootPath(configPath); + const rawText = readFileSync(absolute, "utf8"); + const root = readYamlRecord>(absolute, "CicdBranchFollowerRegistry"); + const metadata = recordField(root, "metadata", configPath); + const controller = parseController(recordField(root, "controller", configPath)); + const followers = arrayField(root, "followers", configPath).map(parseFollower); + const ids = new Set(); + for (const follower of followers) { + if (ids.has(follower.id)) throw new Error(`${configPath}.followers has duplicate id ${follower.id}`); + ids.add(follower.id); + } + return { + path: configPath, + rawText, + rawSha256: createHash("sha256").update(rawText).digest("hex"), + metadata: { + id: stringField(metadata, "id", `${configPath}.metadata`), + owner: stringField(metadata, "owner", `${configPath}.metadata`), + specRef: stringField(metadata, "specRef", `${configPath}.metadata`), + version: stringField(metadata, "version", `${configPath}.metadata`), + }, + controller, + followers, + }; +} + +function parseController(root: Record): ControllerSpec { + const source = recordField(root, "source", "controller"); + const authority = recordField(source, "sourceAuthority", "controller.source"); + const snapshot = recordField(source, "sourceSnapshot", "controller.source"); + const loop = recordField(root, "loop", "controller"); + const budgets = recordField(root, "budgets", "controller"); + const result: ControllerSpec = { + namespace: stringField(root, "namespace", "controller"), + kubeRoute: stringField(root, "kubeRoute", "controller"), + fieldManager: stringField(root, "fieldManager", "controller"), + serviceAccountName: stringField(root, "serviceAccountName", "controller"), + deploymentName: stringField(root, "deploymentName", "controller"), + configMapName: stringField(root, "configMapName", "controller"), + stateConfigMapName: stringField(root, "stateConfigMapName", "controller"), + leaseName: stringField(root, "leaseName", "controller"), + image: stringField(root, "image", "controller"), + labels: stringMap(recordField(root, "labels", "controller"), "controller.labels"), + source: { + repository: stringField(source, "repository", "controller.source"), + branch: stringField(source, "branch", "controller.source"), + gitMirrorReadUrl: stringField(source, "gitMirrorReadUrl", "controller.source"), + sourceAuthority: { + mode: stringField(authority, "mode", "controller.source.sourceAuthority"), + resolver: stringField(authority, "resolver", "controller.source.sourceAuthority"), + allowHostGit: booleanField(authority, "allowHostGit", "controller.source.sourceAuthority"), + allowHostWorkspace: booleanField(authority, "allowHostWorkspace", "controller.source.sourceAuthority"), + allowGithubDirectInPipeline: booleanField(authority, "allowGithubDirectInPipeline", "controller.source.sourceAuthority"), + }, + sourceSnapshot: { + stageRefPrefix: stringField(snapshot, "stageRefPrefix", "controller.source.sourceSnapshot"), + missingObjectPolicy: stringField(snapshot, "missingObjectPolicy", "controller.source.sourceSnapshot"), + refreshPolicy: stringField(snapshot, "refreshPolicy", "controller.source.sourceSnapshot"), + }, + }, + loop: { + intervalSeconds: integerField(loop, "intervalSeconds", "controller.loop"), + reconcileTimeoutSeconds: integerField(loop, "reconcileTimeoutSeconds", "controller.loop"), + }, + budgets: { + applyWaitSeconds: integerField(budgets, "applyWaitSeconds", "controller.budgets"), + statusSeconds: integerField(budgets, "statusSeconds", "controller.budgets"), + runOnceSeconds: integerField(budgets, "runOnceSeconds", "controller.budgets"), + }, + }; + if (result.source.sourceAuthority.allowHostGit || result.source.sourceAuthority.allowHostWorkspace || result.source.sourceAuthority.allowGithubDirectInPipeline) { + throw new Error("controller.source.sourceAuthority must disable host git, host workspace, and direct GitHub pipeline fallback"); + } + return result; +} + +function parseFollower(root: Record, index: number): FollowerSpec { + const label = `followers[${index}]`; + const source = recordField(root, "source", label); + const target = recordField(root, "target", label); + const budgets = recordField(root, "budgets", label); + const commands = recordField(root, "commands", label); + const closeout = recordField(root, "closeout", label); + const configRefs = stringMap(recordField(target, "configRefs", `${label}.target`), `${label}.target.configRefs`); + return { + id: simpleId(stringField(root, "id", label), `${label}.id`), + enabled: booleanField(root, "enabled", label), + adapter: stringField(root, "adapter", label), + description: stringField(root, "description", label), + source: { + repository: stringField(source, "repository", `${label}.source`), + branch: stringField(source, "branch", `${label}.source`), + branchRef: stringField(source, "branchRef", `${label}.source`), + authorityRef: stringField(source, "authorityRef", `${label}.source`), + snapshotPrefix: stringField(source, "snapshotPrefix", `${label}.source`), + snapshotRef: stringField(source, "snapshotRef", `${label}.source`), + }, + target: { + node: stringField(target, "node", `${label}.target`), + lane: stringField(target, "lane", `${label}.target`), + namespace: stringField(target, "namespace", `${label}.target`), + sentinel: typeof target.sentinel === "string" && target.sentinel.length > 0 ? target.sentinel : null, + configRefs, + }, + budgets: { + endToEndSeconds: integerField(budgets, "endToEndSeconds", `${label}.budgets`), + statusSeconds: integerField(budgets, "statusSeconds", `${label}.budgets`), + triggerSeconds: integerField(budgets, "triggerSeconds", `${label}.budgets`), + sourceSyncSeconds: integerField(budgets, "sourceSyncSeconds", `${label}.budgets`), + }, + commands: { + plan: parseCommand(recordField(commands, "plan", `${label}.commands`), `${label}.commands.plan`), + status: parseCommand(recordField(commands, "status", `${label}.commands`), `${label}.commands.status`), + trigger: parseCommand(recordField(commands, "trigger", `${label}.commands`), `${label}.commands.trigger`), + events: parseCommand(recordField(commands, "events", `${label}.commands`), `${label}.commands.events`), + logs: parseCommand(recordField(commands, "logs", `${label}.commands`), `${label}.commands.logs`), + }, + closeoutChecks: stringArrayField(closeout, "checks", `${label}.closeout`), + }; +} + +function parseCommand(root: Record, label: string): CommandSpec { + return { + argv: stringArrayField(root, "argv", label), + timeoutSeconds: integerField(root, "timeoutSeconds", label), + }; +} + +function stringMap(root: Record, label: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(root)) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label}.${key} must be a non-empty string`); + result[key] = value; + } + return result; +} + +function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record { + const selected = selectFollowers(registry, options, { includeDisabled: true }); + const followers = selected.map((follower) => { + const branchValue = safeResolveString(follower.source.branchRef); + const graph = configRefGraph([ + { id: "source.branch", ref: follower.source.branchRef }, + { id: "source.authority", ref: follower.source.authorityRef }, + { id: "source.snapshot", ref: follower.source.snapshotRef }, + ...Object.entries(follower.target.configRefs).map(([id, ref]) => ({ id: `target.${id}`, ref })), + ]); + const warnings: string[] = []; + if (branchValue !== null && branchValue !== follower.source.branch) warnings.push(`source.branch ${follower.source.branch} differs from ${follower.source.branchRef} value ${branchValue}`); + return { + id: follower.id, + enabled: follower.enabled, + adapter: follower.adapter, + description: follower.description, + source: { + repository: follower.source.repository, + branch: follower.source.branch, + branchRef: follower.source.branchRef, + resolvedBranch: branchValue, + snapshotPrefix: follower.source.snapshotPrefix, + }, + target: follower.target, + budgets: follower.budgets, + commands: redactCommands(follower), + closeoutChecks: follower.closeoutChecks, + configRefGraph: graph, + warnings, + }; + }); + return { + ok: true, + action: "plan", + spec: `${SPEC_REF} ${SPEC_VERSION}`, + registry: registrySummary(registry), + hostWorktreeAuthority: false, + sourceAuthority: { + mode: registry.controller.source.sourceAuthority.mode, + resolver: registry.controller.source.sourceAuthority.resolver, + allowHostGit: registry.controller.source.sourceAuthority.allowHostGit, + allowHostWorkspace: registry.controller.source.sourceAuthority.allowHostWorkspace, + allowGithubDirectInPipeline: registry.controller.source.sourceAuthority.allowGithubDirectInPipeline, + }, + controller: { + namespace: registry.controller.namespace, + kubeRoute: registry.controller.kubeRoute, + deploymentName: registry.controller.deploymentName, + stateConfigMapName: registry.controller.stateConfigMapName, + leaseName: registry.controller.leaseName, + image: registry.controller.image, + loop: registry.controller.loop, + budgets: registry.controller.budgets, + }, + followers, + next: { + apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", + status: "bun scripts/cli.ts cicd branch-follower status", + dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", + }, + }; +} + +async function applyController(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { + const manifests = renderControllerManifests(registry); + const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + const waitSeconds = options.timeoutSeconds ?? registry.controller.budgets.applyWaitSeconds; + const script = [ + "set -eu", + "tmp=$(mktemp)", + "cat >\"$tmp\"", + options.dryRun + ? `kubectl apply --dry-run=server --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"` + : `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"`, + !options.dryRun && options.wait + ? `kubectl -n ${shQuote(registry.controller.namespace)} rollout status deploy/${shQuote(registry.controller.deploymentName)} --timeout=${waitSeconds}s` + : "true", + `kubectl -n ${shQuote(registry.controller.namespace)} get deploy/${shQuote(registry.controller.deploymentName)} cm/${shQuote(registry.controller.configMapName)} cm/${shQuote(registry.controller.stateConfigMapName)} lease/${shQuote(registry.controller.leaseName)} -o wide 2>/dev/null || true`, + ].join("\n"); + const result = runKubeScript(registry, options, script, manifestYaml, (waitSeconds + 15) * 1000); + return { + ok: result.exitCode === 0, + action: "apply", + dryRun: options.dryRun, + wait: options.wait, + registry: registrySummary(registry), + objects: manifests.map((item) => objectRef(item)), + manifestSha256: createHash("sha256").update(manifestYaml).digest("hex"), + controller: { + namespace: registry.controller.namespace, + route: registry.controller.kubeRoute, + deploymentName: registry.controller.deploymentName, + stateConfigMapName: registry.controller.stateConfigMapName, + leaseName: registry.controller.leaseName, + hostWorktreeMounted: false, + sourceMode: "k8s-git-mirror-to-emptyDir", + }, + command: commandCompact(result, options), + next: { + status: "bun scripts/cli.ts cicd branch-follower status", + logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? ""}`, + dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", + }, + }; +} + +async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { + const k8s = readK8sState(registry, options); + const shouldLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0); + const selected = selectFollowers(registry, options, { includeDisabled: true }); + const followers = []; + for (const follower of selected) { + const stored = k8s.stateByFollower[follower.id] ?? {}; + const live = shouldLive && follower.enabled ? await readAdapterStatus(follower, options) : null; + followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive)); + } + return { + ok: k8s.ok && followers.every((item) => item.ok !== false), + action: "status", + live: shouldLive, + registry: registrySummary(registry), + controller: controllerStatusSummary(registry, k8s), + followers, + errors: k8s.errors, + next: { + apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", + liveStatus: "bun scripts/cli.ts cicd branch-follower status --live", + dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", + }, + }; +} + +async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { + const selected = selectFollowers(registry, options, { includeDisabled: false }); + const previous = readK8sState(registry, options); + const results: FollowerState[] = []; + const stateWriteWarnings: string[] = []; + for (const follower of selected) { + const oldState = previous.stateByFollower[follower.id] ?? {}; + const live = await readAdapterStatus(follower, options); + const state = await decideAndMaybeTrigger(registry, follower, oldState, live, options); + if (!options.dryRun) { + const write = writeFollowerState(registry, state, options); + if (write.exitCode !== 0) { + const warning = `state write failed for ${follower.id}: ${tailText(write.stderr || write.stdout, 300)}`; + state.warnings.push(warning); + stateWriteWarnings.push(warning); + } + } + results.push(state); + } + return { + ok: results.every((item) => item.phase !== "Failed" && item.phase !== "Blocked"), + action: "run-once", + dryRun: options.dryRun, + confirm: options.confirm, + wait: options.wait, + controller: options.controller, + registry: registrySummary(registry), + followers: results, + warnings: stateWriteWarnings, + next: { + status: "bun scripts/cli.ts cicd branch-follower status", + liveStatus: "bun scripts/cli.ts cicd branch-follower status --live", + }, + }; +} + +function runFollowerDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Record { + if (options.followerId === null) { + return { + ok: true, + action: options.action, + message: "select one follower to run the configured drill-down command", + followers: registry.followers.map((follower) => ({ + id: follower.id, + adapter: follower.adapter, + command: (options.action === "events" ? follower.commands.events : follower.commands.logs).argv.join(" "), + })), + }; + } + const follower = registry.followers.find((item) => item.id === options.followerId); + if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`); + const spec = options.action === "events" ? follower.commands.events : follower.commands.logs; + const result = runCommand(spec.argv, repoRoot, { timeoutMs: (options.timeoutSeconds ?? spec.timeoutSeconds) * 1000 }); + return { + ok: result.exitCode === 0, + action: options.action, + follower: follower.id, + adapter: follower.adapter, + command: spec.argv.join(" "), + result: { + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + stdoutTail: redactText(tailText(result.stdout, options.tailBytes)), + stderrTail: redactText(tailText(result.stderr, options.tailBytes)), + }, + next: { + status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`, + runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`, + }, + }; +} + +async function decideAndMaybeTrigger( + registry: BranchFollowerRegistry, + follower: FollowerSpec, + previous: Record, + live: AdapterSummary, + options: ParsedOptions, +): Promise { + const warnings: string[] = []; + if (!live.ok) warnings.push(`status command failed: exitCode=${live.exitCode}${live.timedOut ? " timedOut=true" : ""}`); + const observedSha = live.observedSha; + const targetSha = live.targetSha; + const previousLastTriggered = stringOrNull(previous.lastTriggeredSha); + const previousInFlight = stringOrNull(previous.inFlightJob); + const previousObserved = stringOrNull(recordAt(previous, ["source"])?.observedSha); + const superseded = previousInFlight !== null && previousObserved !== null && observedSha !== null && previousObserved !== observedSha; + let phase: BranchFollowerPhase; + let decision: string; + let triggerCommand: Record | undefined; + let inFlightJob: string | null = live.inFlightJob; + let lastTriggeredSha = live.lastTriggeredSha ?? previousLastTriggered; + let lastSucceededSha = live.lastSucceededSha ?? stringOrNull(previous.lastSucceededSha); + + if (!follower.enabled) { + phase = "Skipped"; + decision = "follower disabled"; + } else if (observedSha === null) { + phase = live.ok ? "Observed" : "Blocked"; + decision = "status did not expose an observed source sha; adapter trigger-current remains the dedupe authority"; + } else if (superseded) { + phase = "Superseded"; + decision = `previous in-flight sha ${shortSha(previousObserved)} was superseded by ${shortSha(observedSha)}`; + } else if (targetSha !== null && targetSha === observedSha) { + phase = "Noop"; + decision = "target already matches observed source sha"; + lastSucceededSha = observedSha; + } else if (previousLastTriggered !== null && previousLastTriggered === observedSha && !options.confirm) { + phase = "ClosingOut"; + decision = "same sha was already triggered; use status/events/logs for closeout"; + } else { + phase = "PendingTrigger"; + decision = targetSha === null + ? "target sha is unknown; trigger-current adapter will dedupe by source snapshot" + : `observed ${shortSha(observedSha)} differs from target ${shortSha(targetSha)}`; + } + + if (options.confirm && (phase === "PendingTrigger" || phase === "Superseded" || (phase === "Observed" && observedSha !== null))) { + const trigger = await executeTrigger(follower, observedSha, options); + triggerCommand = trigger.command; + phase = trigger.ok ? (options.wait || options.controller ? "ClosingOut" : "Triggering") : "Failed"; + decision = trigger.ok ? `trigger submitted for ${shortSha(observedSha)}` : `trigger failed for ${shortSha(observedSha)}`; + inFlightJob = trigger.jobId ?? live.inFlightJob; + lastTriggeredSha = observedSha; + if (trigger.ok && options.wait && targetSha === observedSha) lastSucceededSha = observedSha; + if (!trigger.ok) warnings.push(trigger.message); + } + + if (options.dryRun && phase === "PendingTrigger") decision = `${decision}; dry-run did not trigger`; + + return { + id: follower.id, + adapter: follower.adapter, + enabled: follower.enabled, + phase, + source: { + repository: follower.source.repository, + branch: follower.source.branch, + branchRef: follower.source.branchRef, + snapshotPrefix: follower.source.snapshotPrefix, + observedSha, + }, + target: { + node: follower.target.node, + lane: follower.target.lane, + namespace: follower.target.namespace, + sentinel: follower.target.sentinel, + targetSha, + }, + lastTriggeredSha, + lastSucceededSha, + pipelineRun: live.pipelineRun, + inFlightJob, + budgetSource: follower.budgets, + controller: { + mode: options.controller ? "k8s-controller" : "local-cli", + stateConfigMap: registry.controller.stateConfigMapName, + leaseName: registry.controller.leaseName, + }, + decision, + dryRun: options.dryRun, + updatedAt: new Date().toISOString(), + warnings, + next: followerNextCommands(follower), + command: triggerCommand ?? { + status: live.command, + exitCode: live.exitCode, + timedOut: live.timedOut, + }, + }; +} + +async function executeTrigger(follower: FollowerSpec, observedSha: string | null, options: ParsedOptions): Promise<{ ok: boolean; message: string; jobId: string | null; command: Record }> { + const spec = follower.commands.trigger; + const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; + if (!options.wait && !options.controller) { + const job = startJob(`cicd_branch_follower_${safeJobSegment(follower.id)}`, spec.argv, `Trigger ${follower.id} for observed sha ${observedSha ?? "unknown"}`); + return { + ok: true, + message: `started async job ${job.id}`, + jobId: job.id, + command: { + mode: "async-job", + argv: spec.argv, + jobId: job.id, + status: `bun scripts/cli.ts job status ${job.id}`, + }, + }; + } + const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 }); + return { + ok: result.exitCode === 0, + message: result.exitCode === 0 ? "trigger command completed" : tailText(result.stderr || result.stdout, 500), + jobId: null, + command: commandCompact(result, options), + }; +} + +async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions): Promise { + const spec = follower.commands.status; + const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; + const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 }); + const payload = parseJsonObject(result.stdout) ?? parseJsonObject(result.stderr); + const body = payload === null ? null : unwrapEnvelope(payload); + const observedSha = firstStringPath(body, [ + "summary.sourceCommit", + "summary.observedSha", + "summary.observedCommit", + "sourceHead.commit", + "sourceHead.sha", + "source.commit", + "source.sha", + "selectedSource.commit", + "selectedSource.sha", + "selectedCommit", + "sourceCommit", + "observedSha", + ], ["sourceCommit", "observedSha", "observedCommit", "selectedCommit", "selectedSourceCommit"]); + const targetSha = firstStringPath(body, [ + "summary.targetCommit", + "summary.targetSha", + "summary.runtimeCommit", + "summary.gitopsCommit", + "runtime.sourceCommit", + "runtime.commit", + "target.commit", + "target.sha", + "gitops.sourceCommit", + "deployedCommit", + "currentCommit", + "targetSha", + ], ["targetCommit", "targetSha", "runtimeCommit", "gitopsCommit", "deployedCommit", "currentCommit"]); + const lastTriggeredSha = firstStringPath(body, [ + "summary.lastTriggeredSha", + "summary.lastTriggeredCommit", + "trigger.sourceCommit", + "pipelineRun.sourceCommit", + "lastTriggeredSha", + ], ["lastTriggeredSha", "lastTriggeredCommit"]); + const lastSucceededSha = firstStringPath(body, [ + "summary.lastSucceededSha", + "summary.lastSucceededCommit", + "lastSucceededSha", + "runtime.succeededCommit", + ], ["lastSucceededSha", "lastSucceededCommit", "succeededCommit"]); + const pipelineRun = firstStringPath(body, [ + "summary.pipelineRun", + "pipelineRun.name", + "pipelineRun", + "latestPipelineRun.name", + ], ["pipelineRun", "pipelineRunName"]); + const inFlightJob = firstStringPath(body, [ + "summary.inFlightJob", + "job.id", + "inFlightJob", + "latestJob.id", + ], ["inFlightJob", "jobId"]); + const aligned = firstBooleanPath(body, [ + "summary.aligned", + "alignment.aligned", + "aligned", + "runtime.aligned", + ]); + const ok = result.exitCode === 0; + const phase = inferPhase(ok, aligned, observedSha, targetSha, result.timedOut); + return { + ok, + command: spec.argv.join(" "), + exitCode: result.exitCode, + timedOut: result.timedOut, + observedSha, + targetSha, + lastTriggeredSha, + lastSucceededSha, + pipelineRun, + inFlightJob, + aligned, + phase, + message: statusMessage(ok, phase, observedSha, targetSha, result), + payload: body, + stderrTail: redactText(tailText(result.stderr, 1000)), + stdoutTail: redactText(tailText(result.stdout, 1000)), + }; +} + +function inferPhase(ok: boolean, aligned: boolean | null, observedSha: string | null, targetSha: string | null, timedOut: boolean): BranchFollowerPhase { + if (!ok || timedOut) return "Blocked"; + if (aligned === true) return "Succeeded"; + if (observedSha !== null && targetSha !== null && observedSha === targetSha) return "Noop"; + if (observedSha !== null) return "PendingTrigger"; + return "Observed"; +} + +function statusMessage(ok: boolean, phase: BranchFollowerPhase, observedSha: string | null, targetSha: string | null, result: CommandResult): string { + if (!ok) return `status command failed: exitCode=${result.exitCode}${result.timedOut ? " timedOut=true" : ""}`; + if (phase === "Noop" || phase === "Succeeded") return `target matches ${shortSha(observedSha)}`; + if (observedSha !== null && targetSha !== null) return `observed ${shortSha(observedSha)} target ${shortSha(targetSha)}`; + if (observedSha !== null) return `observed ${shortSha(observedSha)} target unknown`; + return "status command completed; observed sha not exposed in compact payload"; +} + +function mergeFollowerStatus( + registry: BranchFollowerRegistry, + follower: FollowerSpec, + stored: Record, + live: AdapterSummary | null, + liveRequested: boolean, +): Record { + const storedSource = asOptionalRecord(stored.source); + const storedTarget = asOptionalRecord(stored.target); + const phase = live?.phase ?? stringOrNull(stored.phase) ?? "Observed"; + const observedSha = live?.observedSha ?? stringOrNull(storedSource?.observedSha); + const targetSha = live?.targetSha ?? stringOrNull(storedTarget?.targetSha); + const lastTriggeredSha = live?.lastTriggeredSha ?? stringOrNull(stored.lastTriggeredSha); + const lastSucceededSha = live?.lastSucceededSha ?? stringOrNull(stored.lastSucceededSha); + return { + ok: live === null ? true : live.ok, + id: follower.id, + enabled: follower.enabled, + adapter: follower.adapter, + phase, + source: { + repository: follower.source.repository, + branch: follower.source.branch, + observedSha, + snapshotPrefix: follower.source.snapshotPrefix, + }, + target: { + node: follower.target.node, + lane: follower.target.lane, + namespace: follower.target.namespace, + sentinel: follower.target.sentinel, + targetSha, + }, + lastTriggeredSha, + lastSucceededSha, + pipelineRun: live?.pipelineRun ?? stringOrNull(stored.pipelineRun), + inFlightJob: live?.inFlightJob ?? stringOrNull(stored.inFlightJob), + budgetSource: follower.budgets, + updatedAt: stringOrNull(stored.updatedAt), + stateConfigMap: registry.controller.stateConfigMapName, + live: liveRequested, + message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet", + warnings: Array.isArray(stored.warnings) ? stored.warnings.slice(0, 6) : [], + next: followerNextCommands(follower), + }; +} + +function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead { + const errors: string[] = []; + const stateResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get configmap ${shQuote(registry.controller.stateConfigMapName)} -o json`, 10_000); + const deploymentResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get deploy ${shQuote(registry.controller.deploymentName)} -o json`, 10_000); + const leaseResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get lease ${shQuote(registry.controller.leaseName)} -o json`, 10_000); + const podSelector = labelSelector(registry.controller.labels); + const podsResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get pods -l ${shQuote(podSelector)} -o json`, 10_000); + if (!stateResult.ok && !isNotFoundText(stateResult.error)) errors.push(`state configmap: ${stateResult.error}`); + if (!deploymentResult.ok && !isNotFoundText(deploymentResult.error)) errors.push(`deployment: ${deploymentResult.error}`); + if (!leaseResult.ok && !isNotFoundText(leaseResult.error)) errors.push(`lease: ${leaseResult.error}`); + if (!podsResult.ok && !isNotFoundText(podsResult.error)) errors.push(`pods: ${podsResult.error}`); + const stateByFollower: Record> = {}; + const data = asOptionalRecord(stateResult.value?.data); + if (data !== null) { + for (const [key, value] of Object.entries(data)) { + if (key.startsWith("_")) continue; + if (typeof value !== "string") continue; + const parsed = parseJsonObject(value); + if (parsed !== null) stateByFollower[key] = parsed; + } + } + return { + ok: errors.length === 0, + stateByFollower, + stateConfigMapPresent: stateResult.value !== null, + deployment: deploymentResult.value, + lease: leaseResult.value, + pods: podsResult.value, + errors, + }; +} + +function kubeJson(registry: BranchFollowerRegistry, options: ParsedOptions, command: string, timeoutMs: number): { ok: boolean; value: Record | null; error: string } { + const result = runKubeScript(registry, options, `set -eu\n${command}`, "", timeoutMs); + const value = result.exitCode === 0 ? parseJsonObject(result.stdout) : null; + return { + ok: result.exitCode === 0 && value !== null, + value, + error: redactText(tailText(result.stderr || result.stdout, 800)), + }; +} + +function runKubeScript(registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number): CommandResult { + if (options.controller) { + return runCommand(["sh", "-lc", script], repoRoot, { input, timeoutMs }); + } + return runCommand([transPath(), registry.controller.kubeRoute, "sh"], repoRoot, { input: `${script}\n`, timeoutMs }); +} + +function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult { + const json = JSON.stringify(state); + const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } }); + const script = [ + "set -eu", + `kubectl -n ${shQuote(registry.controller.namespace)} create configmap ${shQuote(registry.controller.stateConfigMapName)} --from-literal=_createdAt="$(date -Iseconds)" --dry-run=client -o yaml | kubectl apply -f - >/dev/null`, + `kubectl -n ${shQuote(registry.controller.namespace)} patch configmap ${shQuote(registry.controller.stateConfigMapName)} --type merge -p ${shQuote(dataPatch)} >/dev/null`, + ].join("\n"); + return runKubeScript(registry, options, script, "", 10_000); +} + +function renderControllerManifests(registry: BranchFollowerRegistry): Record[] { + const labels = registry.controller.labels; + const selector = labels; + return [ + { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: registry.controller.namespace, labels }, + }, + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, + rules: [ + { apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, + { apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] }, + { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, + { apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, + ], + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, + subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: registry.controller.serviceAccountName }, + }, + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels }, + data: { "cicd-branch-followers.yaml": registry.rawText }, + }, + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: registry.controller.stateConfigMapName, namespace: registry.controller.namespace, labels }, + data: { + _createdAt: new Date().toISOString(), + _specRef: SPEC_REF, + _registrySha256: registry.rawSha256, + }, + }, + { + apiVersion: "coordination.k8s.io/v1", + kind: "Lease", + metadata: { name: registry.controller.leaseName, namespace: registry.controller.namespace, labels }, + spec: { holderIdentity: "unidesk-cicd-branch-follower", leaseDurationSeconds: Math.max(30, registry.controller.loop.reconcileTimeoutSeconds + 30) }, + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: registry.controller.deploymentName, namespace: registry.controller.namespace, labels }, + spec: { + replicas: 1, + selector: { matchLabels: selector }, + template: { + metadata: { + labels: selector, + annotations: { + "unidesk.pikapython.com/spec-ref": SPEC_REF, + "unidesk.pikapython.com/registry-sha256": registry.rawSha256, + "unidesk.pikapython.com/host-worktree-authority": "false", + }, + }, + spec: { + serviceAccountName: registry.controller.serviceAccountName, + terminationGracePeriodSeconds: 30, + volumes: [ + { name: "registry", configMap: { name: registry.controller.configMapName } }, + { name: "work", emptyDir: {} }, + ], + containers: [ + { + name: "controller", + image: registry.controller.image, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-lc"], + args: [controllerLoopScript()], + env: [ + { name: "UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS", value: String(registry.controller.loop.intervalSeconds) }, + { name: "UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS", value: String(registry.controller.loop.reconcileTimeoutSeconds) }, + { name: "UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL", value: registry.controller.source.gitMirrorReadUrl }, + { name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch }, + ], + volumeMounts: [ + { name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true }, + { name: "work", mountPath: "/work" }, + ], + }, + ], + }, + }, + }, + }, + ]; +} + +function controllerLoopScript(): string { + return [ + "set -eu", + "interval=\"${UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS}\"", + "timeout=\"${UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS}\"", + "while true; do", + " started_at=$(date -Iseconds)", + " echo \"branch-follower loop started ${started_at}\"", + " rm -rf /work/unidesk", + " git clone --depth=1 --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL}\" /work/unidesk", + " cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml", + " cd /work/unidesk", + " bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --controller --config config/cicd-branch-followers.yaml --timeout-seconds \"${timeout}\" --json || true", + " echo \"branch-follower loop finished $(date -Iseconds)\"", + " sleep \"${interval}\"", + "done", + ].join("\n"); +} + +function selectFollowers(registry: BranchFollowerRegistry, options: ParsedOptions, opts: { includeDisabled: boolean }): FollowerSpec[] { + let selected = registry.followers; + if (options.followerId !== null) selected = selected.filter((item) => item.id === options.followerId); + else if (!options.all && options.action === "run-once") selected = selected.filter((item) => item.enabled); + if (!opts.includeDisabled) selected = selected.filter((item) => item.enabled); + if (selected.length === 0) throw new Error(options.followerId === null ? "no followers selected" : `unknown or disabled follower ${options.followerId}`); + return selected; +} + +function registrySummary(registry: BranchFollowerRegistry): Record { + return { + path: registry.path, + sha256: registry.rawSha256, + metadata: registry.metadata, + controller: { + namespace: registry.controller.namespace, + kubeRoute: registry.controller.kubeRoute, + deploymentName: registry.controller.deploymentName, + stateConfigMapName: registry.controller.stateConfigMapName, + leaseName: registry.controller.leaseName, + }, + followers: registry.followers.map((item) => item.id), + }; +} + +function redactCommands(follower: FollowerSpec): Record { + return { + plan: follower.commands.plan.argv.join(" "), + status: follower.commands.status.argv.join(" "), + trigger: follower.commands.trigger.argv.join(" "), + events: follower.commands.events.argv.join(" "), + logs: follower.commands.logs.argv.join(" "), + }; +} + +function controllerStatusSummary(registry: BranchFollowerRegistry, k8s: K8sStateRead): Record { + const deploymentStatus = asOptionalRecord(k8s.deployment?.status); + const available = numberOrNull(deploymentStatus?.availableReplicas) ?? 0; + const replicas = numberOrNull(deploymentStatus?.replicas) ?? 0; + const leaseSpec = asOptionalRecord(k8s.lease?.spec); + const podItems = Array.isArray(k8s.pods?.items) ? k8s.pods.items.length : null; + return { + namespace: registry.controller.namespace, + route: registry.controller.kubeRoute, + deploymentName: registry.controller.deploymentName, + deploymentPresent: k8s.deployment !== null, + availableReplicas: available, + replicas, + pods: podItems, + stateConfigMapName: registry.controller.stateConfigMapName, + stateConfigMapPresent: k8s.stateConfigMapPresent, + leaseName: registry.controller.leaseName, + leaseHolder: stringOrNull(leaseSpec?.holderIdentity), + noHostWorktreeAuthority: true, + }; +} + +function followerNextCommands(follower: FollowerSpec): Record { + return { + status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`, + liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`, + dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`, + trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`, + events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`, + logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`, + adapterStatus: follower.commands.status.argv.join(" "), + }; +} + +function safeResolveString(ref: string): string | null { + try { + return resolveConfigRefString(ref, ref); + } catch { + return null; + } +} + +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (trimmed.length === 0) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + return asOptionalRecord(parsed); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; + return asOptionalRecord(parsed); + } catch { + return null; + } + } +} + +function unwrapEnvelope(payload: Record): Record { + const data = asOptionalRecord(payload.data); + return data ?? payload; +} + +function firstStringPath(root: Record | null, paths: string[], fallbackKeys: string[] = []): string | null { + if (root === null) return null; + for (const path of paths) { + const value = valueAt(root, path); + if (typeof value === "string" && value.length > 0) return value; + } + return fallbackKeys.length === 0 ? null : firstStringByKey(root, fallbackKeys); +} + +function firstBooleanPath(root: Record | null, paths: string[]): boolean | null { + if (root === null) return null; + for (const path of paths) { + const value = valueAt(root, path); + if (typeof value === "boolean") return value; + } + return null; +} + +function valueAt(root: unknown, path: string): unknown { + let current = root; + for (const part of path.split(".")) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + return current; +} + +function firstStringByKey(root: unknown, keys: string[]): string | null { + if (typeof root !== "object" || root === null) return null; + if (Array.isArray(root)) { + for (const item of root) { + const found = firstStringByKey(item, keys); + if (found !== null) return found; + } + return null; + } + const record = root as Record; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + for (const value of Object.values(record)) { + const found = firstStringByKey(value, keys); + if (found !== null) return found; + } + return null; +} + +function recordAt(root: Record, path: string[]): Record | null { + let current: unknown = root; + for (const item of path) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return null; + current = (current as Record)[item]; + } + return asOptionalRecord(current); +} + +function asOptionalRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function commandCompact(result: CommandResult, options: ParsedOptions): Record { + return { + argv: result.command, + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + stdoutTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stdout, options.tailBytes)) : "", + stderrTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stderr, Math.min(options.tailBytes, 4000))) : "", + }; +} + +function objectRef(item: Record): Record { + const metadata = asRecord(item.metadata, "metadata"); + return { + kind: stringField(item, "kind", "manifest"), + namespace: typeof metadata.namespace === "string" ? metadata.namespace : "-", + name: stringField(metadata, "name", "manifest.metadata"), + }; +} + +function labelSelector(labels: Record): string { + return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(","); +} + +function isNotFoundText(value: string): boolean { + return /notfound|not found|notfound|NotFound/u.test(value); +} + +function shortSha(value: string | null): string { + if (value === null) return "-"; + return value.length > 12 ? value.slice(0, 12) : value; +} + +function safeJobSegment(value: string): string { + return value.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 60); +} + +function tailText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(text.length - maxChars); +} + +function commandLabel(options: ParsedOptions): string { + return `cicd branch-follower ${options.action}`; +} + +function renderResult(command: string, payload: Record, options: ParsedOptions): RenderedCliResult { + const ok = payload.ok !== false; + if (options.output === "json") return renderMachine(command, payload, "json", ok); + if (options.output === "yaml") return renderMachine(command, payload, "yaml", ok); + return rendered(ok, command, renderHuman(command, payload, options)); +} + +function renderMachine(command: string, value: unknown, mode: "json" | "yaml", ok = true): RenderedCliResult { + return rendered(ok, command, mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`, mode === "json" ? "application/json" : "application/yaml"); +} + +function rendered(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult { + return { ok, command, renderedText, contentType }; +} + +function renderHuman(command: string, payload: Record, options: ParsedOptions): string { + if (command.endsWith(" plan")) return renderPlanHuman(payload); + if (command.endsWith(" apply")) return renderApplyHuman(payload); + if (command.endsWith(" status")) return renderStatusHuman(payload, options); + if (command.endsWith(" run-once")) return renderRunOnceHuman(payload); + if (command.endsWith(" events") || command.endsWith(" logs")) return renderDrillDownHuman(payload); + return `${JSON.stringify(payload, null, 2)}\n`; +} + +function renderPlanHuman(payload: Record): string { + const followers = arrayRecords(payload.followers); + const rows = followers.map((item) => { + const source = asOptionalRecord(item.source); + const target = asOptionalRecord(item.target); + const budgets = asOptionalRecord(item.budgets); + return [ + item.id, + item.enabled, + item.adapter, + `${source?.repository ?? "-"}@${source?.branch ?? "-"}`, + `${target?.node ?? "-"}/${target?.lane ?? "-"}`, + budgets?.endToEndSeconds ?? "-", + arrayRecords(item.configRefGraph).length, + arrayText(item.closeoutChecks), + ]; + }); + const next = asOptionalRecord(payload.next); + return [ + `CI/CD BRANCH-FOLLOWER PLAN (${payload.ok === false ? "blocked" : "ok"})`, + "", + table(["FOLLOWER", "ENABLED", "ADAPTER", "SOURCE", "TARGET", "BUDGET", "REFS", "CHECKS"], rows), + "", + "SOURCE AUTHORITY", + `hostWorktreeAuthority=${payload.hostWorktreeAuthority === true ? "true" : "false"} mode=${asOptionalRecord(payload.sourceAuthority)?.mode ?? "-"} resolver=${asOptionalRecord(payload.sourceAuthority)?.resolver ?? "-"}`, + "", + "NEXT", + `apply: ${next?.apply ?? "-"}`, + `status: ${next?.status ?? "-"}`, + `dry-run: ${next?.dryRun ?? "-"}`, + "", + ].join("\n"); +} + +function renderApplyHuman(payload: Record): string { + const controller = asOptionalRecord(payload.controller); + const command = asOptionalRecord(payload.command); + const next = asOptionalRecord(payload.next); + return [ + `CI/CD BRANCH-FOLLOWER APPLY (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`, + "", + table( + ["NAMESPACE", "ROUTE", "DEPLOYMENT", "STATE_CM", "LEASE", "HOST_WORKTREE"], + [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", controller?.stateConfigMapName ?? "-", controller?.leaseName ?? "-", controller?.hostWorktreeMounted === true ? "mounted" : "not-mounted"]], + ), + "", + table(["OBJECTS", "MANIFEST_SHA", "EXIT", "TIMED_OUT"], [[arrayRecords(payload.objects).length, shortSha(stringOrNull(payload.manifestSha256)), command?.exitCode ?? "-", command?.timedOut ?? "-"]]), + command?.stderrTail ? `\nSTDERR\n${command.stderrTail}` : "", + "", + "NEXT", + `status: ${next?.status ?? "-"}`, + `dry-run: ${next?.dryRun ?? "-"}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function renderStatusHuman(payload: Record, _options: ParsedOptions): string { + const controller = asOptionalRecord(payload.controller); + const followers = arrayRecords(payload.followers); + const rows = followers.map((item) => { + const source = asOptionalRecord(item.source); + const target = asOptionalRecord(item.target); + const budgets = asOptionalRecord(item.budgetSource); + return [ + item.id, + item.phase, + item.adapter, + `${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`, + shortSha(stringOrNull(target?.targetSha)), + shortSha(stringOrNull(item.lastTriggeredSha)), + shortSha(stringOrNull(item.lastSucceededSha)), + item.pipelineRun ?? item.inFlightJob ?? "-", + budgets?.endToEndSeconds ?? "-", + item.message ?? "-", + ]; + }); + const next = asOptionalRecord(payload.next); + const errors = Array.isArray(payload.errors) ? payload.errors : []; + return [ + `CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`, + "", + table( + ["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "STATE_CM", "LEASE"], + [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]], + ), + "", + table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows), + errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`, + "", + "NEXT", + `live-status: ${next?.liveStatus ?? "-"}`, + `dry-run: ${next?.dryRun ?? "-"}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function renderRunOnceHuman(payload: Record): string { + const followers = arrayRecords(payload.followers); + const rows = followers.map((item) => { + const source = asOptionalRecord(item.source); + const target = asOptionalRecord(item.target); + return [ + item.id, + item.phase, + `${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`, + shortSha(stringOrNull(target?.targetSha)), + shortSha(stringOrNull(item.lastTriggeredSha)), + item.inFlightJob ?? "-", + item.decision ?? "-", + ]; + }); + const next = asOptionalRecord(payload.next); + return [ + `CI/CD BRANCH-FOLLOWER RUN-ONCE (${payload.ok === false ? "blocked" : payload.dryRun === true ? "dry-run" : "ok"})`, + "", + table(["FOLLOWER", "PHASE", "OBSERVED", "TARGET", "TRIGGERED", "IN_FLIGHT", "DECISION"], rows), + "", + "NEXT", + `status: ${next?.status ?? "-"}`, + `live-status: ${next?.liveStatus ?? "-"}`, + "", + ].join("\n"); +} + +function renderDrillDownHuman(payload: Record): string { + if (payload.follower === undefined) { + const followers = arrayRecords(payload.followers); + return [ + `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`, + "", + table(["FOLLOWER", "ADAPTER", "COMMAND"], followers.map((item) => [item.id, item.adapter, item.command])), + "", + ].join("\n"); + } + const result = asOptionalRecord(payload.result); + return [ + `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`, + "", + table(["FOLLOWER", "ADAPTER", "EXIT", "TIMED_OUT", "STDOUT", "STDERR"], [[payload.follower, payload.adapter ?? "-", result?.exitCode ?? "-", result?.timedOut ?? "-", result?.stdoutBytes ?? "-", result?.stderrBytes ?? "-"]]), + result?.stdoutTail ? `\nSTDOUT_TAIL\n${result.stdoutTail}` : "", + result?.stderrTail ? `\nSTDERR_TAIL\n${result.stderrTail}` : "", + "", + ].filter((line) => line !== "").join("\n"); +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function arrayText(value: unknown): string { + return Array.isArray(value) ? value.map(String).join(",") : "-"; +} + +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/help.ts b/scripts/src/help.ts index 3d2e0c21..b9b640a2 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,4 +1,5 @@ // SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction. +// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. // Static CLI help for job aliases and server lifecycle progressive disclosure. import { ghHelp, ghScopedHelp } from "./gh"; import { authBrokerHelp } from "./auth-broker"; @@ -57,6 +58,7 @@ export function rootHelp(): unknown { { command: "decision requirement list|create|show|update|upsert [id|docNo] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." }, { 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: "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." }, @@ -730,6 +732,20 @@ function webProbeHelpSummary(): unknown { }; } +function cicdHelpSummary(): unknown { + return { + command: "cicd branch-follower plan|apply|status|run-once|events|logs", + 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", + ], + description: "YAML-first Kubernetes branch follower for three CI/CD running planes, with K8s state and adapter drill-down visibility.", + }; +} + function hwlabG14HelpSummary(): unknown { return { command: "hwlab g14 monitor-prs|control-plane|git-mirror|tools-image|retirement", @@ -795,6 +811,7 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./cicd")).cicdHelp(), cicdHelpSummary()); if (top === "agentrun") return loadHelp(async () => (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary()); if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); if (top === "platform-db") return platformDbHelp();