[WIP] Replace submodule coupling with versioned dependencies and source workspaces #537
+943
-479
File diff suppressed because it is too large
Load Diff
+13
@@ -21,3 +21,16 @@ runtime-backups/
|
||||
|
||||
# e2e.sh 运行工件(构建/种子/日志;scripts/e2e.sh 输出)
|
||||
.tmp/
|
||||
|
||||
# Isolated migration candidates and local component workspaces.
|
||||
/.migration/
|
||||
/.workspace/
|
||||
/go.work
|
||||
/go.work.sum
|
||||
/.ci-mirrors/
|
||||
/.ci-mirrors.env
|
||||
/.ci-rdm-pins
|
||||
|
||||
# Legacy checkouts remain for explicit owner transition and metadata preservation.
|
||||
/modules/
|
||||
/deps/
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
[submodule "modules/core"]
|
||||
path = modules/core
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
|
||||
[submodule "modules/backend"]
|
||||
path = modules/backend
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git
|
||||
[submodule "modules/frontend"]
|
||||
path = modules/frontend
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Frontend.git
|
||||
[submodule "modules/documents"]
|
||||
path = modules/documents
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Documents.git
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
[submodule "deps/agent-native"]
|
||||
path = deps/agent-native
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||
[submodule "deps/agent-squad"]
|
||||
path = deps/agent-squad
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
[submodule "deps/agent-intent"]
|
||||
path = deps/agent-intent
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
|
||||
[submodule "deps/error-code-system"]
|
||||
path = deps/error-code-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
|
||||
[submodule "deps/logging-system"]
|
||||
path = deps/logging-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
|
||||
[submodule "modules/ui"]
|
||||
path = modules/ui
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git
|
||||
[submodule "deps/agent-command"]
|
||||
path = deps/agent-command
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
||||
# Agent 工作说明(Agent-System-Dev)
|
||||
|
||||
本仓开发遵循 Development-Guidelines(见 `deps/development-guidelines`,gitlink 固定版本):
|
||||
本仓开发遵循固定 Development-Guidelines:新布局从 `components.lock.json` 准备
|
||||
`.workspace/Development-Guidelines`(启动检查可先提供仓外固定缓存);旧提交仍读
|
||||
`deps/development-guidelines` 的 gitlink。规则载入、版本权威及分支过渡见
|
||||
[policy/dependencies.md](policy/dependencies.md),首次准备失败不得跳过必读。
|
||||
核心规则与术语见其 `core/`,按任务触发阅读 `standards/` 专题;通用交付依据为 `workflows/delivery.md`。
|
||||
本项目的需求、设计试验、实施调整及交付**必读 [policy/delivery.md](policy/delivery.md)**:
|
||||
需求、方案、实施与验收默认逐阶段人工评审,阶段内迭代、按影响回退;代码 Review 与 PR
|
||||
@@ -15,7 +18,7 @@
|
||||
`@ui/motion.css` 的 `mk-*` 工具类;折叠一律用 `@ui` Collapsible 原语,禁止 `<details>`。
|
||||
- 守护测试(UI 仓与 Frontend 仓各一份 `design-guard.test.ts`)以棘轮拦截新增裸色值、
|
||||
私有 `@keyframes` 与行内字号;风格调整须在同一变更集中关联 UI 令牌 PR 与 Dev 的
|
||||
`DESIGN.md`/gitlink 组合 PR,并验证精确组件 SHA(跨仓不要求两个文件出现在同一 PR)。
|
||||
`DESIGN.md`/依赖锁组合 PR,并验证精确组件 SHA(跨仓不要求两个文件出现在同一 PR)。
|
||||
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
|
||||
|
||||
## 任务启动门禁(强制)
|
||||
@@ -81,7 +84,10 @@
|
||||
|
||||
- 主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`
|
||||
四段;任务卡与 PR 描述为同一份文档。
|
||||
- 跨仓 gitlink 变更须经组合验证(`make test`)后在 Dev 仓推进;受保护分支经 PR 合入。
|
||||
- 跨仓语言依赖/应用源码锁变更须经固定组合验证(`make test`)后在 Dev 仓推进;
|
||||
旧布局 gitlink 变更仍按其固定组合核验。受保护分支经 PR 合入。
|
||||
功能仓源码只提交到所属仓;按需编辑、开发覆盖与旧任务显式过渡遵循
|
||||
[依赖与过渡入口](policy/dependencies.md)。不得修改其他活动工作区或删除仍被引用的 Git 管理数据。
|
||||
- **高后果操作双钥匙**:改写历史、解除/变更分支保护、删除归档引用、发布版本——Agent 只准备
|
||||
方案与命令,由用户确认并亲手执行;Agent 不得自行实施(baseline 硬边界 1 的授权范围要求)。
|
||||
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
||||
@@ -102,7 +108,7 @@
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
(.gitmodules 与各仓 origin 均为此格式)。
|
||||
(components.json 与各仓 origin 使用此传输格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
|
||||
|
||||
@@ -2,18 +2,36 @@
|
||||
# 部署约束:非容器化;默认产物为单二进制(modules/backend,内嵌前端与嵌入式存储)。
|
||||
|
||||
SHELL := /bin/bash
|
||||
SOURCE_MODE := $(if $(wildcard components.json),workspace,legacy)
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
CORE := .workspace/Agent-System-Core
|
||||
BACKEND := .workspace/Agent-System-Backend
|
||||
FRONTEND := .workspace/Agent-System-Frontend
|
||||
UI := .workspace/Agent-System-UI
|
||||
NATIVE := .workspace/Agent-Native
|
||||
INTENT := .workspace/Agent-Intent
|
||||
SQUAD := .workspace/Agent-Squad
|
||||
COMMAND := .workspace/Agent-Command
|
||||
DOCUMENTS := .workspace/Agent-System-Documents
|
||||
else
|
||||
CORE := modules/core
|
||||
BACKEND := modules/backend
|
||||
FRONTEND := modules/frontend
|
||||
UI := modules/ui
|
||||
NATIVE := deps/agent-native
|
||||
INTENT := deps/agent-intent
|
||||
SQUAD := deps/agent-squad
|
||||
COMMAND := deps/agent-command
|
||||
DOCUMENTS := modules/documents
|
||||
endif
|
||||
BIN := bin/agent-system
|
||||
EMBED_DIR := $(BACKEND)/web/dist
|
||||
PLUGINS := plugins
|
||||
# 版本溯源(release-operations:运行制品须可对上固定组合)。无 tag 时 --always 给短 SHA。
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C $(COMMAND) rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C $(DOCUMENTS) rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C $(NATIVE) rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C $(SQUAD) rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C $(INTENT) rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status workspace-prepare component-edit component-unlink workspace-status workspace-transition build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
@@ -57,14 +75,33 @@ task-finish:
|
||||
evidence:
|
||||
@SRC='$(SRC)' TAIL='$(TAIL)' bash scripts/evidence.sh
|
||||
|
||||
# workspace.py owns source identity, locks and development overlays.
|
||||
workspace-prepare:
|
||||
@python3 scripts/workspace.py --root . prepare $(if $(NAME),--name "$(NAME)") $(if $(filter 1,$(REFRESH_FIXED)),--refresh-fixed)
|
||||
|
||||
component-edit:
|
||||
@test -n "$(NAME)" || { echo "usage: make component-edit NAME=<component> [BRANCH=feature/<slug>]"; exit 2; }
|
||||
@python3 scripts/workspace.py --root . edit --name "$(NAME)" $(if $(BRANCH),--branch "$(BRANCH)")
|
||||
|
||||
component-unlink:
|
||||
@test -n "$(NAME)" || { echo "usage: make component-unlink NAME=<component>"; exit 2; }
|
||||
@python3 scripts/workspace.py --root . unlink --name "$(NAME)"
|
||||
|
||||
workspace-status:
|
||||
@python3 scripts/workspace.py --root . status $(if $(NAME),--name "$(NAME)")
|
||||
|
||||
# Default report is read-only. Only explicit ARGS=--apply copies into new roots.
|
||||
workspace-transition:
|
||||
@python3 scripts/workspace.py --root . transition $(if $(NAME),--name "$(NAME)") $(ARGS)
|
||||
|
||||
# 外部事实登记表逐条重验(--update 刷新 PASS 项 last_verified)。
|
||||
facts-verify:
|
||||
@bash scripts/facts-verify.sh $(ARGS)
|
||||
|
||||
help:
|
||||
@echo "make sync 物化全部 submodule(递归)"
|
||||
@echo "make build 构建单二进制 bin/agent-system(契约同步→前端构建→embed→静态链接)"
|
||||
@echo "make test 全量测试(boundaries + pins + libs + core + backend + frontend + ui + agent-command)"
|
||||
@echo "make sync 准备固定应用/资料/规则源码(旧提交保留 submodule 模式)"
|
||||
@echo "make build 固定版本隔离构建 bin/agent-system(前端资产→embed→单二进制)"
|
||||
@echo "make test 固定组合测试(治理 + boundaries/architecture + 组件与前端测试)"
|
||||
@echo "make test-governance 临时真实仓库中的任务、证据、写守卫与 CI 凭据回归"
|
||||
@echo "make svc 服务级操作:make svc ENV=<name> [ACTION=deploy|start|stop|restart|status|logs] [ARGS='--from dev@<sha> --tag ...']"
|
||||
@echo "make svc-list 服务总览:make svc-list [ARGS='--tag core'];数据库运维:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set"
|
||||
@@ -75,16 +112,32 @@ help:
|
||||
@echo "make svc-recover 自恢复:拉回 autostart=1 但已死的服务:make svc-recover [ARGS='dev --quiet']"
|
||||
@echo "make svc-watchdog 自恢复看护:make svc-watchdog ARGS='install|remove|status'(systemd 用户 timer)"
|
||||
@echo "make dev 开发模式说明(前后端分离热更新)"
|
||||
@echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)"
|
||||
@echo "make status submodule 状态"
|
||||
@echo "make workspace-prepare 准备固定源码;component-edit NAME=<id> 按需编辑"
|
||||
@echo "make workspace-transition [NAME=<id>] 默认只报告;ARGS=--apply 显式准备新现场"
|
||||
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
sync: workspace-prepare
|
||||
|
||||
status: workspace-status
|
||||
else
|
||||
sync:
|
||||
git submodule update --init --recursive
|
||||
|
||||
status:
|
||||
@git submodule status
|
||||
endif
|
||||
|
||||
# 契约同步:Core 的 TS 类型镜像到前端与 UI 组件库(Core 为权威,单一来源)。
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
# Contracts are versioned npm package exports; frontend builds materialize them.
|
||||
contracts: frontend
|
||||
|
||||
frontend:
|
||||
python3 scripts/build-composition.py --root . --target frontend
|
||||
|
||||
build:
|
||||
python3 scripts/build-composition.py --root . --target build
|
||||
else
|
||||
contracts: test-worktree
|
||||
cp $(CORE)/ts/types.ts $(FRONTEND)/src/contracts.ts.tmp
|
||||
{ echo "// 从 Agent-System-Core ts/types.ts 同步(make contracts 生成;Core 为权威)。"; cat $(FRONTEND)/src/contracts.ts.tmp; } > $(FRONTEND)/src/contracts.ts
|
||||
@@ -103,6 +156,22 @@ build: frontend
|
||||
cd $(BACKEND) && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o ../../bin/storage-migrate ./cmd/storage-migrate
|
||||
@echo "OK: $(BIN)(版本 $(VERSION);随附插件目录 $(PLUGINS)/)+ bin/storage-migrate(协作存储 U4 迁移工具)"
|
||||
|
||||
endif
|
||||
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
# Named checks exercise developer sources, prepare selected versions, and keep
|
||||
# GOWORK=off unless LOCAL_OVERLAY=1 explicitly selects registered component links.
|
||||
.PHONY: dev-backend dev-frontend
|
||||
LOCAL_CHECK := python3 scripts/component-check.py --root .
|
||||
test-lifecycle:
|
||||
@test -n "$${AGENT_PG_TEST_DSN}" || { echo "test-lifecycle: AGENT_PG_TEST_DSN 未设置(真实 PostgreSQL 必需,不静默跳过)"; exit 1; }
|
||||
@test -n "$${SVC_RDM_SRC_DIR}" || { echo "test-lifecycle: SVC_RDM_SRC_DIR 未设置(RDM 检出必需,不静默跳过)"; exit 1; }
|
||||
bash scripts/fetch-rdm-pin.sh tests/integration/lifecycle/rdm.pin >/dev/null
|
||||
python3 tests/integration/lifecycle/lifecycle_suite.py
|
||||
|
||||
test-core test-backend test-backend-pg test-multi-instance test-frontend test-ui test-agentcmd eval-deliverables dev-backend dev-frontend:
|
||||
$(LOCAL_CHECK) --target $@ $(if $(filter 1,$(LOCAL_OVERLAY)),--overlay)
|
||||
else
|
||||
test-core:
|
||||
cd $(CORE) && go test ./...
|
||||
|
||||
@@ -143,24 +212,37 @@ test-ui: contracts
|
||||
cd $(UI) && npm ci --no-fund --no-audit && npx tsc --noEmit && npx vitest run && node scripts/check-knip.mjs
|
||||
|
||||
test-agentcmd:
|
||||
cd deps/agent-command && go vet ./... && go test -count=1 ./...
|
||||
cd $(COMMAND) && go vet ./... && go test -count=1 ./...
|
||||
|
||||
endif
|
||||
|
||||
test-boundaries:
|
||||
bash scripts/lint-boundaries.sh
|
||||
|
||||
# 容器仓 deps/ gitlink 与 Dev 顶层指针一致。读索引中的 gitlink。
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
test-pins:
|
||||
@if [ -n "$${COMPOSITION_SOURCE_MAP:-}" ]; then bash scripts/check-pins.sh; else python3 scripts/workspace.py --root . verify; fi
|
||||
else
|
||||
test-pins:
|
||||
bash scripts/check-pins.sh
|
||||
endif
|
||||
|
||||
# 三个库在各自模块内测试。不用 go.work:工作区 MVS 会抬高 golang.org/x 版本。
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
test-libs:
|
||||
cd deps/agent-intent && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-native && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-squad && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-squad/adapters/intent && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-squad/adapters/native && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-squad/adapters/sqlite && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd deps/agent-squad/cmd/agent-squad && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
$(LOCAL_CHECK) --target $@ $(if $(filter 1,$(LOCAL_OVERLAY)),--overlay)
|
||||
else
|
||||
test-libs:
|
||||
cd $(INTENT) && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(NATIVE) && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(SQUAD) && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(SQUAD)/adapters/intent && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(SQUAD)/adapters/native && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(SQUAD)/adapters/sqlite && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
cd $(SQUAD)/cmd/agent-squad && GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
|
||||
endif
|
||||
|
||||
plugin-new:
|
||||
@test -n "$(NAME)" || (echo "用法:make plugin-new NAME=<id>"; exit 2)
|
||||
@@ -175,9 +257,23 @@ test-worktree:
|
||||
python3 scripts/check-worktree.py verify
|
||||
|
||||
# 即使 make -j,也先确认实际被测子仓与固定组合一致。
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
test-libs test-core test-backend test-frontend test-ui test-agentcmd: | test-worktree
|
||||
ifeq ($(COMPOSITION_SOURCE_MAP),)
|
||||
test-boundaries test-pins: | test-worktree
|
||||
endif
|
||||
else
|
||||
test-boundaries test-pins test-libs test-core test-backend test-frontend test-ui test-agentcmd: | test-worktree
|
||||
endif
|
||||
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
# Formal composition uses isolated sources and GOWORK=off. Developer overlays
|
||||
# remain untouched; the composition executor runs the component gates.
|
||||
test:
|
||||
python3 scripts/build-composition.py --root . --target test
|
||||
else
|
||||
test: test-worktree test-governance test-boundaries test-pins test-libs test-core test-backend test-frontend test-ui test-agentcmd
|
||||
endif
|
||||
|
||||
# 运行演示(mock 模式):单二进制 + 示例插件 + 嵌入式存储,浏览器 http://127.0.0.1:8787
|
||||
# 环境服务管理(scripts/svc.sh):deploy = 停旧 → 构建 → 启动;dev/prod 相互隔离。
|
||||
@@ -210,6 +306,10 @@ svc-update:
|
||||
demo: build
|
||||
$(BIN) serve --addr 127.0.0.1:8787 --data ./data --plugins $(PLUGINS) --mock
|
||||
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
dev:
|
||||
$(LOCAL_CHECK) --target dev $(if $(filter 1,$(LOCAL_OVERLAY)),--overlay)
|
||||
else
|
||||
dev:
|
||||
@echo "开发模式(原生,非容器):"
|
||||
@echo " 1. make sync && make contracts"
|
||||
@@ -217,7 +317,14 @@ dev:
|
||||
@echo " 3. 终端 B:cd $(FRONTEND) && npm ci && npm run dev # Vite 代理到 8787"
|
||||
@echo " 4. mock 演示:设置→运行时→演示模式;或终端 A 加 --mock"
|
||||
|
||||
endif
|
||||
|
||||
# 推进指针(多仓纪律:先验证后固定)。
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
pin:
|
||||
@echo "pin: update consumer go.mod/package-lock.json or components.lock.json explicitly, then verify the fixed composition" >&2
|
||||
@exit 2
|
||||
else
|
||||
pin:
|
||||
@for m in modules/core modules/backend modules/frontend modules/ui modules/documents deps/agent-native deps/agent-squad deps/agent-intent deps/error-code-system deps/logging-system deps/development-guidelines deps/agent-command; do \
|
||||
(git -C $$m fetch -q origin && git -C $$m checkout -q origin/main) || echo "skip $$m"; \
|
||||
@@ -225,5 +332,12 @@ pin:
|
||||
@echo "submodule 指针已推进;请 make test 后 git add .gitmodules modules deps && git commit"
|
||||
@scripts/check-pins.sh || echo "pin: check-pins 未通过,上面列出的容器需要重钉;pin 本身不失败"
|
||||
|
||||
endif
|
||||
|
||||
ifeq ($(SOURCE_MODE),workspace)
|
||||
clean:
|
||||
rm -f $(BIN) bin/storage-migrate
|
||||
else
|
||||
clean:
|
||||
rm -rf $(BIN) $(FRONTEND)/dist $(FRONTEND)/node_modules $(UI)/node_modules data
|
||||
endif
|
||||
|
||||
+39
-27
@@ -1,53 +1,59 @@
|
||||
# Agent-System-Dev 项目事实
|
||||
|
||||
- 用途:开发集成父仓(gitlink 权威);非分发单元。
|
||||
- 组合:modules/{core,backend,frontend,ui,documents} + deps/{development-guidelines,agent-native,agent-squad,agent-intent,agent-command,error-code-system,logging-system}。
|
||||
- 用途:独立功能仓的开发与固定组合入口;非分发单元。
|
||||
- 组合:组件身份见 components.json;应用/规范/资料提交见 components.lock.json,Go/npm 库版本由消费方语言锁决定。按需源码位于被忽略的 .workspace/。
|
||||
- 部署:非容器;Releases 仓从本仓固定提交导出版本。
|
||||
|
||||
## 方向与规划入口
|
||||
|
||||
长期方向、目标及跨任务规划从过程仓的 [项目方向与规划索引](modules/documents/docs/proposals/INDEX.md)
|
||||
查找主记录、里程碑和关联任务;原始正文沿用 [方案与规划目录](modules/documents/docs/proposals/)。
|
||||
长期方向、目标及跨任务规划从过程仓的 [项目方向与规划索引](.workspace/Agent-System-Documents/docs/proposals/INDEX.md)
|
||||
查找主记录、里程碑和关联任务;原始正文沿用 [方案与规划目录](.workspace/Agent-System-Documents/docs/proposals/)。
|
||||
确立或调整具体方向时,在本入口维护其主记录链接;正文、确认依据和进展仍以该主记录为准。
|
||||
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
|
||||
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
|
||||
|
||||
- [用户、职位角色与分层权限统一方案 r1](modules/documents/docs/proposals/2026-10-13-identity-role-permission-plan.md):用户已批准,首批仅 I1(U1–U2);
|
||||
[权限合同](modules/documents/docs/proposals/2026-10-13-iam-i1-permission-contract.md) 冻结边界,
|
||||
[I1 实施候选与评审记录](modules/documents/docs/proposals/2026-10-13-iam-i1-review.md) 维护实际结果与剩余限制;后续单元须另行授权。
|
||||
- [版本化依赖与按需源码工作区改造任务](.task/feature-dependency-workspace.md):
|
||||
已批准方案和实施计划由固定 Documents 的 `docs/proposals/2026-10-13-versioned-dependencies-and-workspaces.md`
|
||||
与对应 `-implementation-plan.md` 保存。用户授权重构与自检连续推进,特别要求在途分支平稳过渡;
|
||||
实施进展和命令证据见任务卡,正式验收、共享部署及发布仍按原审批边界。
|
||||
[项目依赖与过渡规则](policy/dependencies.md) 是具体工作入口。
|
||||
|
||||
- [快慢通道展示与动态工作摘要方案 r1.1](modules/documents/docs/proposals/2026-10-12-fast-slow-channel-display.md):
|
||||
- [用户、职位角色与分层权限统一方案 r1](.workspace/Agent-System-Documents/docs/proposals/2026-10-13-identity-role-permission-plan.md):用户已批准,首批仅 I1(U1–U2);
|
||||
[权限合同](.workspace/Agent-System-Documents/docs/proposals/2026-10-13-iam-i1-permission-contract.md) 冻结边界,
|
||||
[I1 实施候选与评审记录](.workspace/Agent-System-Documents/docs/proposals/2026-10-13-iam-i1-review.md) 维护实际结果与剩余限制;后续单元须另行授权。
|
||||
|
||||
- [快慢通道展示与动态工作摘要方案 r1.1](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-fast-slow-channel-display.md):
|
||||
对话即时答复、后台动态发现与来源、四层阅读、真实状态、并发/恢复及 U1–U4;
|
||||
2026-10-12 方案设计已批准(阶段 1–4),首批仅 U1;
|
||||
[U1 实施计划](modules/documents/docs/proposals/2026-10-12-fast-slow-channel-display-u1-plan.md) 待阶段 5 批准后启动业务代码;
|
||||
[U1 实施计划](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-fast-slow-channel-display-u1-plan.md) 待阶段 5 批准后启动业务代码;
|
||||
业务实施、模型实验、运行验收与部署 NOT_RUN。
|
||||
|
||||
新增方向:[Generative UI 调研与插件方案 r2](modules/documents/docs/proposals/2026-10-10-generative-ui-plugin.md)。
|
||||
新增方向:[Generative UI 调研与插件方案 r2](.workspace/Agent-System-Documents/docs/proposals/2026-10-10-generative-ui-plugin.md)。
|
||||
2026-10-11 用户裁定首试点(对话内分析页)、可信编译插件交付与试验参数;U1 受控试验当日完成
|
||||
(结构分化 6/6、生成走工具参数通道、首期自研 renderer,详见方案「U1 结果与 r2 修订」);
|
||||
U2 起实施与部署分别评审。
|
||||
|
||||
- [多人协作存储与分布式演进方案](modules/documents/docs/proposals/2026-10-11-collaboration-storage-and-distribution.md):
|
||||
- [多人协作存储与分布式演进方案](.workspace/Agent-System-Documents/docs/proposals/2026-10-11-collaboration-storage-and-distribution.md):
|
||||
10–100 人、多项目、多会话并行;PostgreSQL 团队档、迁移恢复与多实例执行。方案 r1 已批准
|
||||
(2026-10-11,用户指示「按方案开始改造」),实施自首选增量 U1+U2 启动。
|
||||
|
||||
- [RDM 两树一网集成、完善与追溯审计展示方案](modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md):
|
||||
- [RDM 两树一网集成、完善与追溯审计展示方案](.workspace/Agent-System-Documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md):
|
||||
每项目一套笔记树、文档树与追溯网;项目绑定与读取授权闭环、原子事件历史、统一审计读服务
|
||||
与四入口工作区。方案 r1 已获用户评审通过(2026-10-11,五阶段一并,KTD4 双层管理员裁定);
|
||||
U1–U5 全部实施合入(RDM PR #195/#197/#201/#213;Dev PR #368,2026-10-11;超管槽待基座能力
|
||||
接线,申请见 RDM 仓 docs/proposals/2026-10-11-u1-base-extension-requests.md);
|
||||
U6 团队存储独立后续;运行验收属部署环境。
|
||||
|
||||
- [RDM 模板与两树一网对话文档生成方案 r1.1](modules/documents/docs/proposals/2026-10-11-rdm-template-dialog-generation.md):69 类历史资产盘点、模板版本与章节实例化、持久提案、文档级审签及 DOCX;待用户评审,业务实施与运行验收 NOT_RUN。 首批项目建议书/竞品分析、随后扩展全部文档已确认(2026-10-11),其余方案与实施仍待评审。
|
||||
- [RDM 模板与两树一网对话文档生成方案 r1.1](.workspace/Agent-System-Documents/docs/proposals/2026-10-11-rdm-template-dialog-generation.md):69 类历史资产盘点、模板版本与章节实例化、持久提案、文档级审签及 DOCX;待用户评审,业务实施与运行验收 NOT_RUN。 首批项目建议书/竞品分析、随后扩展全部文档已确认(2026-10-11),其余方案与实施仍待评审。
|
||||
|
||||
- [RDM 最终目标核对与完整交付方案 r1](modules/documents/docs/proposals/2026-10-11-rdm-final-target-completion.md):系统级项目、上限内权限覆盖、私域协作、全部文档三权与完整业务链;34 UR/68 SR/10 F 逐条映射;方案已随 #340 合入(2026-10-11),U1 需求基线核对经 RDM #199/#200 落地,U2 起未启动。
|
||||
- [RDM 最终目标核对与完整交付方案 r1](.workspace/Agent-System-Documents/docs/proposals/2026-10-11-rdm-final-target-completion.md):系统级项目、上限内权限覆盖、私域协作、全部文档三权与完整业务链;34 UR/68 SR/10 F 逐条映射;方案已随 #340 合入(2026-10-11),U1 需求基线核对经 RDM #199/#200 落地,U2 起未启动。
|
||||
|
||||
- [服务重启与全量数据恢复方案 r3.1](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
- [服务重启与全量数据恢复方案 r3.1](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
svc 启停、异常进程/主机退出后的依赖、配置/数据身份、任务/HITL/副作用、文件与组合备份恢复;
|
||||
r2 已批准。r3.1 增补 D1 实例身份收尾、D2 可回滚切换、D3 真实组合门禁,并记录 2026-10-12 rdmcand 数据归属:会话不在 dev 库的 rdmcand 窗口内,领域事件为空,不跨实例搬迁。
|
||||
D1 与 D2 已合入 Backend #184、RDM #291、Dev #485。D3 的 `make test-lifecycle` 跑真实组合用例;登录仍是 NOT_RUN,依赖账户方案 U7/U5,不在本轮公共化账户 CLI。r3 其余未批条款仍待人工评审。
|
||||
|
||||
- [svc 统一部署与一次性登录 Key 方案 r1](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
- [svc 统一部署与一次性登录 Key 方案 r1](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;一次性 Key 仅兑换设密受限会话;
|
||||
2026-10-12 已批准;U1/U4 实施启动中;规格见 `docs/svc-deploy/`。
|
||||
|
||||
@@ -55,19 +61,25 @@ U2 起实施与部署分别评审。
|
||||
|
||||
## 验证
|
||||
|
||||
规范修订:[最终验证与合入调度 r1](policy/merge-queue.md) 及[U1–U6 实施计划](modules/documents/docs/proposals/2026-10-13-merge-queue-implementation-plan.md)已批准。通用规范与计划已分别经 PR 合入,本候选组合固定实际合入 SHA,见[采纳记录](policy/adoption.md)。同步幂等化、显式局部恢复和源码外证据基础已实施;Dev 最新组合验收及平台调度模式启用仍按各自门禁办理。
|
||||
规范修订:[最终验证与合入调度 r1](policy/merge-queue.md) 及[U1–U6 实施计划](.workspace/Agent-System-Documents/docs/proposals/2026-10-13-merge-queue-implementation-plan.md)已批准。通用规范与计划已分别经 PR 合入,本候选组合固定实际合入 SHA,见[采纳记录](policy/adoption.md)。同步幂等化、显式局部恢复和源码外证据基础已实施;Dev 最新组合验收及平台调度模式启用仍按各自门禁办理。
|
||||
|
||||
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
|
||||
测试。实际目标以根 Makefile 为准。`make test-governance` 可独立检查开发门禁,使用临时
|
||||
真实仓库,不访问生产数据;操作与证据要求见 [开发门禁](policy/governance.md)。
|
||||
`make setup` 启用钩子。新 clone 无需递归克隆;启动门禁提供固定规则,
|
||||
`make workspace-prepare` 准备固定应用与资料。`make component-edit NAME=Agent-Native`
|
||||
按消费方实际版本创建功能仓编辑分支并生成本地 go.work;解除用 `make component-unlink`。
|
||||
|
||||
`make build` 在 backend 模块内编译,replace 指向该模块自己的 `deps/`;
|
||||
`scripts/check-pins.sh` 核对这些 gitlink 与 Dev 顶层指针,`make test-worktree` 另核对
|
||||
真正被测的组件工作树。两者分别验证固定组合与实际检出,不能相互替代。
|
||||
`make test` / `make build` 在隔离目录使用已提交的清单、应用源码、语言依赖和前端包,
|
||||
关闭 Go 开发覆盖,拒绝本地 replace/link,构建 frontend dist 后装配 backend embed。
|
||||
输出 bin/resolved-manifest.json,含实际来源、解析版本及资产摘要;工作区本地覆盖不会改变正式候选。
|
||||
|
||||
未加 go.work:工作区最小版本选择会抬高 backend 单独构建使用的部分 `golang.org/x` 版本。
|
||||
发布消费方须从固定 Dev 提交递归物化后运行 `make build`,再核验制品与实际运行状态。
|
||||
`make test-governance` 在临时真实仓库验证启动、任务同步、证据、迁移保留和拒绝路径;
|
||||
`make test-pins` 在新布局核验固定来源,历史提交仍执行旧 gitlink 检查。
|
||||
公开边界与架构检查使用组合器的固定来源映射,详细操作见 [开发门禁](policy/governance.md)。
|
||||
|
||||
- [渐进工作台界面重设计 r0.2](modules/documents/docs/proposals/2026-10-12-work-companion-ui-design.md):用户已授权连续实施;[计划 r1](modules/documents/docs/proposals/2026-10-12-work-companion-ui-plan.md)覆盖任务摘要、对话/输入、窄屏和真实状态投影;进展见对应任务卡。
|
||||
RDM 从自身 go.mod 消费远端 Backend,`COMPOSITION_ROOT=<已提交 Dev 根目录> make product`
|
||||
隔离 vendor 并注入固定前端资产,随产物记录其实际 Backend 版本;不改模块缓存。
|
||||
`product-smoke` 的临时运行验证与正式验收、共享部署分开记录。真实 PG/Redis、生命周期等
|
||||
专项门禁保持原环境要求;未提供环境不会由本地组合测试冒充通过。
|
||||
|
||||
- [系统项目、成员工作入口与私域协作闭环 r1](modules/documents/docs/proposals/2026-10-13-system-project-membership.md):2026-10-13 用户批准阶段 1–9 连续推进;统一全局项目身份与名称、成员个人会话、每项目两树一网和私域权限;当前组合验证/CI/tag 消费推进中,部署与线上修复未授权。
|
||||
- [渐进工作台界面重设计 r0.2](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-work-companion-ui-design.md):用户已授权连续实施;[计划 r1](.workspace/Agent-System-Documents/docs/proposals/2026-10-12-work-companion-ui-plan.md)覆盖任务摘要、对话/输入、窄屏和真实状态投影;进展见对应任务卡。
|
||||
|
||||
- [系统项目、成员工作入口与私域协作闭环 r1](.workspace/Agent-System-Documents/docs/proposals/2026-10-13-system-project-membership.md):2026-10-13 用户批准阶段 1–9 连续推进;统一全局项目身份与名称、成员个人会话、每项目两树一网和私域权限;当前组合验证/CI/tag 消费推进中,部署与线上修复未授权。
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
# Agent-System-Dev
|
||||
|
||||
Agent-System 的开发集成仓(父仓):以 submodule 固定全部模块组合,承载跨仓构建、测试编排与原生(非容器)开发环境。**非分发单元**——发布物在 Agent-System-Releases。
|
||||
Agent-System 的开发集成入口,组织独立功能仓、固定依赖和跨仓构建。只需手动 clone 本仓;应用与资料由源码清单准备,Go/npm 库由消费方下载。发布物仍在 Agent-System-Releases,默认采用原生单二进制。
|
||||
|
||||
## 组合结构
|
||||
|
||||
```
|
||||
modules/core Agent-System-Core 核心契约(领域模型/事件协议/插件 API/设置与 UI 词汇)
|
||||
modules/backend Agent-System-Backend 后端单二进制(内嵌前端与嵌入式 SQLite)
|
||||
modules/frontend Agent-System-Frontend 前端(React 19 + Vite + TS)
|
||||
modules/documents Agent-System-Documents 系统文档与过程记录
|
||||
modules/ui Agent-System-UI UI 组件库(词汇渲染器/设计令牌/收件箱卡片工作区组件)
|
||||
deps/development-guidelines 开发规范(gitlink 固定版本)
|
||||
deps/agent-native deps/agent-squad deps/agent-intent 生态执行/编排/意图
|
||||
deps/agent-command Agent-Command 命令总线基础库(spec/注册表/危险分级/审计信封)
|
||||
deps/error-code-system deps/logging-system 基础设施
|
||||
plugins/ 示例插件与插件开发参考(P4 起填充)
|
||||
```
|
||||
|
||||
父仓 gitlink 是各组件源码版本的权威;构建、审核与发布固定提交,不执行 `submodule update --remote`。
|
||||
|
||||
## 常用命令
|
||||
## 开始开发
|
||||
|
||||
```bash
|
||||
make sync # 物化全部 submodule(递归)
|
||||
make build # 构建单二进制 bin/agent-system
|
||||
make test # 全量测试
|
||||
make dev # 开发模式说明(前后端分离热更新)
|
||||
make setup
|
||||
RECEIPT=/tmp/my-task-precheck.json CMD="make precheck" make evidence
|
||||
make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
|
||||
# 在返回的任务 worktree 中执行
|
||||
make workspace-prepare
|
||||
make component-edit NAME=Agent-Native BRANCH=feature/my-native-change
|
||||
make workspace-status
|
||||
make component-unlink NAME=Agent-Native
|
||||
```
|
||||
|
||||
## 约束(全局)
|
||||
按需源码是 .workspace 下的普通独立 Git 仓。功能修改提交到所属仓;消费方通过依赖 PR 采用新版本,不需要为了保存源码提交而同步推进全部仓库。
|
||||
|
||||
- 非容器化部署:默认单二进制,必要时拆分多个原生二进制;不发布容器镜像。
|
||||
- 依赖方向:frontend→core←backend;插件只依赖 core;生态仓依赖纪律沿用各仓既有约定。
|
||||
- 开发规范:`deps/development-guidelines`(见各仓 AGENTS.md)。
|
||||
`make test` 与 `make build` 使用已提交的固定组合,在隔离目录关闭开发覆盖,生成 bin/agent-system、storage-migrate 与 resolved-manifest.json。源码身份见 components.json;应用/规范/资料提交见 components.lock.json;库的实际版本见消费方 go.mod/go.sum、package-lock.json。
|
||||
|
||||
## 文档
|
||||
## 规范与过渡
|
||||
|
||||
设计方案:`modules/documents/docs/proposals/2026-09-29-system-skeleton-design-and-plan.md`(含 D1–D16 决策记录)。
|
||||
本机服务管理(后端/前端/数据库部署、生命周期、备份克隆):`docs/svc.md`(`scripts/svc.sh`)。
|
||||
- [项目事实与验证入口](PROJECT.md)
|
||||
- [版本依赖、按需编辑和旧分支过渡](policy/dependencies.md)
|
||||
- [阶段交付](policy/delivery.md)与[开发门禁](policy/governance.md)
|
||||
|
||||
旧提交继续采用自己的 submodule 模式;旧任务同步新主干前由 owner 保存现场并显式过渡。迁移工具默认只报告,不改动其他活动工作区。规范来自固定 Development-Guidelines,过程文档来自固定 Agent-System-Documents;新项目通过独立采纳采用通用规则。
|
||||
|
||||
本机服务管理见 [docs/svc.md](docs/svc.md)。依赖改造不授权共享服务部署、数据库操作或发布。
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "Logging-System",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git",
|
||||
"path": ".workspace/Logging-System",
|
||||
"legacy_path": "deps/logging-system",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Logging-System.git",
|
||||
"legacy": "logging-system",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Error-Code-System",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git",
|
||||
"path": ".workspace/Error-Code-System",
|
||||
"legacy_path": "deps/error-code-system",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Error-Code-System.git",
|
||||
"legacy": "error-code-system",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-System-Core",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git",
|
||||
"path": ".workspace/Agent-System-Core",
|
||||
"legacy_path": "modules/core",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git",
|
||||
"legacy": "agent-system-core",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-System-UI",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git",
|
||||
"path": ".workspace/Agent-System-UI",
|
||||
"legacy_path": "modules/ui",
|
||||
"role": "npm-library",
|
||||
"modules": [],
|
||||
"target": "main",
|
||||
"package_name": "@agent-system/ui"
|
||||
},
|
||||
{
|
||||
"id": "Agent-System-Frontend",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Frontend.git",
|
||||
"path": ".workspace/Agent-System-Frontend",
|
||||
"legacy_path": "modules/frontend",
|
||||
"role": "application",
|
||||
"modules": [],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-Native",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git",
|
||||
"path": ".workspace/Agent-Native",
|
||||
"legacy_path": "deps/agent-native",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git",
|
||||
"legacy": "agent-native",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-Intent",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git",
|
||||
"path": ".workspace/Agent-Intent",
|
||||
"legacy_path": "deps/agent-intent",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Intent.git",
|
||||
"legacy": "agent-intent",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-Command",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git",
|
||||
"path": ".workspace/Agent-Command",
|
||||
"legacy_path": "deps/agent-command",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Command.git",
|
||||
"legacy": "agent-command",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-Squad",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git",
|
||||
"path": ".workspace/Agent-Squad",
|
||||
"legacy_path": "deps/agent-squad",
|
||||
"role": "go-library",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git",
|
||||
"legacy": "agent-squad",
|
||||
"directory": "."
|
||||
},
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/adapters/native",
|
||||
"legacy": "agent-squad/adapters/native",
|
||||
"directory": "adapters/native"
|
||||
},
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/adapters/intent",
|
||||
"legacy": "agent-squad/adapters/intent",
|
||||
"directory": "adapters/intent"
|
||||
},
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/adapters/sqlite",
|
||||
"legacy": "agent-squad/adapters/sqlite",
|
||||
"directory": "adapters/sqlite"
|
||||
},
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/cmd/agent-squad",
|
||||
"legacy": "agent-squad/cmd/agent-squad",
|
||||
"directory": "cmd/agent-squad"
|
||||
},
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/adapters/a2a",
|
||||
"legacy": "agent-squad/adapters/a2a",
|
||||
"directory": "adapters/a2a"
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-System-Backend",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git",
|
||||
"path": ".workspace/Agent-System-Backend",
|
||||
"legacy_path": "modules/backend",
|
||||
"role": "application",
|
||||
"modules": [
|
||||
{
|
||||
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git",
|
||||
"legacy": "agent-system-backend",
|
||||
"directory": "."
|
||||
}
|
||||
],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Development-Guidelines",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git",
|
||||
"path": ".workspace/Development-Guidelines",
|
||||
"legacy_path": "deps/development-guidelines",
|
||||
"role": "guidelines",
|
||||
"modules": [],
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"id": "Agent-System-Documents",
|
||||
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Documents.git",
|
||||
"path": ".workspace/Agent-System-Documents",
|
||||
"legacy_path": "modules/documents",
|
||||
"role": "documentation",
|
||||
"modules": [],
|
||||
"target": "main"
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"go_private": [
|
||||
"git-ssh.u1680832.nyat.app/weichen"
|
||||
],
|
||||
"git_rewrites": [
|
||||
{
|
||||
"from": "https://git-ssh.u1680832.nyat.app/weichen/",
|
||||
"to": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"components": {
|
||||
"Agent-System-Frontend": {
|
||||
"commit": "74511db9a6fb12e17954cb0c3f8e8846a6ecf831"
|
||||
},
|
||||
"Agent-System-Backend": {
|
||||
"commit": "48649a37ee92373c639a384a2c5a9dfaa2ec4bbb"
|
||||
},
|
||||
"Development-Guidelines": {
|
||||
"commit": "db3824bfdbfdcad91d0941495719f04dbeaca053"
|
||||
},
|
||||
"Agent-System-Documents": {
|
||||
"commit": "1de7d17110efaa5ed2ed30a44d415f28987f89f3"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-1
Submodule deps/agent-command deleted from 4f583ca3ee
Vendored
-1
Submodule deps/agent-intent deleted from 343b1e6d9a
Vendored
-1
Submodule deps/agent-native deleted from c348441691
Vendored
-1
Submodule deps/agent-squad deleted from 490b0b2dfa
Vendored
-1
Submodule deps/development-guidelines deleted from 2f5b19872b
Vendored
-1
Submodule deps/error-code-system deleted from a3ee67bba2
Vendored
-1
Submodule deps/logging-system deleted from 49b5d8be60
@@ -1,9 +1,5 @@
|
||||
# 下游薄切片
|
||||
# 独立下游扩展示例
|
||||
|
||||
本目录模拟独立仓库 `Agent-System-Plugin-RDM`。它只 import `agent-system-backend/pkg/extension`,不改基座。
|
||||
本目录模拟独立功能仓,通过 Backend 的公开 pkg/extension 注册扩展。go.mod/go.sum 固定远端依赖,普通 clone 即可下载和测试,不要求相邻源码目录或复制 replace。需要完整产品时空白导入公开 pkg/extension/link,并通过 Dev 的固定组合器装配前端资产,具体见 policy/dependencies.md。
|
||||
|
||||
把本目录搬到新仓库时,由 owner 创建 Git 仓库。`replace` 改成基座检出路径或已发布版本。需要拉起完整二进制时再空白导入 `agent-system-backend/pkg/extension/link`,并复制基座 `go.mod` 的 replace(下游模块不会继承它们)。
|
||||
|
||||
```
|
||||
go test .
|
||||
```
|
||||
版本采用由 Go 生成的提交版本;更新消费方依赖后独立测试,再对实际固定组合审阅。本地源码联调用工作区覆盖,正式构建使用 GOWORK=off。
|
||||
|
||||
@@ -3,13 +3,6 @@ module example.com/agent-system-plugin-rdm
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-system-backend v0.0.0
|
||||
agent-system-core v0.0.0
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git v0.4.1-0.20261013154716-f1302ed5b395
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git v0.4.1-0.20261013151137-f32d4317846f
|
||||
)
|
||||
|
||||
// 声明式路径(仅 pkg/extension 根包)只需下面两条 replace。
|
||||
// 空白导入 pkg/extension/link 拉起完整后端时,须复制基座 go.mod 的
|
||||
// 全部 8 条 deps replace——见 README 与 extension-compat-policy §4。
|
||||
replace agent-system-backend => ../../modules/backend
|
||||
|
||||
replace agent-system-core => ../../modules/backend/deps/agent-system-core
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git v0.4.1-0.20261013154716-f1302ed5b395 h1:Ij/SYy1bNdlteFZeCSBFUEEFbhwd3srpAOwSIjhbpsI=
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git v0.4.1-0.20261013154716-f1302ed5b395/go.mod h1:ipD03jF1IpqWwU7m+verjzgnOYVc7Tau9xz9mJKKn8s=
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git v0.4.1-0.20261013151137-f32d4317846f h1:39RW0R6zmtXLNbLQaqFCmtZ6zjEs2lhDLb9YLhudIiM=
|
||||
git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git v0.4.1-0.20261013151137-f32d4317846f/go.mod h1:T3FU9NsngaxF/1C2ISi02fV7+aqNepOEvX0QhswA70Y=
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/pkg/extension"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/pkg/extension"
|
||||
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
|
||||
)
|
||||
|
||||
func TestCompiledSkillRegistration(t *testing.T) {
|
||||
@@ -24,6 +24,7 @@ func TestCompiledSkillRegistration(t *testing.T) {
|
||||
|
||||
// fakeRegistry 记录 register() 的登记动作,不拉起基座。
|
||||
type fakeRegistry struct {
|
||||
extension.Registry // Unused capabilities fail if this example starts calling them.
|
||||
skills []string
|
||||
commands []string
|
||||
handlers int
|
||||
@@ -64,7 +65,10 @@ func TestRegisterBindsPingHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
// fakeHost 是下游测试里的最小句柄替身:只实现 rdm.ping 用到的能力。
|
||||
type fakeHost struct{ owner string }
|
||||
type fakeHost struct {
|
||||
extension.Host
|
||||
owner string
|
||||
}
|
||||
|
||||
func (f fakeHost) PluginID() string { return "rdm" }
|
||||
func (f fakeHost) Owner() string { return f.owner }
|
||||
|
||||
Submodule modules/backend deleted from 8e80e2618e
-1
Submodule modules/core deleted from 5773e74086
Submodule modules/documents deleted from 7bd4e17827
Submodule modules/frontend deleted from 5313cfad4e
-1
Submodule modules/ui deleted from 2c4322ef56
+17
-7
@@ -2,12 +2,12 @@
|
||||
|
||||
- 项目/负责人:Agent-System 组合 / weichen
|
||||
- 状态:active(2026-09-30 经 PR weichen/Development-Guidelines#1 采纳 draft.7;机制经 Dev PR#1 合入 ef89fc8)
|
||||
- 规范位置:deps/development-guidelines(gitlink 固定版本)
|
||||
- 过程文档位置:modules/documents(docs/records/)
|
||||
- 版本权威:父仓 gitlink
|
||||
- 规范位置:.workspace/Development-Guidelines(components.lock.json 固定源码;首次启动可读仓外固定缓存)
|
||||
- 过程文档位置:.workspace/Agent-System-Documents(docs/records/)
|
||||
- 版本权威:components.lock.json;规则内容版本由该提交的 VERSION 标识,历史提交保留原 gitlink
|
||||
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1
|
||||
- 本次升级候选:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本候选父仓固定该源码版本。基础门禁继续执行,合入调度流程单独记 approved/pending-activation;Dev 组合验收、平台正负例和全部写入者覆盖完成前不切换交付模式。
|
||||
- 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
|
||||
- 合入调度采纳基线:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本次依赖候选的固定规范包含该提交;最终来源仅由 components.lock.json 决定。基础门禁继续执行,合入调度流程单独记 approved/pending-activation;Dev 组合验收、平台正负例和全部写入者覆盖完成前不切换交付模式。
|
||||
- 合入调度过程计划来源:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
|
||||
|
||||
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
|
||||
|---|---|---|
|
||||
@@ -16,7 +16,7 @@
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地命令)**:分支、现场、组件检查;task-sync 实际合并时记录,同基线只核验。最终平台与阶段授权另验 |
|
||||
| 提交格式(主题行/四段 body) | 全部提交 | 主题行 enforced(local),body manual;任务工具生成四段 body,历史 PR 仅作旧版本证据 |
|
||||
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
|
||||
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
| standards/multi-repo.md | 跨仓/版本依赖 | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 |
|
||||
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | manual(人工/Agent 执行):前序人工决策与有范围的连续推进授权;智能体完成 Review、冲突处置和当前 HEAD 的 CI 核验后合入;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
|
||||
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
|
||||
@@ -32,4 +32,14 @@
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 保留需求、方案、实施与验收的人工节点;代码 Review 与 PR 合入交由智能体,冲突解决且当前 HEAD 的 CI 通过后合入;小任务简化篇幅,阶段内可迭代 | 2026-10-12 用户明确要求智能体 Review 后合入,合入前解决冲突且 CI 通过;其他人工决策与高后果双钥匙边界保留 | weichen;Review、CI 或平台准入条件变化时复查 |
|
||||
|
||||
- 历史记录(2026-09-30)报告 11 个组合仓 main 禁直推、拒绝陈旧候选;当前平台能力须独立重验,本地脚本测试不替代远端证据。
|
||||
- 规范源码以 gitlink 及其 VERSION 为准,流程启用以本采纳状态及项目条件为准;固定源码版本不代替实际能力证明。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护或发布授权。
|
||||
- 规范源码以 components.lock.json 固定内容及其 VERSION 为准;流程启用以本采纳状态及项目条件为准,固定源码不代替实际能力证明。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护、发布授权或通用库版本。
|
||||
|
||||
## 2026-10-13 版本依赖采纳增量
|
||||
|
||||
用户批准版本依赖与按需工作区方案 r1,并授权重构与自检连续推进、保护在途分支。
|
||||
固定 draft.12 内容延续已固定上游规则;本次采纳将 Go/npm 库的版本权威交给消费方,
|
||||
应用、规范与资料交给源码锁。具体组件、命令、旧任务同步及回退见 [依赖规则](dependencies.md)。
|
||||
|
||||
启动取源、组件编辑/解除、固定组合与旧现场保留由本地工具和真实仓库回归验证,证据为 SELF。
|
||||
平台 runner 隔离、token scope、受信工作流来源、分支保护和精确 HEAD CI 仍须 GATE/REPLAY 核验;
|
||||
本文件不宣称平台合并队列或发布能力已就位。其他项目须独立采纳,不因本轮升级自动改变规则。
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
# it queries store metadata and does not depend on either transport/composition root.
|
||||
# Approved membership proposal r1 adds app/server -> projectscope -> store.
|
||||
backend:
|
||||
root: modules/backend/internal
|
||||
import_prefix: agent-system-backend/internal/
|
||||
root: .workspace/Agent-System-Backend/internal
|
||||
import_prefix: git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/
|
||||
frontend:
|
||||
root: modules/frontend/src
|
||||
root: .workspace/Agent-System-Frontend/src
|
||||
groups:
|
||||
- name: app
|
||||
dir: .
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# 需求、试验与迭代交付
|
||||
|
||||
适用:本项目的需求、修复、规范修改和其他交付任务。依据固定版本的
|
||||
[通用交付流程](../deps/development-guidelines/workflows/delivery.md),本项目收紧为
|
||||
[通用交付流程](../.workspace/Development-Guidelines/workflows/delivery.md),本项目收紧为
|
||||
**需求、方案、实施与验收默认逐阶段人工评审;代码 Review 与 PR 合入由智能体完成;
|
||||
阶段内迭代,按影响回退,按增量交付**。
|
||||
本文件是阶段与评审的权威表述;启动、同步、命令证据和回收操作见
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 版本依赖、源码编辑与分支过渡
|
||||
|
||||
适用:组件版本升级、功能仓编辑、新 clone、旧任务同步和源码候选构建。通用原则见固定 Development-Guidelines 的 multi-repo/collaboration/adoption;本文件只规定项目映射、命令和过渡入口。
|
||||
|
||||
## 版本权威
|
||||
|
||||
- `components.json`:逻辑组件、批准的仓库、职责、模块/包身份与消费路径,不存凭据。
|
||||
- `components.lock.json`:应用、规范和过程资料的完整提交;Go/npm 库不另设可竞争的源码锁。
|
||||
- Go:消费方 go.mod 的实际模块图决定版本,go.sum 校验内容。模块路径为 `git-ssh.u1680832.nyat.app/weichen/<Repo>.git`,子模块追加目录;端口仅属于传输配置。
|
||||
- npm:消费方 package-lock 固定 Git 提交及包完整性信息;契约由 Core 单一 TS 源码产生,UI 与 Frontend 消费 `@agent-system/contracts`,Frontend 消费 `@agent-system/ui`。
|
||||
- 正式产物:隔离固定源码装配 frontend dist/backend embed,自动导出解析清单与摘要。RDM 使用隔离 vendor 构建注入固定前端资产,不改 Go module cache,不写本地路径 replace。
|
||||
|
||||
采用的提交在功能仓合入后仍须从批准来源获取。合入方式须保留已锁定的提交对象;回收功能分支前核对这些提交已在主干或受保护归档中可达,不用 squash 或删除唯一引用使现有语言版本失效。发布 tag 与归档删除仍保留原双钥匙边界。
|
||||
|
||||
新模式是否存在以该提交的 `components.json`/锁为准。历史提交没有这两份文件时继续按照其旧 gitlinks 与脚本构建;不能套用最新来源改变历史候选。
|
||||
|
||||
## 从 Dev 按需编辑
|
||||
|
||||
1. 从普通 clone 开始,按项目启动门禁执行 precheck/task-start;新布局由固定清单准备应用和规范,普通库按语言依赖下载。
|
||||
2. `make component-edit NAME=Agent-Native` 取得消费方实际使用的源版本,创建所属功能仓的独立编辑分支并接入本地 go.work。
|
||||
3. 在返回的目录修改、局部验证、提交及创建功能仓 PR;Dev 不提交复制的功能仓源码。
|
||||
4. `make component-unlink NAME=Agent-Native` 解除开发覆盖,保留源码和分支;采用新版本由消费方依赖 PR 决定。
|
||||
|
||||
`make test-backend LOCAL_OVERLAY=1` 可明确使用已登记 Go 开发覆盖做局部检查,默认局部入口 GOWORK=off;结果仍为开发对象,不能代替固定组合。
|
||||
|
||||
`make workspace-status` 是只读状态;`make workspace-prepare` 准备固定来源。正式 build/test 与局部开发验证分开,不能把存在 go.work/npm 链接的工作树当发布候选。Go 正式消费 `GOWORK=off`,候选代码执行环境没有 checkout token;源码获取和执行阶段分离。
|
||||
|
||||
依赖锁变更后,owner 可用 `make workspace-prepare NAME=Agent-System-Backend REFRESH_FIXED=1`
|
||||
显式刷新已登记、干净且处于原基线的 detached 检出,旧提交仍由本地保存引用保留。编辑分支、
|
||||
未保存或 ignored 唯一文件、Git 操作和共享归属均拒绝刷新;不会切换已编辑组件。
|
||||
|
||||
UI 源码编辑从 Frontend 锁中解析实际 Git 提交;其包构建是显式命令。当前工作区工具的源码编辑/版本解析与 Go 覆盖分别验证,不能仅凭 UI 仓被检出就声称 npm 开发链接已生效。
|
||||
|
||||
## 在途分支与迁移
|
||||
|
||||
本轮切换不 checkout/reset/stash/clean 其他活动工作区,不批量 rebase,不重写旧提交和 tag,不删除旧物理目录或 Git 管理数据。
|
||||
|
||||
- 未同步的旧任务继续用旧提交、旧脚本和固定 gitlinks;新功能分支从最新权威 main 起步。
|
||||
- 旧任务纳入新 main 前先核对父仓与组件状态;有当前写者、Git 锁、未提交或唯一文件时由 owner 保存并安排安全同步点。文本和 API 冲突分别处理,不把旧 pin/锁批量覆盖新组合。
|
||||
- `make workspace-transition` 默认只报告迁移计划;`NAME=<组件>` 可限定范围。确认归属后显式 `ARGS=--apply` 才在新工作区准备独立源码。原仓、refs、reflog、暂存/工作树/未跟踪和 ignored 唯一文件保留。
|
||||
- 已登记嵌套组件独立保存;相同内容的重复来源可去重,但引用仍按来源保存。未知归属、冲突的重复组件、外借对象库、逃逸 symlink 或目标已有他人现场拒绝并报告,不能称迁移成功。
|
||||
- 组件分支仍是所属仓的代码;来源/命名变化后按已合入接口进行正常合并和审阅。原分支或数据不被自动删除;不能将“已复制保存”解释为语义迁移已完成。
|
||||
|
||||
迁移后的库版本以消费方实际解析为准,主干前进时不隐式切换已编辑组件。旧组件目录的 .git common-dir 可能供外部 worktree 使用;task-finish 必须核对依赖及唯一成果,删除被拒就保留现场。
|
||||
|
||||
## 验证与回退
|
||||
|
||||
真实 Git/Go fixture 验证固定取源、编辑/解除、嵌套组件、dirty/index/ignored/ref 保留、冲突、锁和越界拒绝。新 clone 的固定产品构建、RDM 完整前端二进制、当前组合和历史组合分别重放;文档检查不能替代这些能力。
|
||||
|
||||
回退用普通 PR 恢复已批准依赖、源码锁和构建规则,并重建同一解析组合;不改历史、不推断业务数据库可以逆迁移。正式验收与合入仍按 policy/delivery.md,SELF 不提升为 GATE/REPLAY,发布与共享部署保留原授权边界。
|
||||
|
||||
## 后续项目采纳
|
||||
|
||||
通用规则只维护在 Development-Guidelines;新项目固定审阅过的规范来源并填写采纳模板,选择自己的仓库映射、读取目录、编辑命令和正式构建入口。现存项目不会因上游规则合入或新包可用而自动升级、自动移除 submodule 或自动改变审批权限。
|
||||
@@ -1,7 +1,7 @@
|
||||
# 开发门禁的执行与证据
|
||||
|
||||
本文件细化根 AGENTS.md 的启动、验证和回收规则。通用语义以固定的
|
||||
`deps/development-guidelines` 为准;门禁实现及回归用例随本仓版本管理。
|
||||
`components.lock.json` 固定的 Development-Guidelines 为准;门禁实现及回归用例随本仓版本管理。
|
||||
需求表、设计试验、迭代调整、阶段产物、人工决策及智能体 Review/合入按
|
||||
[迭代交付](delivery.md) 执行。任务卡分别记录人工批准、智能体审阅者及对象版本;
|
||||
本地 evidence 仅记录 SELF 命令,不能证明人工批准或提升为 CI/独立审阅证据。
|
||||
@@ -42,13 +42,15 @@ LOCAL_READY 只用于局部恢复;新任务入口始终校验严格 READY,
|
||||
|
||||
## 被测对象
|
||||
|
||||
`make task-sync` 在父仓合并后按固定 gitlink 物化未修改组件,并核对实际组件工作树。
|
||||
组件 feature 分支若与新指针不同,先保留其分支,交给 owner 显式同步并更新组合;
|
||||
不能用自动 checkout 丢弃组件分支现场。任何失败都保留现场并阻止同步成功声明。
|
||||
`make task-sync` 在父仓合并后按该提交的模式准备来源:新布局读取固定清单,
|
||||
历史布局读取原 gitlink。主干前进不会自动切换已编辑组件;来源变化、未保存修改或 Git 操作
|
||||
由 owner 先处理,任何失败保留现场并阻止同步成功声明。旧目录仍可能支撑其他 worktree,
|
||||
显式过渡与保存要求见 [依赖规则](dependencies.md)。
|
||||
|
||||
`make test-worktree` 核对递归子模块:未初始化、冲突、指针漂移及子仓未保存改动均拒绝。
|
||||
它是组合测试和构建的前置检查。父仓未提交改动可以局部测试,但证据必须记录实际对象状态;
|
||||
正式验收使用已提交的固定对象,不能仅写一个与工作树不同的 HEAD。
|
||||
`make test-worktree` 验证当前模式中的已登记来源与实际工作树;新布局的本地编辑状态可记录,
|
||||
但正式 `make test/build` 从已提交的固定源码与语言图隔离物化。evidence 同时记录忽略目录中
|
||||
已登记组件的 HEAD、分支、dirty/差异与唯一内容摘要,不能只用父仓 HEAD 代表开发组合。
|
||||
历史布局继续拒绝未初始化、冲突、指针漂移及未保存子仓改动。
|
||||
|
||||
`task-sync` 对同一基线重复调用只核验,保留 HEAD、索引和卡片;实际合并新目标才追加一次记录。
|
||||
输出完整 sync_base、记录完成后的 tested_head、checked_at 及 changed;changed 表示是否实际纳入新目标,组件仍须核对。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
状态:approved(r1 条文作为实施计划基线);执行模式:manual 受控试行;更新日期:2026-10-14(Asia/Shanghai);规范采纳负责人:weichen。实际启用时间、对象和窗口以调度记录 #518 为准,候选文件不代替当前平台准入。
|
||||
r1 条文来源提交为 913b214f2826a50b3dab65f79d9ebf83e84f2519;Documents 实施与测试计划 r1@ba4044d57e38d0887dd425d052abf26b20a28bf4 已于 2026-10-13 获用户「批准」。阶段结论与执行进展按 [迭代交付](delivery.md) 记录在任务主记录,候选固定后的结论保存在关联 PR 与源码外收据中;规范及计划批准不等于模式已启用。
|
||||
现行规则仍以根 AGENTS.md、[迭代交付](delivery.md)、[开发门禁](governance.md) 和已采纳 gitlink 为准。
|
||||
现行规则仍以根 AGENTS.md、[迭代交付](delivery.md)、[开发门禁](governance.md) 和已采纳的固定规范来源为准。
|
||||
本分支已实施 CLI 同步/恢复及源码外证据基础,自检依据见任务主记录及固定后记录;通用条文和实施计划已经各自 PR 合入,本候选组合固定实际合入版本。用户已于 2026-10-14 明确授权当前智能体接管 #489/#488/#486,完成平台验证、试行、实际合入与清理。平台所有者操作已实际回读,隔离正反例及独立重放已完成;本轮使用单一智能体协调的 manual 窗口,原 CI 触发和全部必需作业保持,未建设自动调度。
|
||||
|
||||
## 通用规范与项目职责
|
||||
@@ -10,10 +10,10 @@ r1 条文来源提交为 913b214f2826a50b3dab65f79d9ebf83e84f2519;Documents
|
||||
通用正文的唯一候选来源是 Development-Guidelines 的 `standards/merge-queue.md`,
|
||||
配套模板为该仓 `templates/merge-queue.md`,候选内容版本 `0.1.0-draft.11`。
|
||||
本仓仅细化目标、入口、阶段、预算和采纳条件,不复制通用调度正文。
|
||||
通用候选已经 [Guidelines PR #5](https://git-home.u1680832.nyat.app:30319/weichen/Development-Guidelines/pulls/5) 合入为 `2f5b19872be2a4597838a4131ad75d1be089ee5e`;本候选 `deps/development-guidelines` 已物化该固定版本及上述文件。Dev 已经 #519/#536 固定采纳并通过其精确 HEAD 的全部必需 CI。平台机制与手动写者覆盖的实际依据见 #518;受控试行的当前候选仍须独立组合测试、当前 HEAD CI 与平台准入,不把已采纳规范标成自动 enforced。
|
||||
通用候选已经 [Guidelines PR #5](https://git-home.u1680832.nyat.app:30319/weichen/Development-Guidelines/pulls/5) 合入为 `2f5b19872be2a4597838a4131ad75d1be089ee5e`;本候选 components.lock.json 固定的规范来源包含该版本及上述文件。Dev 已经 #519/#536 固定采纳并通过其精确 HEAD 的全部必需 CI。平台机制与手动写者覆盖的实际依据见 #518;受控试行的当前候选仍须独立组合测试、当前 HEAD CI 与平台准入,不把已采纳规范标成自动 enforced。
|
||||
|
||||
Dev 目标身份使用实际托管实例、仓库 ID 与 `refs/heads/main`;其他组件仓各有自己的目标队列。
|
||||
跨仓组合按任务中的依赖关系先交付组件,再固定父仓组合;保留其他任务已经合入的 gitlink 能力。
|
||||
跨仓组合按任务中的依赖关系先交付组件,再固定父仓组合;保留其他任务已经合入的依赖和治理能力。
|
||||
队列仅安排工作,受保护分支最终准入继续由 Gitea 或等价受信入口负责。
|
||||
|
||||
## 项目调度记录
|
||||
@@ -113,7 +113,7 @@ SYNC_REQUIRED 不整体降级为 READY;local 与 delivery 用独立结果和
|
||||
2. 在通用仓完成候选修订 Review 与其文档验证,按授权经 PR 合入;宿主采纳完整固定 SHA。
|
||||
3. 另行提交实施计划,覆盖 task-sync 幂等化、恢复/写守卫兼容、源码外收据、CI 分配和能力矩阵。
|
||||
4. 在隔离场景完成治理脚本正负例;涉及脚本必须运行 `make test-governance`。
|
||||
5. 跨仓 gitlink 正式推进前运行 Dev `make test`;最终对象的 CI/构建及平台准入遵循现行规则。
|
||||
5. 跨仓语言依赖或应用源码锁正式推进前运行 Dev `make test`;最终对象的 CI/构建及平台准入遵循现行规则。
|
||||
6. 完成所有者平台操作和对应验证,登记真实协调者与调度 Issue,才切换新交付模式。
|
||||
7. 用三个就绪任务试行,记录同步轮数、CI 次数/耗时、废弃运行、排队和恢复,复盘后决定扩大。
|
||||
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build immutable application commits without developer workspace overlays.
|
||||
|
||||
Go versions remain authoritative in go.mod; npm versions in package-lock.json.
|
||||
The output manifest records the selected graph rather than creating another lock.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
|
||||
SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
IDS = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
||||
BACKEND = "Agent-System-Backend"
|
||||
FRONTEND = "Agent-System-Frontend"
|
||||
NPM_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*\Z")
|
||||
NPM_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\Z")
|
||||
|
||||
|
||||
class CompositionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run(command, cwd, env, *, binary=False, quiet=False):
|
||||
if not quiet:
|
||||
print("[composition] " + " ".join(map(str, command)), flush=True)
|
||||
result = subprocess.run(command, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=not binary)
|
||||
if not quiet and not binary:
|
||||
sys.stdout.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
if result.returncode:
|
||||
detail = result.stderr.decode(errors="replace") if binary else result.stderr
|
||||
raise CompositionError(f"command failed ({result.returncode}): {command[0]} {detail.strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def digest(path):
|
||||
# The composer also supports standalone fixed-root wrapper fixtures.
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def tree_digest(root):
|
||||
if root.is_symlink():
|
||||
raise CompositionError(f"artifact root symlink is forbidden: {root.name}")
|
||||
entries = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_symlink():
|
||||
raise CompositionError(f"artifact symlink is forbidden: {path.name}")
|
||||
if path.is_file():
|
||||
entries.append({"path": path.relative_to(root).as_posix(), "sha256": digest(path), "size": path.stat().st_size})
|
||||
encoded = json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()
|
||||
return {"sha256": hashlib.sha256(encoded).hexdigest(), "files": entries}
|
||||
|
||||
|
||||
def safe_relative(root, value):
|
||||
if not isinstance(value, str) or not value or "\\" in value:
|
||||
raise CompositionError("invalid component path")
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or ".." in relative.parts or (root / value).resolve().is_relative_to(root) is False:
|
||||
raise CompositionError("component path escapes root")
|
||||
|
||||
|
||||
def validate_repo(repo):
|
||||
if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo):
|
||||
raise CompositionError("invalid repository source")
|
||||
if "://" not in repo:
|
||||
if not Path(repo).is_absolute():
|
||||
raise CompositionError("repository must be an approved URL or absolute local path")
|
||||
return
|
||||
parsed = urlsplit(repo)
|
||||
if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment:
|
||||
raise CompositionError("repository URL contains credentials or unsupported transport")
|
||||
if parsed.scheme == "https" and parsed.username:
|
||||
raise CompositionError("repository credentials are forbidden")
|
||||
if parsed.scheme == "file" and parsed.netloc not in {"", "localhost"}:
|
||||
raise CompositionError("file repository must be local")
|
||||
|
||||
|
||||
def tooling_identity(root, environment=None):
|
||||
"""Bind executed tools before importing acquisition configuration."""
|
||||
env = (os.environ if environment is None else environment).copy()
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
|
||||
env.pop(key, None)
|
||||
commit = run(["git", "rev-parse", "HEAD"], root, env, quiet=True).strip()
|
||||
result = {"root_commit": commit, "files": {}}
|
||||
for relative in ("scripts/build-composition.py", "scripts/workspace/transport.py"):
|
||||
executing = Path(__file__).resolve().parent / relative.removeprefix("scripts/")
|
||||
fixed = subprocess.run(["git", "show", f"{commit}:{relative}"], cwd=root, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if fixed.returncode == 0:
|
||||
if executing.is_symlink() or executing.read_bytes() != fixed.stdout:
|
||||
raise CompositionError(f"executing tooling differs from committed source: {relative}")
|
||||
mode = "committed-root"
|
||||
elif (root / relative).exists():
|
||||
raise CompositionError(f"root tooling has no committed identity: {relative}")
|
||||
else:
|
||||
# A standalone consumer fixture can use an external composer. Its
|
||||
# exact tool bytes are a separate identity, never the consumer SHA.
|
||||
mode = "external-tool"
|
||||
result["files"][relative] = {"sha256": digest(executing), "source": mode}
|
||||
return result
|
||||
|
||||
|
||||
def inputs(root, env):
|
||||
commit = run(["git", "rev-parse", "HEAD"], root, env, quiet=True).strip()
|
||||
if not SHA.fullmatch(commit):
|
||||
raise CompositionError("root must have a committed Git revision")
|
||||
documents = {}
|
||||
hashes = {}
|
||||
for name in ("components.json", "components.lock.json"):
|
||||
committed = run(["git", "show", f"{commit}:{name}"], root, env, binary=True, quiet=True)
|
||||
path = root / name
|
||||
if path.is_symlink() or path.read_bytes() != committed:
|
||||
raise CompositionError(f"{name} differs from committed root {commit}")
|
||||
documents[name] = json.loads(committed)
|
||||
hashes[name] = hashlib.sha256(committed).hexdigest()
|
||||
manifest, lock = documents["components.json"], documents["components.lock.json"]
|
||||
if manifest.get("schema") != 1 or not isinstance(manifest.get("components"), list):
|
||||
raise CompositionError("unsupported components schema")
|
||||
if lock.get("schema") != 1 or not isinstance(lock.get("components"), dict):
|
||||
raise CompositionError("unsupported component lock schema")
|
||||
components = {}
|
||||
for item in manifest["components"]:
|
||||
name = item.get("id")
|
||||
if not isinstance(name, str) or not IDS.fullmatch(name) or name in components:
|
||||
raise CompositionError("invalid or duplicate component id")
|
||||
if item.get("path") != f".workspace/{name}":
|
||||
raise CompositionError(f"invalid component path: {name}")
|
||||
safe_relative(root, item["path"])
|
||||
if item.get("legacy_path"):
|
||||
safe_relative(root, item["legacy_path"])
|
||||
validate_repo(item.get("repo"))
|
||||
for module in item.get("modules", []):
|
||||
safe_relative(root, module.get("directory", "."))
|
||||
components[name] = item
|
||||
for name, value in lock["components"].items():
|
||||
if name not in components or not isinstance(value, dict) or not SHA.fullmatch(value.get("commit", "")):
|
||||
raise CompositionError(f"invalid locked component: {name}")
|
||||
if components[name].get("role") in {"go-library", "npm-library"}:
|
||||
raise CompositionError(f"language library cannot have a competing source lock: {name}")
|
||||
for name in (BACKEND, FRONTEND):
|
||||
if name not in components or components[name].get("role") != "application" or name not in lock["components"]:
|
||||
raise CompositionError(f"missing fixed application source: {name}")
|
||||
return commit, components, lock["components"], hashes
|
||||
|
||||
|
||||
def execution_environment(env):
|
||||
"""Acquisition-only authentication/transport never reaches npm/Go candidate code."""
|
||||
clean = env.copy()
|
||||
for key in list(clean):
|
||||
upper = key.upper()
|
||||
if (any(word in upper for word in ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "API_KEY", "ACCESS_KEY"))
|
||||
or upper.startswith(("GIT_CONFIG_", "GIT_SSH", "NPM_CONFIG_", "BASH_FUNC_"))
|
||||
or upper in {"SSH_AUTH_SOCK", "SSH_AGENT_PID", "NODE_OPTIONS", "BASH_ENV", "ENV", "GOFLAGS", "GIT_ASKPASS", "SSH_ASKPASS", "CONTRACTS_TARBALL", "UI_TARBALL"}):
|
||||
del clean[key]
|
||||
clean.update(GOWORK="off", GOTOOLCHAIN="local", GOFLAGS="", CGO_ENABLED="0")
|
||||
# Cache location is transport-independent state, shared by acquisition and
|
||||
# offline consumers. Other npm settings can carry credentials or hooks.
|
||||
for key in ("npm_config_cache", "NPM_CONFIG_CACHE"):
|
||||
if env.get(key):
|
||||
clean[key] = env[key]
|
||||
return clean
|
||||
|
||||
|
||||
def export_source(repo, commit, destination, temporary, env, *, abbreviated=False):
|
||||
validate_repo(repo)
|
||||
clone = temporary / (destination.name + "-git")
|
||||
run(["git", "clone", "--no-checkout", "--no-hardlinks", "--", repo, str(clone)], temporary, env)
|
||||
exists = subprocess.run(["git", "cat-file", "-e", commit + "^{commit}"], cwd=clone, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
if not exists:
|
||||
run(["git", "fetch", "--no-tags", "origin", commit], clone, env)
|
||||
actual = run(["git", "rev-parse", commit + "^{commit}"], clone, env, quiet=True).strip()
|
||||
if not SHA.fullmatch(actual) or (actual != commit and not (abbreviated and len(commit) == 12 and actual.startswith(commit))):
|
||||
raise CompositionError("application revision is not an immutable full commit")
|
||||
archive = run(["git", "archive", "--format=tar", actual], clone, env, binary=True, quiet=True)
|
||||
extract_archive(archive, destination)
|
||||
return {"repo": repo, "commit": actual, "archive_sha256": hashlib.sha256(archive).hexdigest()}
|
||||
|
||||
|
||||
def extract_archive(archive, destination):
|
||||
destination.mkdir()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
||||
for entry in tar.getmembers():
|
||||
safe_relative(destination, entry.name)
|
||||
if not (entry.isfile() or entry.isdir()):
|
||||
raise CompositionError(f"source archive contains an unsupported link: {entry.name}")
|
||||
tar.extractall(destination, filter="data")
|
||||
|
||||
|
||||
def version(manifest, lock, *, backend_commit=None, resolved_go=None):
|
||||
resolved = json.dumps({"go": manifest["go"] if resolved_go is None else resolved_go,
|
||||
"npm_lock": manifest["npm"]["lock_sha256"]},
|
||||
sort_keys=True, separators=(",", ":")).encode()
|
||||
return (manifest["root"]["commit"][:12] + "+backend=" + (backend_commit or lock[BACKEND]["commit"])[:12]
|
||||
+ "+frontend=" + lock[FRONTEND]["commit"][:12]
|
||||
+ "+deps=" + hashlib.sha256(resolved).hexdigest()[:12])
|
||||
|
||||
|
||||
def json_stream(output):
|
||||
decoder = json.JSONDecoder()
|
||||
result = []
|
||||
while output.strip():
|
||||
value, end = decoder.raw_decode(output.lstrip())
|
||||
result.append(value)
|
||||
output = output.lstrip()[end:]
|
||||
return result
|
||||
|
||||
|
||||
def go_dependencies(source, fetch_env):
|
||||
document = json.loads(run(["go", "mod", "edit", "-json"], source, fetch_env, quiet=True))
|
||||
for replacement in document.get("Replace") or []:
|
||||
if not replacement["New"].get("Version"):
|
||||
raise CompositionError("formal Go build forbids local directory replace")
|
||||
original = {name: (source / name).read_bytes() if (source / name).exists() else None for name in ("go.mod", "go.sum")}
|
||||
# Tidy locks need sums for consumed packages, not unused transitive zip files.
|
||||
# Load actual build/test metadata before ending acquisition; no tests execute.
|
||||
# Download may normalize sum lines even for a valid readonly consumer. Keep
|
||||
# that package-manager bookkeeping in an identical disposable manifest.
|
||||
with tempfile.TemporaryDirectory(prefix="composition-go-prefetch-") as directory:
|
||||
isolated = Path(directory)
|
||||
for name, content in original.items():
|
||||
if content is not None:
|
||||
(isolated / name).write_bytes(content)
|
||||
downloads = json_stream(run(["go", "mod", "download", "-json"], isolated, fetch_env, quiet=True))
|
||||
run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], source,
|
||||
fetch_env | {"CGO_ENABLED": "0"}, quiet=True)
|
||||
run(["go", "mod", "verify"], source, fetch_env)
|
||||
modules = json_stream(run(["go", "list", "-mod=readonly", "-m", "-json", "all"], source, fetch_env, quiet=True))
|
||||
selected = []
|
||||
for module in modules:
|
||||
item = {key: module[key] for key in ("Path", "Version", "Main", "Sum", "GoModSum", "GoVersion") if key in module}
|
||||
if "Replace" in module:
|
||||
replacement = module["Replace"]
|
||||
if not replacement.get("Version"):
|
||||
raise CompositionError("resolved Go graph contains a local replace")
|
||||
item["Replace"] = {key: replacement[key] for key in ("Path", "Version", "Sum", "GoModSum") if key in replacement}
|
||||
selected.append(item)
|
||||
for name, content in original.items():
|
||||
actual = (source / name).read_bytes() if (source / name).exists() else None
|
||||
if actual != content:
|
||||
raise CompositionError(f"Go acquisition changed committed {name}; update the consumer first")
|
||||
return {"modules": selected, "go_mod_sha256": digest(source / "go.mod"),
|
||||
"go_sum_sha256": digest(source / "go.sum") if (source / "go.sum").exists() else None,
|
||||
"downloads": [{key: value[key] for key in ("Path", "Version", "Sum", "GoModSum", "Origin") if key in value} for value in downloads]}
|
||||
|
||||
|
||||
def npm_registry_package(location, package):
|
||||
resolved = package.get("resolved", "")
|
||||
if not isinstance(resolved, str):
|
||||
raise CompositionError("invalid npm lock dependency")
|
||||
if not resolved.startswith(("https://", "http://")):
|
||||
return None
|
||||
registry = urlsplit(resolved)
|
||||
if (registry.scheme != "https" or registry.netloc != "registry.npmjs.org" or
|
||||
registry.username or registry.password or registry.query or registry.fragment):
|
||||
raise CompositionError("npm tarball source is outside the reviewed public registry")
|
||||
name = package.get("name") or location.rsplit("node_modules/", 1)[-1]
|
||||
version = package.get("version", "")
|
||||
if (not isinstance(name, str) or not NPM_NAME.fullmatch(name) or
|
||||
not isinstance(version, str) or not NPM_VERSION.fullmatch(version)):
|
||||
raise CompositionError("invalid locked registry package name or version")
|
||||
return name + "@" + version
|
||||
|
||||
|
||||
def npm_dependencies(source, components, fetch_env):
|
||||
path = source / "package-lock.json"
|
||||
lock = json.loads(path.read_text())
|
||||
if lock.get("lockfileVersion", 0) < 2 or not isinstance(lock.get("packages"), dict):
|
||||
raise CompositionError("frontend requires a complete npm lock")
|
||||
approved = {component["repo"] for component in components.values()}
|
||||
packages = {}
|
||||
registry_packages = []
|
||||
for name, package in lock["packages"].items():
|
||||
if name:
|
||||
safe_relative(source, name)
|
||||
registry_package = npm_registry_package(name, package)
|
||||
if registry_package:
|
||||
registry_packages.append((name, registry_package))
|
||||
if package.get("link"):
|
||||
raise CompositionError("formal npm build forbids workspace links")
|
||||
for value in [package.get("resolved", ""), *package.get("dependencies", {}).values()]:
|
||||
if not isinstance(value, str):
|
||||
raise CompositionError("invalid npm lock dependency")
|
||||
if value.startswith(("file:", "link:", "workspace:")):
|
||||
raise CompositionError("formal npm build forbids local dependency overlays")
|
||||
if value.startswith(("git+", "git:", "ssh:")):
|
||||
repo, sep, revision = value.removeprefix("git+").rpartition("#")
|
||||
if not sep or not SHA.fullmatch(revision) or repo not in approved:
|
||||
raise CompositionError("npm Git dependency must use an approved source and full commit")
|
||||
packages[name] = {key: package[key] for key in ("version", "resolved", "integrity") if key in package}
|
||||
before = digest(path)
|
||||
run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], source, fetch_env)
|
||||
# npm ci normally stores tarballs without the packuments required when a
|
||||
# disposable packed-package consumer re-resolves peers offline. Fetch the
|
||||
# installed locked versions before candidate execution, from a fixed public
|
||||
# registry with empty config and no acquisition credentials or candidate rc.
|
||||
metadata_env = execution_environment(fetch_env)
|
||||
with tempfile.TemporaryDirectory(prefix="composition-npm-metadata-") as directory:
|
||||
isolated = Path(directory)
|
||||
global_config = isolated / "empty-global.npmrc"
|
||||
global_config.write_text("")
|
||||
selected = {spec for location, spec in registry_packages if (source / location).is_dir()}
|
||||
if selected:
|
||||
run(["npm", "cache", "add", *sorted(selected), "--ignore-scripts",
|
||||
"--registry=https://registry.npmjs.org", "--userconfig=/dev/null",
|
||||
"--globalconfig=" + str(global_config)], isolated, metadata_env)
|
||||
if digest(path) != before:
|
||||
raise CompositionError("npm acquisition changed committed lock")
|
||||
result = {"lock_sha256": before, "packages": packages}
|
||||
contract = source / "node_modules/@agent-system/contracts"
|
||||
if contract.is_dir():
|
||||
result["contracts"] = tree_digest(contract)
|
||||
return result
|
||||
|
||||
|
||||
def frontend_assets(source, clean_env):
|
||||
dist = source / "dist"
|
||||
if dist.exists():
|
||||
shutil.rmtree(dist)
|
||||
run(["npm", "run", "build"], source, clean_env)
|
||||
if not (dist / "index.html").is_file() or not (dist / "index.html").stat().st_size:
|
||||
raise CompositionError("frontend dist requires nonempty index.html")
|
||||
if not (dist / "assets").is_dir() or not any(p.is_file() and p.stat().st_size for p in (dist / "assets").rglob("*")):
|
||||
raise CompositionError("frontend dist requires nonempty assets/")
|
||||
return dist, tree_digest(dist)
|
||||
|
||||
|
||||
def inject(dist, destination):
|
||||
if destination.is_symlink():
|
||||
raise CompositionError("embed destination must not be a symlink")
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(dist, destination)
|
||||
(destination / ".gitkeep").touch()
|
||||
|
||||
|
||||
def provider_revision(module, downloads):
|
||||
metadata = next((entry for entry in downloads if entry["Path"] == module["Path"] and entry["Version"] == module["Version"]), {})
|
||||
origin = metadata.get("Origin", {})
|
||||
commit = origin.get("Hash", "")
|
||||
pseudo = re.search(r"-([0-9a-f]{12})\Z", module["Version"])
|
||||
if SHA.fullmatch(commit):
|
||||
if pseudo and not commit.startswith(pseudo.group(1)):
|
||||
raise CompositionError(f"Go Origin.Hash differs from selected version: {module['Path']}")
|
||||
return commit, False
|
||||
if pseudo:
|
||||
return pseudo.group(1), True
|
||||
raise CompositionError(f"cannot prove source commit for selected Go module: {module['Path']}@{module['Version']}")
|
||||
|
||||
|
||||
def prepare_provider_tests(root, root_commit, components, sources, manifest, temporary,
|
||||
fetch_env, rdm=None, rdm_admin=None, source_map_path=None):
|
||||
"""Acquire every provider and cache before constructing the execution plan."""
|
||||
graph = manifest["go"]["backend"]
|
||||
source_records = {name: {"path": str(path), "commit": manifest["applications"][name]["commit"], "repo": components[name]["repo"]}
|
||||
for name, path in sources.items()}
|
||||
module_sources = {}
|
||||
manifest["test_sources"] = {}
|
||||
manifest["checks"] = []
|
||||
go_checks = [{"component": BACKEND, "path": sources[BACKEND], "mode": "readonly"}]
|
||||
npm_checks = [{"component": FRONTEND, "path": sources[FRONTEND], "commands": [["npm", "test"]]}]
|
||||
for name, component in components.items():
|
||||
if component.get("role") != "go-library":
|
||||
continue
|
||||
selected = [(mapping, next((m for m in graph["modules"] if m["Path"] == mapping["path"]), None))
|
||||
for mapping in component.get("modules", [])]
|
||||
selected = [(mapping, module) for mapping, module in selected if module is not None]
|
||||
if not selected:
|
||||
continue
|
||||
primary = next((pair for pair in selected if pair[0].get("directory", ".") == "."), selected[0])
|
||||
revision, abbreviated = provider_revision(primary[1], graph["downloads"])
|
||||
source = temporary / name
|
||||
record = export_source(component["repo"], revision, source, temporary, fetch_env, abbreviated=abbreviated)
|
||||
manifest["test_sources"][name] = record
|
||||
source_records[name] = {"path": str(source), "commit": record["commit"], "repo": component["repo"]}
|
||||
sources[name] = source
|
||||
for mapping in component.get("modules", []):
|
||||
module = next((m for m in graph["modules"] if m["Path"] == mapping["path"]), None)
|
||||
module_root = source
|
||||
module_commit = record["commit"]
|
||||
if module:
|
||||
module_revision, module_abbreviated = provider_revision(module, graph["downloads"])
|
||||
if not module_commit.startswith(module_revision):
|
||||
module_root = temporary / (name + "-" + mapping["directory"].replace("/", "-"))
|
||||
alternate = export_source(component["repo"], module_revision, module_root, temporary, fetch_env, abbreviated=module_abbreviated)
|
||||
module_commit = alternate["commit"]
|
||||
manifest["test_sources"][mapping["path"]] = alternate
|
||||
location = module_root / mapping.get("directory", ".")
|
||||
if not (location / "go.mod").is_file():
|
||||
raise CompositionError(f"declared provider module is missing: {mapping['path']}")
|
||||
module_sources[mapping["path"]] = {"path": str(location), "commit": module_commit, "repo": component["repo"]}
|
||||
manifest["go"][mapping["path"]] = go_dependencies(location, fetch_env)
|
||||
go_checks.append({"component": name, "module": mapping["path"], "path": location, "mode": "readonly"})
|
||||
# UI is a package provider; its source identity belongs to the npm lock.
|
||||
ui_component = components.get("Agent-System-UI")
|
||||
if ui_component:
|
||||
package_name = ui_component.get("package_name", "@agent-system/ui")
|
||||
resolved = manifest["npm"]["packages"].get("node_modules/" + package_name, {}).get("resolved", "")
|
||||
repo, _, commit = resolved.removeprefix("git+").rpartition("#")
|
||||
if repo != ui_component["repo"] or not SHA.fullmatch(commit):
|
||||
raise CompositionError("cannot prove UI source from the Frontend npm lock")
|
||||
ui = temporary / "Agent-System-UI"
|
||||
record = export_source(repo, commit, ui, temporary, fetch_env)
|
||||
sources["Agent-System-UI"] = ui
|
||||
manifest["test_sources"]["Agent-System-UI"] = record
|
||||
source_records["Agent-System-UI"] = {"path": str(ui), "commit": commit, "repo": repo}
|
||||
for name, source in sources.items():
|
||||
if name == FRONTEND or not (source / "package.json").is_file():
|
||||
continue
|
||||
manifest.setdefault("provider_npm", {})[name] = npm_dependencies(source, components, fetch_env)
|
||||
scripts = json.loads((source / "package.json").read_text()).get("scripts", {})
|
||||
commands = [["npm", "run", action] for action in ("typecheck", "test") if action in scripts]
|
||||
npm_checks.append({"component": name, "path": source, "commands": commands})
|
||||
if rdm:
|
||||
source_records["Agent-System-RDM"] = {"path": str(rdm), **{key: manifest["applications"]["Agent-System-RDM"][key] for key in ("commit", "repo")}}
|
||||
go_checks.extend([
|
||||
{"component": "Agent-System-RDM", "path": rdm, "mode": "vendor"},
|
||||
{"component": "RDM-Backend", "path": rdm_admin, "mode": "readonly", "commit": manifest["go"]["rdm"]["backend_source"]["commit"]},
|
||||
])
|
||||
formal_root = temporary / "Dev-test-root"
|
||||
archive = run(["git", "archive", "--format=tar", root_commit], root, fetch_env, binary=True, quiet=True)
|
||||
extract_archive(archive, formal_root)
|
||||
source_map = {"schema": 1, "root_commit": root_commit, "components": source_records, "modules": module_sources}
|
||||
map_file = source_map_path or temporary / "composition-sources.json"
|
||||
map_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
map_file.write_text(json.dumps(source_map, indent=2) + "\n")
|
||||
gates = ((["bash", "scripts/lint-boundaries.sh"], "scripts/lint-boundaries.sh"),
|
||||
(["bash", "scripts/check-pins.sh"], "scripts/check-pins.sh"),
|
||||
(["make", "test-governance"], "Makefile"))
|
||||
project_gates = any((formal_root / required_path).is_file() for _, required_path in gates)
|
||||
for command, required_path in gates:
|
||||
if project_gates and not (formal_root / required_path).is_file():
|
||||
raise CompositionError(f"fixed root is missing required test gate: {required_path}")
|
||||
return {"go": go_checks, "npm": npm_checks, "root": formal_root, "root_commit": root_commit,
|
||||
"map_file": map_file, "rdm": rdm,
|
||||
"gates": [command for command, required_path in gates if (formal_root / required_path).is_file()]}
|
||||
|
||||
|
||||
def run_provider_tests(prepared, manifest, clean_env):
|
||||
"""Execute a completed plan; this phase has no acquisition environment."""
|
||||
for check in prepared["npm"][:1]:
|
||||
for command in check["commands"]:
|
||||
run(command, check["path"], clean_env)
|
||||
manifest["checks"].append({"component": check["component"], "command": command, "result": "PASS"})
|
||||
for check in prepared["go"]:
|
||||
for action in ("test", "vet"):
|
||||
command = ["go", action, "-mod=" + check["mode"], "./..."]
|
||||
run(command, check["path"], clean_env)
|
||||
identity = {key: check[key] for key in ("component", "module", "commit") if key in check}
|
||||
manifest["checks"].append({**identity, "command": command, "result": "PASS"})
|
||||
for check in prepared["npm"][1:]:
|
||||
for command in check["commands"]:
|
||||
run(command, check["path"], clean_env)
|
||||
manifest["checks"].append({"component": check["component"], "command": command, "result": "PASS"})
|
||||
gate_env = clean_env | {"COMPOSITION_SOURCE_MAP": str(prepared["map_file"])}
|
||||
if prepared["rdm"]:
|
||||
gate_env["COMPOSITION_RDM_SOURCE"] = str(prepared["rdm"])
|
||||
for command in prepared["gates"]:
|
||||
run(command, prepared["root"], gate_env)
|
||||
manifest["checks"].append({"component": "Agent-System-Dev", "commit": prepared["root_commit"], "command": command, "result": "PASS"})
|
||||
|
||||
|
||||
def compose(root, target, output, rdm_source=None, source_map_path=None):
|
||||
tooling = tooling_identity(root)
|
||||
transport_path = Path(__file__).resolve().parent / "workspace/transport.py"
|
||||
spec = importlib.util.spec_from_file_location("composition_transport", transport_path)
|
||||
transport = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(transport)
|
||||
fetch_env = transport.environment(root)
|
||||
fetch_env.update(GOWORK="off", GOFLAGS="", GOTOOLCHAIN="local")
|
||||
root_commit, components, lock, input_hashes = inputs(root, fetch_env)
|
||||
if root_commit != tooling["root_commit"]:
|
||||
raise CompositionError("root identity changed during tooling validation")
|
||||
clean_env = execution_environment(fetch_env)
|
||||
manifest = {"schema": 1, "kind": "agent-system-composition/1", "target": target,
|
||||
"root": {"commit": root_commit, "inputs": input_hashes}, "tooling": tooling,
|
||||
"applications": {}, "go": {}, "artifacts": {}, "assets": {}}
|
||||
with tempfile.TemporaryDirectory(prefix="agent-system-composition-") as directory:
|
||||
temporary = Path(directory)
|
||||
sources = {}
|
||||
for name in (FRONTEND, BACKEND):
|
||||
if target == "frontend" and name == BACKEND:
|
||||
continue
|
||||
source = temporary / name
|
||||
manifest["applications"][name] = export_source(components[name]["repo"], lock[name]["commit"], source, temporary, fetch_env)
|
||||
sources[name] = source
|
||||
frontend = sources[FRONTEND]
|
||||
manifest["npm"] = npm_dependencies(frontend, components, fetch_env)
|
||||
products = temporary / "products"
|
||||
products.mkdir()
|
||||
rdm = None
|
||||
admin_source = None
|
||||
vendored_backend = None
|
||||
test_plan = None
|
||||
manifest["version"] = version(manifest, lock)
|
||||
if target != "frontend":
|
||||
backend = sources[BACKEND]
|
||||
manifest["go"]["backend"] = go_dependencies(backend, fetch_env)
|
||||
manifest["version"] = version(manifest, lock)
|
||||
if rdm_source:
|
||||
rdm_commit = run(["git", "rev-parse", "HEAD"], rdm_source, fetch_env, quiet=True).strip()
|
||||
rdm = temporary / "Agent-System-RDM"
|
||||
manifest["applications"]["Agent-System-RDM"] = export_source(str(rdm_source), rdm_commit, rdm, temporary, fetch_env)
|
||||
graph = go_dependencies(rdm, fetch_env)
|
||||
manifest["go"]["rdm"] = graph
|
||||
backend_module = next((m["path"] for m in components[BACKEND]["modules"] if m.get("directory", ".") == "."), None)
|
||||
selected = next((m for m in graph["modules"] if m["Path"] == backend_module), None)
|
||||
if not selected:
|
||||
raise CompositionError("RDM must consume the fixed Backend as an ordinary versioned Go module")
|
||||
effective = selected.get("Replace", selected)
|
||||
if effective.get("Path") != backend_module or not effective.get("Version"):
|
||||
raise CompositionError("RDM Backend must resolve to the approved versioned module")
|
||||
# RDM's module graph is its dependency authority. The Dev app
|
||||
# source lock fixes a separate application and may differ.
|
||||
revision, abbreviated = provider_revision(effective, graph["downloads"])
|
||||
admin_source = temporary / "RDM-Backend"
|
||||
backend_record = export_source(components[BACKEND]["repo"], revision, admin_source,
|
||||
temporary, fetch_env, abbreviated=abbreviated)
|
||||
graph["backend_source"] = {**backend_record, "module": backend_module, "version": effective["Version"]}
|
||||
manifest["go"]["rdm_admin"] = go_dependencies(admin_source, fetch_env)
|
||||
run(["go", "mod", "vendor"], rdm, fetch_env)
|
||||
vendored_backend = rdm / "vendor" / backend_module
|
||||
if not (vendored_backend / "web/embed.go").is_file():
|
||||
raise CompositionError("RDM vendored Backend does not contain the embed package")
|
||||
graph["vendor_modules_sha256"] = digest(rdm / "vendor/modules.txt")
|
||||
graph["vendor_backend_source_sha256"] = tree_digest(vendored_backend)["sha256"]
|
||||
if target == "test":
|
||||
test_plan = prepare_provider_tests(root, root_commit, components, sources, manifest, temporary,
|
||||
fetch_env, rdm, admin_source, source_map_path)
|
||||
if target == "build" and run(["git", "ls-tree", root_commit, "--", "plugins"], root, fetch_env, quiet=True).strip():
|
||||
archive = run(["git", "archive", "--format=tar", root_commit, "plugins"], root, fetch_env, binary=True, quiet=True)
|
||||
root_assets = temporary / "root-assets"
|
||||
extract_archive(archive, root_assets)
|
||||
manifest["assets"]["plugins"] = tree_digest(root_assets / "plugins")
|
||||
manifest["assets"]["plugins"]["root_commit"] = root_commit
|
||||
|
||||
# All fixed sources, package caches, vendor files and root assets are
|
||||
# acquired above. Candidate commands below receive only clean_env;
|
||||
# there is no acquisition environment in the execution plan.
|
||||
del fetch_env
|
||||
dist, manifest["frontend"] = frontend_assets(frontend, clean_env)
|
||||
manifest["toolchains"] = {"go": run(["go", "version"], temporary, clean_env, quiet=True).strip(),
|
||||
"node": run(["node", "--version"], temporary, clean_env, quiet=True).strip(),
|
||||
"npm": run(["npm", "--version"], temporary, clean_env, quiet=True).strip()}
|
||||
if target != "frontend":
|
||||
inject(dist, backend / "web/dist")
|
||||
manifest["go"]["backend"]["embed_sha256"] = tree_digest(backend / "web/dist")["sha256"]
|
||||
if rdm:
|
||||
graph = manifest["go"]["rdm"]
|
||||
inject(dist, admin_source / "web/dist")
|
||||
manifest["go"]["rdm_admin"]["embed_sha256"] = tree_digest(admin_source / "web/dist")["sha256"]
|
||||
graph["admin_version"] = version(manifest, lock, backend_commit=graph["backend_source"]["commit"],
|
||||
resolved_go={"rdm": graph["modules"], "admin": manifest["go"]["rdm_admin"]})
|
||||
inject(dist, vendored_backend / "web/dist")
|
||||
graph["vendor_backend_dist_sha256"] = tree_digest(vendored_backend / "web/dist")["sha256"]
|
||||
if target == "test":
|
||||
run_provider_tests(test_plan, manifest, clean_env)
|
||||
else:
|
||||
for name, package in (("agent-system", "./cmd/agent-system"), ("storage-migrate", "./cmd/storage-migrate")):
|
||||
flags = "-s -w" + (" -X main.version=" + manifest["version"] if name == "agent-system" else "")
|
||||
run(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags=" + flags, "-o", str(products / name), package], backend, clean_env)
|
||||
if rdm:
|
||||
run(["go", "build", "-mod=vendor", "-trimpath", "-buildvcs=false", "-ldflags=-s -w", "-o", str(products / "agent-system-rdm-product"), "."], rdm, clean_env)
|
||||
run(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false",
|
||||
"-ldflags=-s -w -X main.version=" + graph["admin_version"],
|
||||
"-o", str(products / "agent-system-admin"), "./cmd/agent-system"], admin_source, clean_env)
|
||||
for artifact in products.iterdir():
|
||||
manifest["artifacts"][artifact.name] = {"sha256": digest(artifact), "size": artifact.stat().st_size}
|
||||
if tooling_identity(root, clean_env) != tooling:
|
||||
raise CompositionError("executing tooling changed during composition")
|
||||
# Publish only after all required build/test commands succeeded.
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for artifact in products.iterdir():
|
||||
shutil.copy2(artifact, output / artifact.name)
|
||||
if "plugins" in manifest["assets"]:
|
||||
destination = output / "plugins"
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(root_assets / "plugins", destination)
|
||||
if target == "frontend":
|
||||
destination = output / "frontend-dist"
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(dist, destination)
|
||||
pending = output / ".resolved-manifest.json.tmp"
|
||||
pending.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
||||
pending.replace(output / "resolved-manifest.json")
|
||||
print(f"[composition] complete: {target}; manifest={output / 'resolved-manifest.json'}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--target", required=True, choices=("frontend", "build", "test"))
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--rdm-source", type=Path)
|
||||
parser.add_argument("--source-map", type=Path, help="Write source identity map used by test gates; paths exist only during this run")
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
try:
|
||||
compose(root, args.target, (args.output or root / "bin").resolve(), args.rdm_source.resolve() if args.rdm_source else None,
|
||||
args.source_map.resolve() if args.source_map else None)
|
||||
except (CompositionError, OSError, ValueError, KeyError, TypeError) as error:
|
||||
print(f"composition: {error}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,6 +43,12 @@ def card_path(path):
|
||||
def check_snapshot(snapshot):
|
||||
evidence.validate_snapshot(snapshot)
|
||||
stale = check_head(snapshot["head"])
|
||||
current = evidence.component_snapshot(Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()))
|
||||
if current is not None and snapshot.get('components') != current:
|
||||
raise ValueError('recorded ordinary component/source-lock objects differ from the current objects')
|
||||
for item in snapshot.get('components', {}).get('components', {}).values():
|
||||
if item.get('status') or item.get('untracked'):
|
||||
raise ValueError('recorded ordinary component includes uncommitted changes')
|
||||
for line in snapshot["status"].splitlines():
|
||||
if len(line) < 4 or line[2] != " ":
|
||||
raise ValueError("invalid recorded worktree status")
|
||||
|
||||
@@ -5,6 +5,54 @@ set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$root"
|
||||
|
||||
# New layouts have one version authority per dependency kind. Inspect the formal
|
||||
# source map when supplied, otherwise verify prepared fixed application sources.
|
||||
if [ -f components.json ]; then
|
||||
python3 - "$root" <<'PYCODE'
|
||||
import importlib.util, json, os, pathlib, subprocess, sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root / "scripts"))
|
||||
spec = importlib.util.spec_from_file_location("component_source", root / "scripts/component-source.py")
|
||||
resolver = importlib.util.module_from_spec(spec); spec.loader.exec_module(resolver)
|
||||
try:
|
||||
document = json.loads((root / "components.json").read_text())
|
||||
components = {entry["id"]: entry for entry in document["components"]}
|
||||
marker = os.environ.get("COMPOSITION_SOURCE_MAP")
|
||||
if marker:
|
||||
names = [name for name in json.loads(pathlib.Path(marker).read_text())["components"] if name in components]
|
||||
else:
|
||||
names = [name for name, entry in components.items() if entry["role"] == "application"]
|
||||
for name in names:
|
||||
component = components[name]
|
||||
path = resolver.source_path(root, name)
|
||||
if component["role"] == "application" and (path / ".gitmodules").exists():
|
||||
raise ValueError("legacy gitlink declaration remains in fixed source: " + name)
|
||||
for module in component.get("modules", []):
|
||||
directory = path / module.get("directory", ".")
|
||||
if not (directory / "go.mod").is_file():
|
||||
raise ValueError("declared module source is missing: " + name)
|
||||
env = os.environ | {"GOWORK": "off", "GOTOOLCHAIN": "local"}
|
||||
result = subprocess.run(["go", "mod", "edit", "-json"], cwd=directory, env=env, capture_output=True, text=True)
|
||||
if result.returncode: raise ValueError("Go module cannot be parsed: " + name)
|
||||
parsed = json.loads(result.stdout)
|
||||
if parsed["Module"]["Path"] != module["path"]:
|
||||
raise ValueError("module identity differs from approved mapping: " + name)
|
||||
if any(not replacement["New"].get("Version") for replacement in parsed.get("Replace") or []):
|
||||
raise ValueError("local replace in fixed source: " + name)
|
||||
npm_lock = path / "package-lock.json"
|
||||
if npm_lock.exists():
|
||||
for entry in json.loads(npm_lock.read_text()).get("packages", {}).values():
|
||||
if entry.get("link") or str(entry.get("resolved", "")).startswith(("file:", "link:")):
|
||||
raise ValueError("local npm overlay in fixed source: " + name)
|
||||
print("ok fixed source " + name)
|
||||
if not names: raise ValueError("fixed application sources are missing")
|
||||
except (ValueError, KeyError, TypeError, OSError) as error:
|
||||
print("pin: " + str(error), file=sys.stderr); sys.exit(1)
|
||||
print("version authorities ok (source lock / go.mod / npm lock; no gitlink alignment)")
|
||||
PYCODE
|
||||
exit $?
|
||||
fi
|
||||
|
||||
fail=0
|
||||
|
||||
pin_of() {
|
||||
|
||||
+173
-9
@@ -1,14 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail closed before task synchronization, verification, or worktree disposal."""
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from workspace.common import WorkspaceError
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
command = ["git", "-C", str(repo)]
|
||||
# Retired absorbed metadata may outlive its configured work tree. Inspect
|
||||
# its refs/registrations without chdir to that absent checkout.
|
||||
if (repo / "HEAD").is_file() and (repo / "config").is_file() and not (repo / ".git").exists():
|
||||
command += ["--git-dir=" + str(repo), "--work-tree=" + str(repo)]
|
||||
environment = os.environ.copy()
|
||||
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
||||
for variable in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
|
||||
environment.pop(variable, None)
|
||||
result = subprocess.run([*command, *args], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, env=environment)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Git check failed in {repo}: {' '.join(args)}")
|
||||
return result.stdout
|
||||
@@ -103,6 +115,143 @@ def materialize(repo):
|
||||
materialize(child)
|
||||
|
||||
|
||||
def component_manifest(repo):
|
||||
from workspace.manifest import Manifest
|
||||
return Manifest(repo) if (repo / "components.json").exists() else None
|
||||
|
||||
|
||||
def prepare_rules(repo):
|
||||
"""Resolve startup reading before task writes, using an external fixed cache."""
|
||||
from workspace.cli import process_environment
|
||||
from workspace.git import Git
|
||||
manifest = component_manifest(repo)
|
||||
if manifest is None:
|
||||
return
|
||||
rules = [entry for entry in manifest.select() if entry["role"] == "guidelines"]
|
||||
if len(rules) != 1 or rules[0]["id"] not in manifest.lock:
|
||||
raise RuntimeError("exactly one fixed guidelines source is required before READY")
|
||||
component = rules[0]
|
||||
commit = manifest.lock[component["id"]]["commit"]
|
||||
cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "agent-system" / "rules"
|
||||
if not cache.is_absolute() or cache.resolve().is_relative_to(repo):
|
||||
raise RuntimeError("rules cache must be an absolute external directory")
|
||||
for parent in (cache, *cache.parents):
|
||||
if parent.is_symlink():
|
||||
raise RuntimeError("rules cache must not traverse symbolic links")
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
identity = hashlib.sha256((component["repo"] + "\n" + commit).encode()).hexdigest()
|
||||
source = cache / identity
|
||||
lock = cache / (identity + ".lock")
|
||||
try:
|
||||
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError as error:
|
||||
raise RuntimeError("fixed rules cache has an active owner; retry without removing its lock") from error
|
||||
os.close(descriptor)
|
||||
try:
|
||||
helper = Git(process_environment(repo))
|
||||
if source.exists():
|
||||
actual = helper.safe(source, component["repo"])
|
||||
if actual["head"] != commit or actual["branch"]:
|
||||
raise RuntimeError("fixed rules cache differs from its declared source; preserve it for its owner")
|
||||
else:
|
||||
helper.call(cache, "clone", "--no-checkout", "--", component["repo"], str(source))
|
||||
helper.call(source, "fetch", "--no-tags", "origin", commit)
|
||||
helper.call(source, "checkout", "--detach", commit)
|
||||
helper.safe(source, component["repo"])
|
||||
for required in ("core/baseline.md", "workflows/delivery.md"):
|
||||
path = source / required
|
||||
if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(source):
|
||||
raise RuntimeError(f"fixed rules source is missing required reading: {required}")
|
||||
print(f"[rules] path={source} commit={commit}; required-reading root verified")
|
||||
finally:
|
||||
lock.unlink()
|
||||
|
||||
|
||||
def prepare_sources(repo):
|
||||
result = subprocess.run([sys.executable, str(repo / "scripts/workspace.py"), "--root", str(repo), "prepare"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(result.stderr.strip() or "fixed source preparation failed; owner must reconcile")
|
||||
print("[workspace] fixed application, documentation and guidelines sources prepared; edit branches preserved")
|
||||
|
||||
|
||||
def inspect_source(child, mode, removal_roots):
|
||||
if child.is_symlink() or (child / ".git").is_symlink():
|
||||
raise RuntimeError(f"component Git identity is a symbolic link: {child}")
|
||||
if not (child / ".git").exists() or Path(text(child, "rev-parse", "--show-toplevel")).resolve() != child:
|
||||
raise RuntimeError(f"component has borrowed or missing Git identity: {child}")
|
||||
check_operations(child)
|
||||
if mode == "finish":
|
||||
check_component_worktrees(child, removal_roots)
|
||||
check_clean(child, ignored=True)
|
||||
if local_work_outside(child, text(child, "rev-parse", "HEAD")):
|
||||
raise RuntimeError(f"component has unpreserved local refs: {child}; preserve its source before removal")
|
||||
|
||||
|
||||
def inspect_source_tree(root, mode, removal_roots, seen):
|
||||
for current, directories, files in os.walk(root, followlinks=False):
|
||||
child = Path(current)
|
||||
directories[:] = [name for name in directories if name != ".git"]
|
||||
if ".git" in files or (child / ".git").exists():
|
||||
if child not in seen:
|
||||
seen.add(child)
|
||||
inspect_source(child, mode, removal_roots)
|
||||
|
||||
|
||||
def legacy_components(repo, manifest, mode, removal_roots):
|
||||
"""Keep retired physical roots and nested repositories visible after gitlink removal."""
|
||||
from workspace.common import safe_path
|
||||
seen = set()
|
||||
for component in manifest.select():
|
||||
relative = component.get("legacy_path")
|
||||
if not relative:
|
||||
continue
|
||||
root = safe_path(repo, relative)
|
||||
if not root.exists():
|
||||
continue
|
||||
if mode == "materialize":
|
||||
print(f"[transition] retained legacy source {relative}; inspect make workspace-transition NAME={component['id']} before explicit ARGS=--apply")
|
||||
inspect_source_tree(root, mode, removal_roots, seen)
|
||||
|
||||
|
||||
def check_retained_metadata(repo, removal_roots):
|
||||
"""An absent legacy checkout can still own external worktrees via absorbed metadata."""
|
||||
modules = Path(text(repo, "rev-parse", "--absolute-git-dir")) / "modules"
|
||||
if not modules.exists():
|
||||
return
|
||||
for current, directories, files in os.walk(modules, followlinks=False):
|
||||
directories[:] = [name for name in directories if name not in
|
||||
{"objects", "refs", "logs", "hooks", "worktrees", "lfs", "info"}]
|
||||
metadata = Path(current)
|
||||
if "HEAD" not in files or "config" not in files:
|
||||
continue
|
||||
for record in worktrees(metadata):
|
||||
other = Path(os.fsdecode(record[0][9:])).resolve()
|
||||
if not any(other == root or root in other.parents for root in removal_roots):
|
||||
raise RuntimeError(f"registered component worktree {other} depends on retained metadata in {metadata}; preserve it before removal")
|
||||
check_operations(metadata)
|
||||
if local_work_outside(metadata, text(metadata, "rev-parse", "HEAD")):
|
||||
raise RuntimeError(f"retained component metadata has unpreserved local refs: {metadata}")
|
||||
|
||||
|
||||
def ordinary_components(repo, mode, removal_roots=()):
|
||||
manifest = component_manifest(repo)
|
||||
if manifest is None:
|
||||
return
|
||||
from workspace.common import safe_path
|
||||
operation = safe_path(repo, ".workspace/.operation.lock")
|
||||
if operation.exists():
|
||||
raise RuntimeError("active workspace operation lock; preserve it for its owner")
|
||||
seen = set()
|
||||
for item in manifest.select():
|
||||
child = safe_path(repo, item["path"])
|
||||
if child.exists():
|
||||
inspect_source(child, mode, removal_roots)
|
||||
seen.add(child)
|
||||
inspect_source_tree(child, mode, removal_roots, seen)
|
||||
legacy_components(repo, manifest, mode, removal_roots)
|
||||
|
||||
|
||||
def local_work_outside(child, expected):
|
||||
"""2026-10-05 修复:pinned-history 检查此前用 --all --reflog,把 refs/remotes 也
|
||||
计入——pin 在 feature 顶端的子仓因 origin/main 不在 pin 历史内而永远无法 finish
|
||||
@@ -110,6 +259,10 @@ def local_work_outside(child, expected):
|
||||
不在任何远端跟踪引用历史内的提交才算。克隆默认分支停在 origin/main 顶端、
|
||||
reflog 里的检出轨迹等「远端有副本」的状态一律不算;stash 恒为本地独有。"""
|
||||
candidates = git(child, "rev-list", "--branches", "--not", expected).split()
|
||||
for row in git(child, "for-each-ref", "--format=%(objectname) %(refname)").splitlines():
|
||||
tip, ref = row.split(b" ", 1)
|
||||
if not ref.startswith(b"refs/remotes/"):
|
||||
candidates.append(tip)
|
||||
for line in git(child, "reflog", "show", "HEAD", "--format=%H").splitlines():
|
||||
tip = line.strip()
|
||||
if tip:
|
||||
@@ -151,26 +304,37 @@ def check_finish_root(repo):
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "verify"
|
||||
if mode not in ("verify", "before-sync", "before-update", "materialize", "after-sync", "finish"):
|
||||
raise RuntimeError("usage: check-worktree.py [verify|before-sync|before-update|materialize|after-sync|finish]")
|
||||
if mode not in ("rules", "verify", "before-sync", "before-update", "materialize", "after-sync", "finish"):
|
||||
raise RuntimeError("usage: check-worktree.py [rules|verify|before-sync|before-update|materialize|after-sync|finish]")
|
||||
repo = Path(text(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
||||
if mode == "rules":
|
||||
prepare_rules(repo)
|
||||
return
|
||||
check_operations(repo)
|
||||
removal_roots = ()
|
||||
if mode == "finish":
|
||||
check_finish_root(repo)
|
||||
removal_roots = (repo, Path(text(repo, "rev-parse", "--absolute-git-dir")).resolve())
|
||||
if mode in ("before-sync", "after-sync", "finish"):
|
||||
check_clean(repo, ignored=(mode == "finish"))
|
||||
if mode in ("before-sync", "after-sync"):
|
||||
check_clean(repo)
|
||||
manifest = component_manifest(repo)
|
||||
if mode == "materialize":
|
||||
materialize(repo)
|
||||
else:
|
||||
if manifest is None:
|
||||
materialize(repo)
|
||||
else:
|
||||
prepare_sources(repo)
|
||||
elif manifest is None:
|
||||
check_children(repo, mode, removal_roots)
|
||||
ordinary_components(repo, mode, removal_roots)
|
||||
if mode == "finish":
|
||||
check_retained_metadata(repo, removal_roots)
|
||||
check_clean(repo, ignored=True)
|
||||
print(f"[worktree] {mode}: recursive component checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
except (RuntimeError, WorkspaceError, ValueError, OSError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare selected fixed sources and run explicitly local component checks."""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from workspace.cli import Workspace, process_environment
|
||||
from workspace.common import WorkspaceError
|
||||
from workspace.state import State
|
||||
|
||||
SINGLE = {"test-backend": "Agent-System-Backend", "test-backend-pg": "Agent-System-Backend",
|
||||
"test-core": "Agent-System-Core", "test-agentcmd": "Agent-Command",
|
||||
"test-frontend": "Agent-System-Frontend", "test-ui": "Agent-System-UI",
|
||||
"test-multi-instance": "Agent-System-Backend", "eval-deliverables": "Agent-System-Backend",
|
||||
"dev-backend": "Agent-System-Backend", "dev-frontend": "Agent-System-Frontend"}
|
||||
GO_CHECKS = {"test-backend", "test-backend-pg", "test-core", "test-agentcmd",
|
||||
"test-libs", "test-multi-instance", "eval-deliverables"}
|
||||
|
||||
|
||||
def run(command, cwd, env):
|
||||
print("[component-check] " + " ".join(map(str, command)), flush=True)
|
||||
result = subprocess.run(command, cwd=cwd, env=env)
|
||||
if result.returncode:
|
||||
raise WorkspaceError(f"local component command failed ({result.returncode})")
|
||||
|
||||
|
||||
def prepare_local_source(workspace, component):
|
||||
"""Repeated local npm checks may reuse only recognized generated directories."""
|
||||
record = workspace.state.data["components"].get(component["id"])
|
||||
path = workspace.root / component["path"]
|
||||
if record and record.get("mode") == "versioned" and (path / "package.json").is_file():
|
||||
actual = workspace.git.safe(path, component["repo"], clean=False)
|
||||
commit, source = workspace.baseline(component)
|
||||
generated = ("node_modules/", "dist/")
|
||||
if actual["head"] != commit or actual["branch"] or actual["changes"] or any(not name.startswith(generated) for name in actual["ignored"]):
|
||||
raise WorkspaceError(f"local source or ignored unique files require owner coordination: {component['id']}")
|
||||
record.update(baseline=commit, source=source)
|
||||
workspace.state.save()
|
||||
return
|
||||
workspace.prepare_one(component)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def check_sources(workspace, selected, target, overlay):
|
||||
paths = {component["id"]: workspace.root / component["path"] for component in selected}
|
||||
fixed = [component for component in selected
|
||||
if workspace.state.data["components"][component["id"]].get("mode") == "versioned"]
|
||||
if overlay or target not in GO_CHECKS or not fixed:
|
||||
yield paths
|
||||
return
|
||||
# Go tests can create ignored outputs (including Python bytecode). Keep
|
||||
# strict source guards and execute fixed commits in owned disposable clones.
|
||||
# Editable sources and explicit overlays still exercise the owner's code.
|
||||
with tempfile.TemporaryDirectory(prefix="component-check-") as temporary:
|
||||
area = Path(temporary)
|
||||
for component in fixed:
|
||||
name = component["id"]
|
||||
source = paths[name]
|
||||
actual = workspace.git.safe(source, component["repo"])
|
||||
commit = workspace.state.data["components"][name]["baseline"]
|
||||
if actual["head"] != commit or actual["branch"]:
|
||||
raise WorkspaceError(f"fixed source changed before local check: {name}")
|
||||
destination = area / name
|
||||
workspace.git.call(area, "clone", "--no-checkout", "--no-hardlinks", "--", str(source), str(destination))
|
||||
workspace.git.call(destination, "checkout", "--detach", commit)
|
||||
paths[name] = destination
|
||||
print(f"[component-check] fixed check snapshot {name} commit={commit}", flush=True)
|
||||
yield paths
|
||||
|
||||
|
||||
def check(root, target, overlay):
|
||||
root = root.resolve()
|
||||
if target in ("test-backend-pg", "test-multi-instance") and not os.environ.get("AGENT_PG_TEST_DSN"):
|
||||
raise WorkspaceError("AGENT_PG_TEST_DSN required; real PostgreSQL gate is not skipped")
|
||||
if target == "test-backend-pg" and not os.environ.get("AGENT_REDIS_TEST_ADDR"):
|
||||
raise WorkspaceError("AGENT_REDIS_TEST_ADDR required; real Redis gate is not skipped")
|
||||
workspace = Workspace(root)
|
||||
names = [SINGLE[target]] if target in SINGLE else (["Agent-Native", "Agent-Intent", "Agent-Squad"] if target == "test-libs" else ["Agent-System-Backend", "Agent-System-Frontend"])
|
||||
selected = [workspace.manifest.select(name)[0] for name in names]
|
||||
with workspace.state.operation():
|
||||
workspace.state = State(root)
|
||||
if any(component["role"] in ("go-library", "npm-library") for component in selected):
|
||||
for component in workspace.manifest.select(fixed=True):
|
||||
if component["role"] == "application":
|
||||
prepare_local_source(workspace, component)
|
||||
for component in selected:
|
||||
prepare_local_source(workspace, component)
|
||||
workspace.snapshot()
|
||||
env = process_environment(root)
|
||||
if overlay:
|
||||
workspace.go.check_owned()
|
||||
if not workspace.state.data.get("go_work") or not (root / "go.work").is_file():
|
||||
raise WorkspaceError("LOCAL_OVERLAY=1 requires a registered go.work; use component-edit first")
|
||||
env["GOWORK"] = str(root / "go.work")
|
||||
else:
|
||||
env["GOWORK"] = "off"
|
||||
print(f"[component-check] local developer sources; GOWORK={env['GOWORK']}; registered edit branches and unsaved code retained", flush=True)
|
||||
with check_sources(workspace, selected, target, overlay) as paths:
|
||||
run_checks(root, target, selected, names, paths, env)
|
||||
|
||||
|
||||
def run_checks(root, target, selected, names, paths, env):
|
||||
if target == "dev":
|
||||
print("Backend: make dev-backend [LOCAL_OVERLAY=1]\nFrontend: make dev-frontend\nPrivate module transport is scoped to those processes.")
|
||||
return
|
||||
path = paths[names[0]]
|
||||
if target == "dev-backend":
|
||||
run(["go", "run", "./cmd/agent-system", "serve", "--addr", "127.0.0.1:8787", "--data", str(root / "data"), "--plugins", str(root / "plugins")], path, env)
|
||||
elif target == "dev-frontend":
|
||||
run(["npm", "ci", "--ignore-scripts", "--no-fund", "--no-audit"], path, env)
|
||||
run(["npm", "run", "dev"], path, env)
|
||||
elif target == "test-multi-instance":
|
||||
suite = root / "tests/integration/multi-instance"
|
||||
binary = suite / ".build/agent-system"
|
||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
run(["go", "build", "-mod=readonly", "-o", str(binary), "./cmd/agent-system"], path, env | {"CGO_ENABLED": "0"})
|
||||
run(["go", "test", "-mod=readonly", "-count=1", "-timeout=15m", "."], suite, env | {"AGENT_BIN": str(binary)})
|
||||
elif target == "eval-deliverables":
|
||||
run(["go", "test", "-mod=readonly", "./eval/", "./internal/quality/", "-count=1"], path, env)
|
||||
elif target in ("test-frontend", "test-ui"):
|
||||
run(["npm", "ci", "--ignore-scripts", "--no-fund", "--no-audit"], path, env)
|
||||
if target == "test-ui":
|
||||
run(["npm", "exec", "--offline", "--", "tsc", "--noEmit"], path, env)
|
||||
run(["npm", "exec", "--offline", "--", "vitest", "run"], path, env)
|
||||
run(["node", "scripts/check-knip.mjs"], path, env)
|
||||
else:
|
||||
for component in selected:
|
||||
source = paths[component["id"]]
|
||||
modules = component.get("modules", []) if target == "test-libs" else [{"directory": "."}]
|
||||
for module in modules:
|
||||
directory = source / module.get("directory", ".")
|
||||
if target in ("test-libs", "test-agentcmd"):
|
||||
run(["go", "vet", "-mod=readonly", "./..."], directory, env)
|
||||
run(["go", "test", "-mod=readonly", "-count=1", "./..."], directory, env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--target", choices=(*SINGLE, "test-libs", "dev"), required=True)
|
||||
parser.add_argument("--overlay", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
check(args.root, args.target, args.overlay)
|
||||
except (WorkspaceError, OSError, ValueError) as error:
|
||||
print(f"component-check: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve governance inputs from a formal source map or the owner's checkout."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
LEGACY = {"Agent-System-Backend": "modules/backend", "Agent-System-Frontend": "modules/frontend",
|
||||
"Agent-System-Core": "modules/core", "Agent-System-UI": "modules/ui",
|
||||
"Agent-Native": "deps/agent-native", "Agent-Intent": "deps/agent-intent", "Agent-Squad": "deps/agent-squad"}
|
||||
SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
|
||||
|
||||
def source_path(root, name):
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name):
|
||||
raise ValueError("invalid component identifier")
|
||||
marker = os.environ.get("COMPOSITION_SOURCE_MAP")
|
||||
if not marker:
|
||||
relative = ".workspace/" + name if (root / "components.json").exists() else LEGACY[name]
|
||||
path = root / relative
|
||||
else:
|
||||
source_map = Path(marker)
|
||||
if not source_map.is_absolute() or source_map.is_symlink() or not source_map.is_file():
|
||||
raise ValueError("composition source map must be an absolute regular file")
|
||||
document = json.loads(source_map.read_text())
|
||||
if document.get("schema") != 1 or not SHA.fullmatch(str(document.get("root_commit", ""))):
|
||||
raise ValueError("invalid composition source map identity")
|
||||
manifest = json.loads((root / "components.json").read_text())
|
||||
declared = [entry for entry in manifest["components"] if entry.get("id") == name]
|
||||
item = document.get("components", {}).get(name)
|
||||
if len(declared) != 1 or not isinstance(item, dict) or item.get("repo") != declared[0].get("repo") or not SHA.fullmatch(str(item.get("commit", ""))):
|
||||
raise ValueError("invalid isolated component identity: " + name)
|
||||
locked = json.loads((root / "components.lock.json").read_text())["components"].get(name)
|
||||
if locked and item["commit"] != locked["commit"]:
|
||||
raise ValueError("isolated component differs from locked source: " + name)
|
||||
path = Path(item.get("path", ""))
|
||||
if not path.is_absolute():
|
||||
raise ValueError("isolated component path must be absolute: " + name)
|
||||
if not path.is_dir() or path.resolve() != path.absolute():
|
||||
raise ValueError("component source is missing or traverses a symbolic link: " + name)
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
if len(sys.argv) != 2:
|
||||
raise ValueError("usage: component-source.py <component-id>")
|
||||
print(source_path(Path(__file__).resolve().parents[1], sys.argv[1]))
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
print("component-source: " + str(error), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
+100
-48
@@ -1,22 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
|
||||
#
|
||||
# 动线(与 frontend/e2e/rdm-audit.spec.ts 的环境契约一一对应):
|
||||
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
|
||||
# + 基座 CLI(本 worktree modules/backend,建号用)
|
||||
# + frontend dist(本 worktree modules/frontend)
|
||||
# 动线(与 Frontend e2e/rdm-audit.playwright.ts 的环境契约一一对应):
|
||||
# 1. 构建:版本化候选从固定 Dev 组合取得 RDM、同源基座 CLI 与 Frontend dist;
|
||||
# 历史候选继续按原 gitlink 和 pins 路径物化。
|
||||
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
|
||||
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
|
||||
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
|
||||
# 4. 收尾:停 serve/preview,保存认证/失败工件到 $E2E_OUT
|
||||
#
|
||||
# 用法:bash scripts/e2e.sh [--seed-only]
|
||||
# 用法:bash scripts/e2e.sh [--seed-only|--build-only]
|
||||
# --seed-only 只做构建+种子+起服务(留端口与路径在 stdout,供人工/调试)
|
||||
# --build-only 只构建固定产物;不建号、不起进程、不运行浏览器或模型验收
|
||||
# 环境变量:
|
||||
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
|
||||
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
|
||||
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
|
||||
# E2E_PASSWORD2 首次登录后的设密结果(默认 E2eAudit#2026b;种子命令用它)
|
||||
# E2E_PASSWORD2 首次登录后的设密结果(默认 E2eAudit#2026b)
|
||||
# E2E_PROJECT_MAIN/E2E_PROJECT_ISOLATED 项目名(默认 审计演示项目/隔离检查项目)
|
||||
# E2E_OUT 工件目录(默认 .tmp/e2e/<ts>;.gitignore 已覆盖 .tmp)
|
||||
# 依赖:go、node/npm、curl;首次运行会 npx playwright install chromium。
|
||||
@@ -26,7 +26,13 @@ DEV_ROOT=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)
|
||||
cd "$DEV_ROOT"
|
||||
|
||||
SEED_ONLY=0
|
||||
[ "${1:-}" = "--seed-only" ] && SEED_ONLY=1
|
||||
BUILD_ONLY=0
|
||||
case "${1:-}" in
|
||||
--seed-only) SEED_ONLY=1 ;;
|
||||
--build-only) BUILD_ONLY=1 ;;
|
||||
"") ;;
|
||||
*) echo "usage: scripts/e2e.sh [--seed-only|--build-only]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
RDM_REPO=${RDM_REPO:-"$DEV_ROOT/../Agent-System-RDM"}
|
||||
API_PORT=${E2E_API_PORT:-18791}
|
||||
@@ -51,50 +57,101 @@ command -v curl >/dev/null || die "curl not found"
|
||||
[ "$(git -C "$RDM_REPO" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] \
|
||||
|| die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
|
||||
|
||||
if [ "$BUILD_ONLY" != 1 ]; then
|
||||
for p in "$API_PORT" "$UI_PORT"; do
|
||||
if timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null; then
|
||||
die "port $p already in use (set E2E_API_PORT/E2E_UI_PORT)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 1. 构建 ─────────────────────────────────────────────
|
||||
# RDM 源码经 git archive 从本地对象导出(不建 worktree、不触远端——Gitea 抖动时
|
||||
# submodule 克隆会挂死);pins/* 从 RDM 主检出的已物化 submodule 快照拷贝(cp 排除
|
||||
# .git,39MB 级),构建输入由此固定为 RDM origin/main + 其 gitlink 指向的本地内容。
|
||||
log "build RDM binary (archive origin/main + snapshot pins from local checkout)"
|
||||
RDM_WT="$OUT/rdm-src"
|
||||
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
|
||||
mkdir -p "$RDM_WT"
|
||||
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
|
||||
# 主检出 pins 对齐 gitlink(本地对象已有时秒回;此时才需要网络 fetch 一次)。
|
||||
git -C "$RDM_REPO" submodule update --init --recursive --quiet \
|
||||
|| die "materialize RDM pins failed (is the RDM checkout reachable?)"
|
||||
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
|
||||
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
|
||||
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] \
|
||||
|| die "RDM checkout pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT); git -C $RDM_REPO submodule update first"
|
||||
mkdir -p "$RDM_WT/pins"
|
||||
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
|
||||
API_PID=""
|
||||
UI_PID=""
|
||||
cleanup() {
|
||||
[ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; }
|
||||
# Preview runs directly as one owned Vite process. Verify its command before
|
||||
# signaling: a recycled PID must never stop another worktree's process.
|
||||
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | rg -Fq -- "vite preview --port $UI_PORT"; then
|
||||
if [ -n "${API_PID:-}" ]; then kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; fi
|
||||
# Preview is a single owned Vite process. Check its command before signaling
|
||||
# so a recycled PID cannot stop a process from another worktree.
|
||||
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | rg -Fq -- "vite preview --outDir $FRONTEND_DIST --port $UI_PORT"; then
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
|
||||
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
|
||||
|
||||
log "build base CLI (user create) from the same pinned backend as the RDM binary"
|
||||
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
|
||||
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
|
||||
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
|
||||
|| die "backend cli build failed"
|
||||
# ── 1. 构建 ─────────────────────────────────────────────
|
||||
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
|
||||
RDM_MODULE=$(git -C "$RDM_REPO" show "$RDM_SHA:go.mod" | awk '$1 == "module" { print $2; exit }')
|
||||
if [ -f "$DEV_ROOT/components.json" ] && [ "$RDM_MODULE" = "git-ssh.u1680832.nyat.app/weichen/Agent-System-RDM.git" ]; then
|
||||
log "build versioned RDM and matching base CLI through the fixed Dev composition"
|
||||
source "$DEV_ROOT/scripts/svc.sh"
|
||||
RUNTIME="$OUT"
|
||||
ENV_NAME=e2e-build
|
||||
RDM_SRC_DIR="$RDM_REPO"
|
||||
COMPOSITION_ROOT="$DEV_ROOT"
|
||||
RDM_WT=$(materialize_rdm_commit "$RDM_SHA" "${RDM_SHA:0:12}") || die "versioned RDM composition failed"
|
||||
cp "$RDM_WT/bin/agent-system-rdm" "$OUT/agent-system-rdm"
|
||||
cp "$RDM_WT/bin/agent-system-admin" "$OUT/agent-system"
|
||||
# Prepare the owner-controlled fixed source, then keep npm/cache/browser files
|
||||
# in this run's independent archive, rather than modifying its .workspace.
|
||||
python3 "$DEV_ROOT/scripts/workspace.py" --root "$DEV_ROOT" prepare --name Agent-System-Frontend >/dev/null || die "fixed Frontend preparation failed"
|
||||
python3 "$DEV_ROOT/scripts/workspace.py" --root "$DEV_ROOT" verify --name Agent-System-Frontend >/dev/null || die "Frontend differs from fixed source"
|
||||
FRONTEND_PREPARED=$(python3 "$DEV_ROOT/scripts/component-source.py" Agent-System-Frontend) || die "Frontend source identity unavailable"
|
||||
FRONTEND_DIR="$OUT/frontend-src"
|
||||
mkdir -p "$FRONTEND_DIR"
|
||||
git -C "$FRONTEND_PREPARED" archive HEAD | tar -x -C "$FRONTEND_DIR"
|
||||
python3 - "$DEV_ROOT" "$FRONTEND_DIR" <<'NPM_ENV'
|
||||
import importlib.util, pathlib, subprocess, sys
|
||||
root = pathlib.Path(sys.argv[1]); sys.path.insert(0, str(root / "scripts"))
|
||||
from workspace.cli import process_environment
|
||||
spec = importlib.util.spec_from_file_location("composer", root / "scripts/build-composition.py")
|
||||
composer = importlib.util.module_from_spec(spec); spec.loader.exec_module(composer)
|
||||
environment = composer.execution_environment(process_environment(root))
|
||||
sys.exit(subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=sys.argv[2], env=environment).returncode)
|
||||
NPM_ENV
|
||||
[ "$?" = 0 ] || die "credential-free npm acquisition failed"
|
||||
make -C "$DEV_ROOT" frontend >/dev/null || die "fixed frontend build failed"
|
||||
FRONTEND_DIST="$DEV_ROOT/bin/frontend-dist"
|
||||
else
|
||||
# Historical RDM pins and the old Dev layout retain their former build route.
|
||||
if [ -f "$DEV_ROOT/components.json" ]; then
|
||||
die "historical RDM e2e requires its historical Dev candidate; no mixed-layout success is claimed"
|
||||
fi
|
||||
FRONTEND_DIR="$DEV_ROOT/modules/frontend"
|
||||
FRONTEND_DIST="$FRONTEND_DIR/dist"
|
||||
# ── 1. 构建 ─────────────────────────────────────────────
|
||||
# RDM 源码经 git archive 从本地对象导出(不建 worktree、不触远端——Gitea 抖动时
|
||||
# submodule 克隆会挂死);pins/* 从 RDM 主检出的已物化 submodule 快照拷贝(cp 排除
|
||||
# .git,39MB 级),构建输入由此固定为 RDM origin/main + 其 gitlink 指向的本地内容。
|
||||
log "build RDM binary (archive origin/main + snapshot pins from local checkout)"
|
||||
RDM_WT="$OUT/rdm-src"
|
||||
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
|
||||
mkdir -p "$RDM_WT"
|
||||
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
|
||||
# 主检出 pins 对齐 gitlink(本地对象已有时秒回;此时才需要网络 fetch 一次)。
|
||||
git -C "$RDM_REPO" submodule update --init --recursive --quiet \
|
||||
|| die "materialize RDM pins failed (is the RDM checkout reachable?)"
|
||||
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
|
||||
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
|
||||
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] \
|
||||
|| die "RDM checkout pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT); git -C $RDM_REPO submodule update first"
|
||||
mkdir -p "$RDM_WT/pins"
|
||||
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
|
||||
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
|
||||
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
|
||||
|
||||
log "build frontend dist (contracts + ui deps + bundle, via make frontend)"
|
||||
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
|
||||
log "build base CLI (user create) from the same pinned backend as the RDM binary"
|
||||
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
|
||||
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
|
||||
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
|
||||
|| die "backend cli build failed"
|
||||
|
||||
log "build frontend dist (contracts + ui deps + bundle, via make frontend)"
|
||||
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
|
||||
fi
|
||||
[ -x "$OUT/agent-system-rdm" ] && [ -x "$OUT/agent-system" ] && [ -f "$FRONTEND_DIST/index.html" ] || die "complete e2e build artifacts are missing"
|
||||
if [ "$BUILD_ONLY" = 1 ]; then
|
||||
log "build-only complete; runtime/browser/model acceptance NOT_RUN; artifacts=$OUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. 种子 ─────────────────────────────────────────────
|
||||
DATA="$OUT/data"
|
||||
@@ -136,8 +193,6 @@ login() {
|
||||
done
|
||||
die "login failed"
|
||||
}
|
||||
# CLI 建号留下 must_change_password。会话在改密前不能调用业务命令。
|
||||
# 强制设密会作废当前会话,所以设密后用新密码再登录。
|
||||
finish_password_gate() {
|
||||
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -164,8 +219,6 @@ PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'impo
|
||||
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||
log "seed: projects main=$PID_MAIN iso=$PID_ISO"
|
||||
|
||||
# I1 requires a current business grant even for a project creator or system
|
||||
# administrator. Both projects are ordinary test fixtures in this private data root.
|
||||
log "seed: grant explicit audit.read for the fixture owner"
|
||||
cmd rdm.perm.cap '{"action":"audit.read","allow":true}' >/dev/null
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
|
||||
@@ -216,7 +269,7 @@ log "seed done"
|
||||
# ── 3. 验收 ─────────────────────────────────────────────
|
||||
if [ "$SEED_ONLY" = 1 ]; then
|
||||
log "seed-only mode: api=$API_BASE ui=next;cookie jar=$JAR;数据=$DATA"
|
||||
log "preview: (cd modules/frontend && PLAYWRIGHT_API_TARGET=$API_BASE npx vite preview --port $UI_PORT)"
|
||||
log "preview: (cd $FRONTEND_DIR && PLAYWRIGHT_API_TARGET=$API_BASE npx vite preview --port $UI_PORT)"
|
||||
log "inspect: curl -b $JAR $API_BASE/api/commands/rdm.inspect.summary -X POST -H 'Content-Type: application/json' -d '{\"params\":{\"projectId\":\"$PID_MAIN\"}}'"
|
||||
wait
|
||||
exit 0
|
||||
@@ -224,9 +277,9 @@ fi
|
||||
|
||||
log "start vite preview at http://127.0.0.1:$UI_PORT"
|
||||
(
|
||||
cd "$DEV_ROOT/modules/frontend"
|
||||
cd "$FRONTEND_DIR"
|
||||
export PLAYWRIGHT_API_TARGET="$API_BASE"
|
||||
exec ./node_modules/.bin/vite preview --port "$UI_PORT" --strictPort
|
||||
exec ./node_modules/.bin/vite preview --outDir "$FRONTEND_DIST" --port "$UI_PORT" --strictPort
|
||||
) >"$OUT/preview.log" 2>&1 &
|
||||
UI_PID=$!
|
||||
for i in $(seq 1 40); do
|
||||
@@ -236,15 +289,14 @@ for i in $(seq 1 40); do
|
||||
done
|
||||
|
||||
log "playwright (install chromium if missing)"
|
||||
(cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true)
|
||||
(cd "$FRONTEND_DIR" && npx playwright install chromium >/dev/null 2>&1 || true)
|
||||
|
||||
log "run specs"
|
||||
cd "$DEV_ROOT/modules/frontend"
|
||||
cd "$FRONTEND_DIR"
|
||||
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" \
|
||||
E2E_PASSWORD="${E2E_PASSWORD:-E2eAudit#2026}" E2E_PASSWORD2="$PASSWORD2" \
|
||||
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
|
||||
set +e
|
||||
# 本脚本的种子只覆盖审计工作区。u7 现场验收用例依赖另一套入口,不在这里跑。
|
||||
npx playwright test e2e/rdm-audit.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
|
||||
PW_EXIT=$?
|
||||
set -e
|
||||
|
||||
+57
-2
@@ -82,6 +82,52 @@ def common_dir():
|
||||
return str(Path(git_text("rev-parse", "--git-common-dir")).resolve())
|
||||
|
||||
|
||||
def component_snapshot(root):
|
||||
"""Record ignored ordinary checkouts without resolving or changing versions."""
|
||||
manifest = root / 'components.json'
|
||||
if not manifest.exists():
|
||||
return None
|
||||
try:
|
||||
config = json.loads(manifest.read_text())
|
||||
if config.get('schema') != 1 or not isinstance(config.get('components'), list):
|
||||
raise EvidenceError('Invalid component manifest')
|
||||
result = {'manifest_sha256': digest(manifest.read_bytes()), 'components': {}}
|
||||
lock = root / 'components.lock.json'
|
||||
result['lock_sha256'] = digest(lock.read_bytes()) if lock.exists() else None
|
||||
state = root / '.workspace/state.json'
|
||||
result['state_sha256'] = digest(state.read_bytes()) if state.exists() else None
|
||||
for item in config['components']:
|
||||
name, relative = item.get('id'), item.get('path')
|
||||
if not isinstance(name, str) or not isinstance(relative, str) or relative != '.workspace/' + name:
|
||||
raise EvidenceError('Invalid component identity/path')
|
||||
path = root / relative
|
||||
if path.resolve() != path.absolute() or root.resolve() not in path.resolve().parents:
|
||||
raise EvidenceError('Component path escapes its owner')
|
||||
if not path.exists():
|
||||
result['components'][name] = {'state': 'missing'}
|
||||
continue
|
||||
if not (path / '.git').exists():
|
||||
raise EvidenceError('Component source has no Git identity')
|
||||
if Path(git_text('-C', str(path), 'rev-parse', '--show-toplevel')).resolve() != path.resolve():
|
||||
raise EvidenceError('Component Git identity is borrowed')
|
||||
untracked = {}
|
||||
for raw in git('-C', str(path), 'ls-files', '--others', '--exclude-standard', '-z').split(b'\0'):
|
||||
if raw:
|
||||
filename = os.fsdecode(raw)
|
||||
entry = path / filename
|
||||
untracked[filename] = digest(os.fsencode(os.readlink(entry))) if entry.is_symlink() else (digest(entry.read_bytes()) if entry.is_file() else None)
|
||||
result['components'][name] = {
|
||||
'head': git_text('-C', str(path), 'rev-parse', 'HEAD'),
|
||||
'branch': git_text('-C', str(path), 'branch', '--show-current'),
|
||||
'status': git('-C', str(path), 'status', '--porcelain', '--untracked-files=all').decode('utf-8', 'replace'),
|
||||
'tracked_diff_sha256': digest(git('-C', str(path), 'diff', '--binary', 'HEAD', '--')),
|
||||
'untracked': untracked,
|
||||
}
|
||||
return result
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
raise EvidenceError('Component object could not be recorded') from error
|
||||
|
||||
|
||||
def snapshot():
|
||||
untracked = {}
|
||||
root = Path(git_text("rev-parse", "--show-toplevel"))
|
||||
@@ -98,7 +144,7 @@ def snapshot():
|
||||
else:
|
||||
untracked[name] = None
|
||||
target = os.environ.get("TASK_TARGET_BRANCH", "main")
|
||||
return {
|
||||
result = {
|
||||
"head": git_text("rev-parse", "HEAD"),
|
||||
"branch": git_text("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
"target_branch": target,
|
||||
@@ -109,6 +155,10 @@ def snapshot():
|
||||
"untracked": untracked,
|
||||
"submodules": git("submodule", "status", "--recursive").decode("utf-8", "replace"),
|
||||
}
|
||||
components = component_snapshot(root)
|
||||
if components is not None:
|
||||
result['components'] = components
|
||||
return result
|
||||
|
||||
|
||||
def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
|
||||
@@ -135,8 +185,10 @@ def validate_snapshot(value):
|
||||
raise EvidenceError("Invalid receipt object snapshot")
|
||||
expected = {"head", "branch", "target_branch", "target_head", "status",
|
||||
"tracked_diff_sha256", "submodules", "untracked"}
|
||||
if set(value) != expected:
|
||||
if set(value) not in (expected, expected | {'components'}):
|
||||
raise EvidenceError("Invalid receipt object fields")
|
||||
if 'components' in value and (not isinstance(value['components'], dict) or not isinstance(value['components'].get('components'), dict)):
|
||||
raise EvidenceError('Invalid component snapshot')
|
||||
for key in ("head", "branch", "target_branch", "status", "tracked_diff_sha256", "submodules"):
|
||||
if not isinstance(value.get(key), str):
|
||||
raise EvidenceError("Invalid receipt object field: " + key)
|
||||
@@ -252,6 +304,9 @@ def validate_precheck(receipt, purpose="start"):
|
||||
raise EvidenceError("Precheck object capture is incomplete")
|
||||
if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]:
|
||||
raise EvidenceError("Precheck source HEAD has changed")
|
||||
if current.get("components") is not None and any(
|
||||
obj.get("components") != current["components"] for obj in (receipt["before"], after)):
|
||||
raise EvidenceError("Precheck ordinary component objects have changed or are missing")
|
||||
if after["target_branch"] != current["target_branch"] or not after["target_head"]:
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
if purpose != "resume-local" and after["target_head"] != current["target_head"]:
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -115,7 +116,11 @@ func checkPolicy(root string) error {
|
||||
return err
|
||||
}
|
||||
text := string(raw)
|
||||
for _, need := range []string{"modules/backend/internal", "modules/frontend/src", "components"} {
|
||||
backend, frontend := "modules/backend/internal", "modules/frontend/src"
|
||||
if _, err := os.Stat(filepath.Join(root, "components.json")); err == nil {
|
||||
backend, frontend = ".workspace/Agent-System-Backend/internal", ".workspace/Agent-System-Frontend/src"
|
||||
}
|
||||
for _, need := range []string{backend, frontend, "components"} {
|
||||
if !strings.Contains(text, need) {
|
||||
return fmt.Errorf("policy/architecture.yaml missing %s", need)
|
||||
}
|
||||
@@ -123,14 +128,36 @@ func checkPolicy(root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func componentRoot(root, id, legacy string) (string, error) {
|
||||
if os.Getenv("COMPOSITION_SOURCE_MAP") != "" {
|
||||
output, err := exec.Command("python3", filepath.Join(root, "scripts/component-source.py"), id).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("isolated component lookup failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "components.json")); err == nil {
|
||||
return filepath.Join(root, ".workspace", id), nil
|
||||
}
|
||||
return filepath.Join(root, legacy), nil
|
||||
}
|
||||
|
||||
func collect(root string) ([]string, error) {
|
||||
var edges []string
|
||||
be, err := backendEdges(filepath.Join(root, "modules/backend/internal"))
|
||||
backend, err := componentRoot(root, "Agent-System-Backend", "modules/backend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frontend, err := componentRoot(root, "Agent-System-Frontend", "modules/frontend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
be, err := backendEdges(filepath.Join(backend, "internal"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges = append(edges, be...)
|
||||
fe, err := frontendEdges(filepath.Join(root, "modules/frontend/src"))
|
||||
fe, err := frontendEdges(filepath.Join(frontend, "src"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,8 +166,26 @@ func collect(root string) ([]string, error) {
|
||||
}
|
||||
|
||||
func backendEdges(dir string) ([]string, error) {
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("backend source directory is unavailable: %s", dir)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(dir), "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := ""
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == "module" {
|
||||
prefix = fields[1] + "/internal/"
|
||||
break
|
||||
}
|
||||
}
|
||||
if prefix == "" {
|
||||
return nil, fmt.Errorf("backend module identity is unavailable")
|
||||
}
|
||||
var edges []string
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
@@ -158,7 +203,6 @@ func backendEdges(dir string) ([]string, error) {
|
||||
}
|
||||
for _, imp := range f.Imports {
|
||||
p := strings.Trim(imp.Path.Value, `"`)
|
||||
const prefix = "agent-system-backend/internal/"
|
||||
if !strings.HasPrefix(p, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -4,23 +4,24 @@
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
base="$root/policy/architecture-baseline.json"
|
||||
tool=(go run "$root/scripts/lint-architecture.go" -root "$root" -baseline "$base")
|
||||
tool=(env GOWORK=off go run "$root/scripts/lint-architecture.go" -root "$root" -baseline "$base")
|
||||
|
||||
( cd "$root/scripts" && "${tool[@]}" )
|
||||
|
||||
probe=$(mktemp)
|
||||
trap 'rm -f "$probe"' EXIT
|
||||
set +e
|
||||
( cd "$root/scripts" && "${tool[@]}" -inject 'backend/store->backend/__architecture_probe__' ) >/tmp/architecture-probe.out 2>&1
|
||||
( cd "$root/scripts" && "${tool[@]}" -inject 'backend/store->backend/__architecture_probe__' ) >"$probe" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -eq 0 ]; then
|
||||
echo "architecture: injected edge was accepted" >&2
|
||||
cat /tmp/architecture-probe.out >&2
|
||||
cat "$probe" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '__architecture_probe__' /tmp/architecture-probe.out; then
|
||||
if ! grep -q '__architecture_probe__' "$probe"; then
|
||||
echo "architecture: injected edge did not fail for the expected reason" >&2
|
||||
cat /tmp/architecture-probe.out >&2
|
||||
cat "$probe" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/architecture-probe.out
|
||||
echo "architecture negative ok"
|
||||
|
||||
+71
-53
@@ -1,60 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# 依赖边界:公开扩展 SDK 与示例不得导入后端 internal。
|
||||
# pkg/extension/link 是唯一例外:下游空白导入它才能把 extension.Main 接到产品组合根。
|
||||
# Public SDK isolation and sibling-library ratchets use the fixed composition map
|
||||
# when supplied; a manual invocation uses sources prepared by the checkout owner.
|
||||
set -euo pipefail
|
||||
command -v rg >/dev/null 2>&1 || { echo "boundary: requires ripgrep (rg)" >&2; exit 2; }
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
fail=0
|
||||
source_path() { python3 "$root/scripts/component-source.py" "$1"; }
|
||||
module_path() {
|
||||
local mod
|
||||
mod=$(awk '/^module /{print $2; exit}' "$1/go.mod")
|
||||
[ -n "$mod" ] || { echo "boundary: source lacks a module identity: $1" >&2; return 1; }
|
||||
printf '%s\n' "$mod"
|
||||
}
|
||||
backend=$(source_path Agent-System-Backend)
|
||||
intent=$(source_path Agent-Intent)
|
||||
native=$(source_path Agent-Native)
|
||||
squad=$(source_path Agent-Squad)
|
||||
backend_mod=$(module_path "$backend")
|
||||
intent_mod=$(module_path "$intent")
|
||||
native_mod=$(module_path "$native")
|
||||
squad_mod=$(module_path "$squad")
|
||||
|
||||
check() {
|
||||
local path="$1"
|
||||
if [ -d "$path" ] && grep -R -n --include='*.go' --include='*.ts' --include='*.tsx' 'agent-system-backend/internal' "$path" >/dev/null; then
|
||||
echo "boundary: $path imports agent-system-backend/internal" >&2
|
||||
grep -R -n --include='*.go' --include='*.ts' --include='*.tsx' 'agent-system-backend/internal' "$path" >&2
|
||||
if [ -d "$path" ] && rg -n --glob '*.go' --glob '*.ts' --glob '*.tsx' -F \
|
||||
-e "$backend_mod/internal" -e 'agent-system-backend/internal' "$path" >/dev/null; then
|
||||
echo "boundary: $path imports backend internal" >&2
|
||||
rg -n --glob '*.go' --glob '*.ts' --glob '*.tsx' -F \
|
||||
-e "$backend_mod/internal" -e 'agent-system-backend/internal' "$path" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
sdk="$root/modules/backend/pkg/extension"
|
||||
sdk="$backend/pkg/extension"
|
||||
if [ -d "$sdk" ]; then
|
||||
hits=$(grep -R -n --include='*.go' 'agent-system-backend/internal' "$sdk" | grep -v '/link/' || true)
|
||||
hits=$(rg -n --glob '*.go' --glob '!link/**' -F -e "$backend_mod/internal" \
|
||||
-e 'agent-system-backend/internal' "$sdk" | { grep -v '/link/' || true; } || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "boundary: pkg/extension imports agent-system-backend/internal" >&2
|
||||
echo "boundary: pkg/extension imports backend internal" >&2
|
||||
echo "$hits" >&2
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
check "$root/examples"
|
||||
|
||||
# 库间依赖棘轮:三个库的包导入兄弟库的配对,相对白名单只许减少、不许新增。
|
||||
# Preserve the allowlist's semantic package names when public module prefixes
|
||||
# change. No baseline is regenerated during dependency migration.
|
||||
normalize() {
|
||||
local value="$1" idx
|
||||
for idx in 0 1 2; do
|
||||
case "$value" in
|
||||
"${canonical[$idx]}") printf '%s' "${legacy[$idx]}"; return ;;
|
||||
"${canonical[$idx]}"/*) printf '%s%s' "${legacy[$idx]}" "${value#"${canonical[$idx]}"}"; return ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$value"
|
||||
}
|
||||
canonical=("$intent_mod" "$native_mod" "$squad_mod")
|
||||
legacy=(agent-intent agent-native agent-squad)
|
||||
siblings=("${canonical[@]}" "${legacy[@]}")
|
||||
allow="$root/policy/lib-deps-allowlist.txt"
|
||||
observed=$(mktemp)
|
||||
recorded=$(mktemp)
|
||||
trap 'rm -f "$observed" "$recorded"' EXIT
|
||||
siblings=(agent-intent agent-native agent-squad)
|
||||
for dir in deps/agent-intent deps/agent-native deps/agent-squad; do
|
||||
mod=$(awk '/^module /{print $2; exit}' "$root/$dir/go.mod")
|
||||
(
|
||||
cd "$root/scripts"
|
||||
go run lint-imports.go "$root/$dir"
|
||||
) | while read -r importer rest; do
|
||||
imports=$(mktemp)
|
||||
trap 'rm -f "$observed" "$recorded" "$imports"' EXIT
|
||||
for dir in "$intent" "$native" "$squad"; do
|
||||
mod=$(module_path "$dir")
|
||||
(cd "$root/scripts" && GOWORK=off go run lint-imports.go "$dir") > "$imports"
|
||||
while read -r importer rest; do
|
||||
for imp in $rest; do
|
||||
for sib in "${siblings[@]}"; do
|
||||
case "$imp" in
|
||||
"$sib"|"$sib"/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case "$importer" in
|
||||
"$mod"|"$mod"/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "$sib" != "$mod" ]; then
|
||||
echo "$importer $imp"
|
||||
case "$imp" in "$sib"|"$sib"/*) ;; *) continue ;; esac
|
||||
case "$importer" in "$mod"|"$mod"/*) ;; *) continue ;; esac
|
||||
if [ "$(normalize "$sib")" != "$(normalize "$mod")" ]; then
|
||||
printf '%s %s\n' "$(normalize "$importer")" "$(normalize "$imp")"
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
done | sort -u >"$observed"
|
||||
grep -v -e '^#' -e '^$' "$allow" | sort -u >"$recorded" || true
|
||||
done < "$imports"
|
||||
done | sort -u > "$observed"
|
||||
grep -v -e '^#' -e '^$' "$allow" | sort -u > "$recorded" || true
|
||||
new_pairs=$(comm -13 "$recorded" "$observed" || true)
|
||||
if [ -n "$new_pairs" ]; then
|
||||
echo "boundary: new sibling-library import pairs" >&2
|
||||
@@ -67,34 +92,27 @@ if [ -n "$stale_pairs" ]; then
|
||||
echo "$stale_pairs" >&2
|
||||
fi
|
||||
|
||||
# 核心模块的依赖图不得出现兄弟库。go list ./... 不进入带 go.mod 的子目录。
|
||||
for dir in deps/agent-intent deps/agent-native deps/agent-squad; do
|
||||
mod=$(awk '/^module /{print $2; exit}' "$root/$dir/go.mod")
|
||||
deps_out=$(cd "$root/$dir" && GOWORK=off go list -deps ./...) || fail=1
|
||||
for dir in "$intent" "$native" "$squad"; do
|
||||
mod=$(module_path "$dir")
|
||||
deps_out=$(cd "$dir" && GOWORK=off go list -deps ./...) || fail=1
|
||||
for sib in "${siblings[@]}"; do
|
||||
[ "$sib" = "$mod" ] && continue
|
||||
if printf '%s\n' "$deps_out" | grep -E -q "^(${sib}|${sib}/)"; then
|
||||
echo "boundary: $mod core depends on $sib" >&2
|
||||
fail=1
|
||||
fi
|
||||
[ "$(normalize "$sib")" = "$(normalize "$mod")" ] && continue
|
||||
while IFS= read -r dep; do
|
||||
case "$dep" in
|
||||
"$sib"|"$sib"/*) echo "boundary: $mod core depends on $sib" >&2; fail=1 ;;
|
||||
esac
|
||||
done <<< "$deps_out"
|
||||
done
|
||||
done
|
||||
|
||||
# agent-intent 只许被小队意图适配器和后端识别适配器导入。
|
||||
while IFS= read -r f; do
|
||||
grep -q '"agent-intent/' "$f" || continue
|
||||
case "$f" in
|
||||
*/deps/agent-squad/adapters/intent/*|*/modules/backend/internal/intentgate/*|*/modules/backend/internal/intentsem/*) ;;
|
||||
*)
|
||||
echo "boundary: $f imports agent-intent" >&2
|
||||
fail=1
|
||||
;;
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
"$squad"/adapters/intent/*|"$backend"/internal/intentgate/*|"$backend"/internal/intentsem/*) ;;
|
||||
*) echo "boundary: $file imports agent-intent" >&2; fail=1 ;;
|
||||
esac
|
||||
done < <(find "$root/deps/agent-native" "$root/deps/agent-squad" "$root/modules/backend" \
|
||||
-path '*/deps/*' -prune -o -name '*.go' -print)
|
||||
done < <(rg -l --glob '*.go' --glob '!**/deps/**' -F -e "\"$intent_mod\"" \
|
||||
-e "\"$intent_mod/" -e '"agent-intent"' -e '"agent-intent/' "$native" "$squad" "$backend" || true)
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
bash "$root/scripts/lint-architecture.sh"
|
||||
echo "boundaries ok"
|
||||
|
||||
+114
-22
@@ -2247,6 +2247,68 @@ svc_deploy_recycle_wt() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Source format belongs to the candidate being built, not the caller's checkout.
|
||||
svc_versioned_rdm() {
|
||||
[ -f "$1/go.mod" ] && [ "$(awk '$1 == "module" { print $2; exit }' "$1/go.mod")" = "git-ssh.u1680832.nyat.app/weichen/Agent-System-RDM.git" ]
|
||||
}
|
||||
|
||||
svc_check_build_worktree() {
|
||||
local wt="$1" expected="$2" actual marker path
|
||||
actual=$(git -C "$wt" rev-parse HEAD) || return 1
|
||||
[ "$actual" = "$expected" ] || { c_err "candidate SHA mismatch: expected=$expected actual=$actual; cached worktree retained"; return 1; }
|
||||
[ "$(git -C "$wt" rev-parse --show-toplevel)" = "$wt" ] || { c_err "candidate worktree identity mismatch; preserve its owner"; return 1; }
|
||||
for marker in index.lock HEAD.lock MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply BISECT_LOG; do
|
||||
path=$(git -C "$wt" rev-parse --path-format=absolute --git-path "$marker") || return 1
|
||||
[ ! -e "$path" ] || { c_err "candidate Git operation/lock active: $marker; preserve its owner"; return 1; }
|
||||
done
|
||||
if [ -f "$wt/components.json" ] || svc_versioned_rdm "$wt"; then
|
||||
[ -z "$(GIT_OPTIONAL_LOCKS=0 git -C "$wt" status --porcelain --untracked-files=all --ignore-submodules=all)" ] \
|
||||
|| { c_err "candidate source is dirty; preserve it before retrying the fixed build"; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
svc_frontend_dist() {
|
||||
local root="$1"
|
||||
if [ -f "$root/components.json" ]; then
|
||||
python3 - "$root/bin/resolved-manifest.json" "$(git -C "$root" rev-parse HEAD)" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
record = json.load(open(sys.argv[1]))
|
||||
if record.get("target") != "frontend" or record.get("root", {}).get("commit") != sys.argv[2]:
|
||||
raise ValueError("frontend composition identity mismatch")
|
||||
except (OSError, ValueError) as error:
|
||||
print("svc: " + str(error), file=sys.stderr); sys.exit(1)
|
||||
PY
|
||||
[ "$?" = 0 ] || return 1
|
||||
printf '%s\n' "$root/bin/frontend-dist"
|
||||
else
|
||||
printf '%s\n' "$root/modules/frontend/dist"
|
||||
fi
|
||||
}
|
||||
|
||||
svc_composition_plugins() {
|
||||
local root="$1" present
|
||||
present=$(python3 - "$root/bin/resolved-manifest.json" "$(git -C "$root" rev-parse HEAD)" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
record = json.load(open(sys.argv[1]))
|
||||
if record.get("target") != "build" or record.get("root", {}).get("commit") != sys.argv[2]:
|
||||
raise ValueError("build composition identity mismatch")
|
||||
print("yes" if "plugins" in record.get("assets", {}) else "no")
|
||||
except (OSError, ValueError) as error:
|
||||
print("svc: " + str(error), file=sys.stderr); sys.exit(1)
|
||||
PY
|
||||
) || return 1
|
||||
if [ "$present" = yes ]; then
|
||||
[ -d "$root/bin/plugins" ] || { c_err "composed plugins are missing"; return 1; }
|
||||
printf '%s\n' "$root/bin/plugins"
|
||||
else
|
||||
mkdir -p "$root/.svc-empty-plugins"
|
||||
[ -z "$(find "$root/.svc-empty-plugins" -mindepth 1 -print -quit)" ] || { c_err "empty plugin source contains unique files; preserve it"; return 1; }
|
||||
printf '%s\n' "$root/.svc-empty-plugins"
|
||||
fi
|
||||
}
|
||||
|
||||
# materialize_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree
|
||||
# 路径(提示信息走 stderr)。TARGET=build(agent)| frontend(static)。
|
||||
materialize_dev_commit() {
|
||||
@@ -2258,12 +2320,16 @@ materialize_dev_commit() {
|
||||
c_info "[$ENV_NAME] 物化 Dev 提交 $sha → 隔离 worktree(源:独立部署源仓 $SRC_DIR,不触碰当前工作区)"
|
||||
git -C "$SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
|
||||
fi
|
||||
c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)"
|
||||
# stdout 只留给最后的 worktree 路径(调用方 $() 捕获)——子模块/构建输出全部走 stderr。
|
||||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||||
c_err "[$ENV_NAME] 子模块物化失败(网络或对象缺失;重试会自动 fetch 部署源仓)"
|
||||
return 1
|
||||
}
|
||||
svc_check_build_worktree "$wt" "$full" || return 1
|
||||
if [ -f "$wt/components.json" ]; then
|
||||
c_info "[$ENV_NAME] 固定源码清单组合(隔离装配;不初始化旧 gitlinks)"
|
||||
else
|
||||
c_info "[$ENV_NAME] 物化历史子模块组合(git submodule update --init --recursive)"
|
||||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||||
c_err "[$ENV_NAME] 历史子模块物化失败;构建 worktree 保留供重试"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
c_info "[$ENV_NAME] 构建(make $target:npm ci + 构建,需网络)"
|
||||
make -C "$wt" "$target" >&2 || return 1
|
||||
echo "$wt"
|
||||
@@ -2273,9 +2339,19 @@ materialize_dev_commit() {
|
||||
recycle_worktree() {
|
||||
local repo="${2:-$SRC_DIR}"
|
||||
local wt="$RUNTIME/.worktrees/$1"
|
||||
c_info "[$ENV_NAME] 回收构建 worktree"
|
||||
git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 \
|
||||
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $repo worktree remove --force $wt"
|
||||
[ -d "$wt" ] || return 0
|
||||
if [ -n "$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" ] \
|
||||
|| ! svc_check_build_worktree "$wt" "$(git -C "$wt" rev-parse HEAD 2>/dev/null)"; then
|
||||
c_warn "[$ENV_NAME] 构建 worktree 属于分支或有活动操作,保留现场:$wt"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$(GIT_OPTIONAL_LOCKS=0 git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null)" ]; then
|
||||
c_warn "[$ENV_NAME] 构建 worktree 含未保存或 ignored 产物,保留现场供 owner 检查:$wt"
|
||||
return 0
|
||||
fi
|
||||
c_info "[$ENV_NAME] 回收干净构建 worktree(无 force)"
|
||||
git -C "$repo" worktree remove "$wt" >/dev/null 2>&1 \
|
||||
|| c_warn "[$ENV_NAME] Git 拒绝回收;保留 worktree 与元数据供 owner 检查:$wt"
|
||||
}
|
||||
|
||||
# materialize_rdm_commit FULL SHA:RDM 源的隔离 worktree 物化+构建;stdout 只回显
|
||||
@@ -2290,13 +2366,25 @@ materialize_rdm_commit() {
|
||||
c_info "[$ENV_NAME] 物化 RDM 提交 $sha → 隔离 worktree(源:独立 RDM 源仓 $RDM_SRC_DIR,不触碰当前工作区)"
|
||||
git -C "$RDM_SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
|
||||
fi
|
||||
c_info "[$ENV_NAME] 物化基座 pins(git submodule update --init --recursive;缺对象时需网络)"
|
||||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||||
c_err "[$ENV_NAME] pins 子模块物化失败(网络或对象缺失;重试会自动 fetch RDM 源仓)"
|
||||
return 1
|
||||
}
|
||||
c_info "[$ENV_NAME] 构建(make build:纯 Go,无需 npm)"
|
||||
make -C "$wt" build >&2 || return 1
|
||||
svc_check_build_worktree "$wt" "$full" || return 1
|
||||
if svc_versioned_rdm "$wt"; then
|
||||
local composition_root="${COMPOSITION_ROOT:-$ROOT}"
|
||||
composition_root=$(realpath -e -- "$composition_root") || { c_err "COMPOSITION_ROOT is unavailable"; return 1; }
|
||||
[ -f "$composition_root/components.json" ] && [ -f "$composition_root/components.lock.json" ] && [ -f "$composition_root/scripts/build-composition.py" ] || {
|
||||
c_err "[$ENV_NAME] 版本化 RDM 需要 COMPOSITION_ROOT 指向固定 Dev 候选;不回落旧 pins"
|
||||
return 1
|
||||
}
|
||||
c_info "[$ENV_NAME] 构建固定 RDM 产品(COMPOSITION_ROOT=$composition_root;不初始化旧 pins)"
|
||||
make -C "$wt" COMPOSITION_ROOT="$composition_root" build >&2 || return 1
|
||||
else
|
||||
c_info "[$ENV_NAME] 物化历史基座 pins(git submodule update --init --recursive)"
|
||||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||||
c_err "[$ENV_NAME] 历史 pins 物化失败;构建 worktree 保留供重试"
|
||||
return 1
|
||||
}
|
||||
c_info "[$ENV_NAME] 构建历史 RDM 候选(make build)"
|
||||
make -C "$wt" build >&2 || return 1
|
||||
fi
|
||||
[ -x "$wt/bin/agent-system-rdm" ] || {
|
||||
c_err "[$ENV_NAME] 构建产物缺失:$wt/bin/agent-system-rdm(RDM Makefile 的 build 目标应产出该名称)"
|
||||
return 1
|
||||
@@ -2678,11 +2766,15 @@ agent_do_deploy() {
|
||||
hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"
|
||||
return 1
|
||||
}
|
||||
srcbin="$BIN"; srcplug="$ROOT/plugins" ;;
|
||||
srcbin="$BIN"
|
||||
if [ -f "$ROOT/components.json" ]; then srcplug=$(svc_composition_plugins "$ROOT") || return 1
|
||||
else srcplug="$ROOT/plugins"; fi ;;
|
||||
dev)
|
||||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||||
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins" ;;
|
||||
srcbin="$wt/bin/agent-system"
|
||||
if [ -f "$wt/components.json" ]; then srcplug=$(svc_composition_plugins "$wt") || return 1
|
||||
else srcplug="$wt/plugins"; fi ;;
|
||||
release)
|
||||
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||||
if [ ! -f "$relbin" ]; then
|
||||
@@ -2803,7 +2895,7 @@ print_agent_plan() {
|
||||
printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;;
|
||||
dev)
|
||||
printf ' 来源:%s(完整提交 %s;独立部署源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$SRC_DIR"
|
||||
printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" ;;
|
||||
printf ' 物化:worktree %s(按候选格式选择历史 gitlinks 或固定源码装配 + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" ;;
|
||||
release)
|
||||
printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")"
|
||||
[ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT"
|
||||
@@ -2812,7 +2904,7 @@ print_agent_plan() {
|
||||
else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art"; fi ;;
|
||||
rdm)
|
||||
printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR"
|
||||
printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
|
||||
printf ' 物化:worktree %s(按候选格式选择历史 pins 或 COMPOSITION_ROOT 固定产品装配)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
|
||||
printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN"
|
||||
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
|
||||
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
|
||||
@@ -3359,12 +3451,12 @@ static_do_deploy() {
|
||||
local)
|
||||
c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)"
|
||||
make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; }
|
||||
src_dist="$ROOT/modules/frontend/dist"
|
||||
src_dist=$(svc_frontend_dist "$ROOT") || return 1
|
||||
;;
|
||||
dev)
|
||||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||||
src_dist="$wt/modules/frontend/dist"
|
||||
src_dist=$(svc_frontend_dist "$wt") || return 1
|
||||
;;
|
||||
release)
|
||||
c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir"
|
||||
|
||||
+22
-9
@@ -19,6 +19,13 @@ fetch_target() {
|
||||
|| { echo "[verdict] REMOTE_UNVERIFIED — target commit missing" >&2; return 6; }
|
||||
}
|
||||
check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; }
|
||||
check_startup_rules() {
|
||||
if ! check_worktree rules; then
|
||||
echo "[verdict] RULES_UNVERIFIED — fixed startup reading unavailable; preserve the workspace and retry" >&2
|
||||
return 7
|
||||
fi
|
||||
}
|
||||
|
||||
main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; }
|
||||
work_mode() {
|
||||
case "${TASK_WORK_MODE:-delivery}" in
|
||||
@@ -121,6 +128,7 @@ cmd_precheck() {
|
||||
fi
|
||||
if [ "$mode" = local ]; then
|
||||
python3 "${SCRIPT_DIR}/evidence.py" --local-context || return 2
|
||||
check_startup_rules || return $?
|
||||
echo "[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks"
|
||||
return 0
|
||||
fi
|
||||
@@ -132,6 +140,7 @@ cmd_precheck() {
|
||||
echo "[verdict] SYNC_REQUIRED — ${branch} 缺少 origin/${TARGET_BRANCH} 的 ${behind} 个提交;主检出 pull --ff-only,任务分支 make task-sync"
|
||||
exit 4
|
||||
fi
|
||||
check_startup_rules || return $?
|
||||
echo "[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
|
||||
}
|
||||
|
||||
@@ -176,14 +185,17 @@ cmd_start() {
|
||||
wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}"
|
||||
[ -e "$wt" ] && die "worktree path exists: $wt"
|
||||
git worktree add -q "$wt" -b "$name" "$start_ref"
|
||||
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
|
||||
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
|
||||
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
|
||||
|| echo "[warn] submodule update 非零退出,见下方状态核对"
|
||||
local sub_drift
|
||||
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
|
||||
[ "${sub_drift:-0}" -gt 0 ] \
|
||||
&& echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对(git -C $wt submodule status --recursive)"
|
||||
if [ -f "$wt/components.json" ]; then
|
||||
(cd "$wt" && check_worktree materialize) || die "fixed sources unavailable; task worktree retained for retry"
|
||||
else
|
||||
# Historical commits keep the original submodule materializer.
|
||||
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
|
||||
|| echo "[warn] submodule update 非零退出,见下方状态核对"
|
||||
local sub_drift
|
||||
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
|
||||
[ "${sub_drift:-0}" -gt 0 ] \
|
||||
&& echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对"
|
||||
fi
|
||||
local card="$wt/.task/${slug}.md"
|
||||
mkdir -p "$wt/.task"
|
||||
cat > "$card" <<EOF
|
||||
@@ -310,6 +322,7 @@ cmd_resume() {
|
||||
local receipt
|
||||
receipt=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
check_startup_rules || return $?
|
||||
IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")"
|
||||
}
|
||||
@@ -357,7 +370,7 @@ cmd_sync() {
|
||||
git add "$card"
|
||||
git commit -q -m "Record the sync checkpoint" \
|
||||
-m "Why: bind the task validation checkpoint to the integration baseline." \
|
||||
-m "Evidence: SELF recursive component checks; sync_base=${base}; before_record=${head}." \
|
||||
-m "Evidence: SELF component ownership and fixed-source checks; sync_base=${base}; before_record=${head}." \
|
||||
-m "Impact: task metadata only." -m "Refs: ${card}" -- "$card"
|
||||
fi
|
||||
# JSON receipts carry before/after state; older table rows remain readable.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Public entry point for fixed sources and local component development."""
|
||||
from workspace.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Safe, local component source workspaces; language manifests own dependencies."""
|
||||
@@ -0,0 +1,282 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .common import WorkspaceError, safe_path, write_json
|
||||
from .git import Git
|
||||
from .go import Go
|
||||
from .manifest import Manifest, validate_branch
|
||||
from .npm import Npm
|
||||
from .state import State
|
||||
from .transition import Transition
|
||||
|
||||
|
||||
def process_environment(root):
|
||||
try:
|
||||
from .transport import environment
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "workspace.transport":
|
||||
raise
|
||||
result = os.environ.copy()
|
||||
result.update({"GOWORK": "off", "GOTOOLCHAIN": "local"})
|
||||
else:
|
||||
result = environment(root)
|
||||
# Read-only reporting does not refresh another owner's Git index.
|
||||
result["GIT_OPTIONAL_LOCKS"] = "0"
|
||||
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
|
||||
result.pop(name, None)
|
||||
return result
|
||||
|
||||
|
||||
class Workspace:
|
||||
def __init__(self, root):
|
||||
self.root = root.resolve()
|
||||
self.manifest = Manifest(self.root)
|
||||
self.state = State(self.root)
|
||||
self.env = process_environment(self.root)
|
||||
self.git = Git(self.env)
|
||||
self.go = Go(self)
|
||||
self.npm = Npm(self)
|
||||
self.transition = Transition(self)
|
||||
|
||||
def baseline(self, component):
|
||||
name = component["id"]
|
||||
if component["role"] == "go-library":
|
||||
resolved = self.go.resolve(component)
|
||||
if resolved:
|
||||
return resolved
|
||||
elif component["role"] == "npm-library":
|
||||
resolved = self.npm.resolve(component)
|
||||
if resolved:
|
||||
return resolved
|
||||
elif name in self.manifest.lock:
|
||||
return self.manifest.lock[name]["commit"], {"kind": "source-lock"}
|
||||
legacy = self.git.gitlink(self.root, component.get("legacy_path"))
|
||||
if legacy:
|
||||
return legacy, {"kind": "legacy-gitlink", "path": component.get("legacy_path")}
|
||||
raise WorkspaceError(f"no fixed version source for {name}; latest main is not a fallback")
|
||||
|
||||
def record(self, component, commit, source):
|
||||
record = {"path": component["path"], "origin": component["repo"], "baseline": commit,
|
||||
"source": source, "owner_root": str(self.root), "mode": "preparing", "linked": False}
|
||||
self.state.data["components"][component["id"]] = record
|
||||
return record
|
||||
|
||||
def refresh_versioned(self, component, path, record, commit, source):
|
||||
old_commit = record["baseline"]
|
||||
old_source = record["source"]
|
||||
reference = "refs/workspace/fixed/" + old_commit
|
||||
try:
|
||||
self.git.call(path, "fetch", "--no-tags", "origin", commit)
|
||||
# Fetch can take time. Recheck ownership and files before changing
|
||||
# this registered checkout, without forcing any owner's changes.
|
||||
actual = self.git.safe(path, component["repo"])
|
||||
if actual["branch"] or actual["head"] != old_commit:
|
||||
raise WorkspaceError("fixed checkout changed during fetch; owner must inspect it")
|
||||
saved = self.git.text(path, "rev-parse", "--verify", "--quiet", reference, check=False)
|
||||
if saved and saved != old_commit:
|
||||
raise WorkspaceError("preserved fixed reference has another value; owner must inspect it")
|
||||
if not saved:
|
||||
self.git.call(path, "update-ref", reference, old_commit, "0" * 40)
|
||||
self.git.call(path, "checkout", "--no-overwrite-ignore", "--detach", commit)
|
||||
actual = self.git.safe(path, component["repo"])
|
||||
if actual["head"] != commit or actual["branch"]:
|
||||
raise WorkspaceError("refreshed checkout differs from the declared fixed source")
|
||||
history = list(record.get("previous_sources", []))
|
||||
previous = {"commit": old_commit, "source": old_source, "ref": reference}
|
||||
if previous not in history:
|
||||
history.append(previous)
|
||||
record.update({"baseline": commit, "source": source, "branch": None,
|
||||
"mode": "versioned", "previous_sources": history})
|
||||
record.pop("error", None)
|
||||
self.state.save()
|
||||
return record
|
||||
except Exception as error:
|
||||
# In particular, a failed fetch retains the old baseline and mode,
|
||||
# so retrying the explicit refresh cannot mistake it for a partial clone.
|
||||
record["error"] = str(error)
|
||||
self.state.save()
|
||||
raise
|
||||
|
||||
def prepare_one(self, component, refresh_fixed=False):
|
||||
name = component["id"]
|
||||
path = safe_path(self.root, component["path"])
|
||||
commit, source = self.baseline(component)
|
||||
record = self.state.data["components"].get(name)
|
||||
if record and (record["path"] != component["path"] or record["origin"] != component["repo"]):
|
||||
raise WorkspaceError(f"registered component identity changed: {name}")
|
||||
if path.exists():
|
||||
editable = record and record.get("mode") in ("editable", "linked")
|
||||
partial = record and record.get("mode") == "preparing"
|
||||
actual = self.git.safe(path, component["repo"], clean=refresh_fixed or not (editable or partial))
|
||||
if refresh_fixed:
|
||||
if not record or record.get("mode") != "versioned" or actual["branch"] or actual["head"] != record["baseline"]:
|
||||
raise WorkspaceError(f"fixed refresh requires a registered detached versioned checkout at its baseline: {name}")
|
||||
if actual["head"] != commit:
|
||||
return self.refresh_versioned(component, path, record, commit, source)
|
||||
if editable:
|
||||
if record["baseline"] != commit:
|
||||
raise WorkspaceError(f"version changed while {name} is editable; owner must synchronize explicitly")
|
||||
if actual["branch"] != record.get("branch"):
|
||||
raise WorkspaceError(f"registered edit branch changed: {name}")
|
||||
return record
|
||||
if partial:
|
||||
if any(entry.name != ".git" for entry in path.iterdir()):
|
||||
raise WorkspaceError(f"partial clone contains files: {name}; preserve them for its owner")
|
||||
else:
|
||||
if actual["branch"] or actual["head"] != commit:
|
||||
raise WorkspaceError(f"existing checkout differs from fixed source: {name}; no automatic checkout")
|
||||
if record is None:
|
||||
record = self.record(component, commit, source)
|
||||
record.update({"mode": "versioned", "baseline": commit, "source": source})
|
||||
record.pop("error", None)
|
||||
self.state.save()
|
||||
return record
|
||||
elif record and record.get("mode") != "preparing":
|
||||
raise WorkspaceError(f"registered source is missing: {name}; owner must resolve it")
|
||||
if record is None:
|
||||
record = self.record(component, commit, source)
|
||||
record.update({"baseline": commit, "source": source, "mode": "preparing"})
|
||||
self.state.save()
|
||||
try:
|
||||
if not path.exists():
|
||||
self.git.call(self.root, "clone", "--no-checkout", "--", component["repo"], str(path))
|
||||
self.git.call(path, "fetch", "--no-tags", "origin", commit)
|
||||
self.git.call(path, "checkout", "--detach", commit)
|
||||
self.git.safe(path, component["repo"])
|
||||
record.update({"mode": "versioned", "branch": None})
|
||||
record.pop("error", None)
|
||||
self.state.save()
|
||||
return record
|
||||
except Exception as error:
|
||||
record["error"] = str(error)
|
||||
self.state.save()
|
||||
raise
|
||||
|
||||
def edit(self, component, branch):
|
||||
self.go.check_owned()
|
||||
if component["role"] in ("go-library", "npm-library"):
|
||||
for consumer in self.manifest.select():
|
||||
if consumer["role"] == "application" and (consumer.get("modules") or component["role"] == "npm-library"):
|
||||
path = safe_path(self.root, consumer["path"])
|
||||
if path.exists():
|
||||
self.git.safe(path, consumer["repo"], clean=False)
|
||||
else:
|
||||
self.prepare_one(consumer)
|
||||
name = component["id"]
|
||||
record = self.prepare_one(component)
|
||||
path = safe_path(self.root, component["path"])
|
||||
branch = branch or "feature/workspace-" + name.lower()
|
||||
validate_branch(branch)
|
||||
if record.get("mode") in ("editable", "linked"):
|
||||
if record.get("branch") != branch:
|
||||
raise WorkspaceError(f"component already belongs to {record.get('branch')}; no branch replacement")
|
||||
self.git.safe(path, component["repo"], clean=False)
|
||||
else:
|
||||
self.git.safe(path, component["repo"])
|
||||
if self.git.call(path, "show-ref", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0:
|
||||
raise WorkspaceError(f"branch already exists: {branch}; owner must choose its checkout")
|
||||
self.git.call(path, "checkout", "-b", branch, record["baseline"])
|
||||
record.update({"mode": "editable", "branch": branch})
|
||||
self.state.save()
|
||||
record["linked"] = bool(self.go.modules(component))
|
||||
if record["linked"]:
|
||||
record["mode"] = "linked"
|
||||
self.go.update()
|
||||
self.state.save()
|
||||
|
||||
def unlink(self, component):
|
||||
self.go.check_owned()
|
||||
record = self.state.data["components"].get(component["id"])
|
||||
if record is None:
|
||||
raise WorkspaceError(f"component is not registered: {component['id']}")
|
||||
self.git.safe(safe_path(self.root, component["path"]), component["repo"], clean=False)
|
||||
record["linked"] = False
|
||||
if record.get("mode") == "linked":
|
||||
record["mode"] = "editable"
|
||||
self.go.update()
|
||||
self.state.save()
|
||||
|
||||
def status(self, components):
|
||||
result = {"schema": 1, "root": str(self.root), "components": {},
|
||||
"go_work": self.state.data.get("go_work")}
|
||||
for component in components:
|
||||
name = component["id"]
|
||||
record = self.state.data["components"].get(name)
|
||||
entry = {"path": component["path"], "repo": component["repo"], "role": component["role"],
|
||||
"declared": self.manifest.lock.get(name), "registered": record}
|
||||
path = safe_path(self.root, component["path"])
|
||||
if path.exists():
|
||||
try:
|
||||
entry["actual"] = self.git.inspect(path)
|
||||
except WorkspaceError as error:
|
||||
entry["error"] = str(error)
|
||||
result["components"][name] = entry
|
||||
return result
|
||||
|
||||
def verify(self, components):
|
||||
self.go.check_owned()
|
||||
for component in components:
|
||||
record = self.state.data["components"].get(component["id"])
|
||||
if not record or record.get("linked"):
|
||||
raise WorkspaceError(f"fixed source missing or development overlay active: {component['id']}")
|
||||
actual = self.git.safe(safe_path(self.root, component["path"]), component["repo"])
|
||||
baseline, _ = self.baseline(component)
|
||||
if actual["head"] != baseline or actual["branch"]:
|
||||
raise WorkspaceError(f"fixed source differs from declared version: {component['id']}")
|
||||
|
||||
def snapshot(self):
|
||||
write_json(safe_path(self.root, ".workspace/resolved.json"), self.status(self.manifest.select()))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Fixed component sources and explicit local editing")
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||
parser.add_argument("command", choices=("prepare", "edit", "unlink", "status", "verify", "transition"))
|
||||
parser.add_argument("--name")
|
||||
parser.add_argument("--branch")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--refresh-fixed", action="store_true", help="explicitly refresh registered clean detached fixed sources")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.branch:
|
||||
validate_branch(args.branch)
|
||||
if args.command in ("edit", "unlink") and not args.name:
|
||||
raise WorkspaceError("--name is required for component editing/unlinking")
|
||||
if args.apply and args.command != "transition":
|
||||
raise WorkspaceError("--apply is only valid for transition")
|
||||
if args.refresh_fixed and args.command != "prepare":
|
||||
raise WorkspaceError("--refresh-fixed is only valid for prepare")
|
||||
workspace = Workspace(args.root)
|
||||
components = workspace.manifest.select(args.name, fixed=args.command in ("prepare", "verify"))
|
||||
if args.command == "status":
|
||||
result = workspace.status(components)
|
||||
elif args.command == "transition" and not args.apply:
|
||||
result = {"schema": 1, "components": {component["id"]: {"legacy": workspace.transition.inspect(component)} for component in components}}
|
||||
elif args.command == "verify":
|
||||
workspace.verify(components)
|
||||
result = workspace.status(components)
|
||||
else:
|
||||
with workspace.state.operation():
|
||||
# Read state again under the operation lock: another process may have
|
||||
# finished between initial inspection and lock acquisition.
|
||||
workspace.state = State(workspace.root)
|
||||
if args.command == "transition":
|
||||
workspace.transition.apply_all(components, args.branch)
|
||||
for component in components:
|
||||
if args.command == "prepare":
|
||||
workspace.prepare_one(component, refresh_fixed=args.refresh_fixed)
|
||||
elif args.command == "edit":
|
||||
workspace.edit(component, args.branch)
|
||||
elif args.command == "unlink":
|
||||
workspace.unlink(component)
|
||||
workspace.snapshot()
|
||||
result = workspace.status(components)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except (WorkspaceError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
print(f"workspace: {error}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -0,0 +1,75 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run(args, cwd, env=None, check=True):
|
||||
result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True,
|
||||
timeout=180)
|
||||
if check and result.returncode:
|
||||
raise WorkspaceError(f"{args[0]} failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}")
|
||||
return result
|
||||
|
||||
|
||||
def relative_path(value, allow_dot=False):
|
||||
if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value):
|
||||
raise WorkspaceError("invalid relative path")
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or ".." in path.parts or (value == "." and not allow_dot):
|
||||
raise WorkspaceError(f"unsafe relative path: {value}")
|
||||
return path
|
||||
|
||||
|
||||
def safe_path(root, value, allow_dot=False):
|
||||
relative_path(value, allow_dot)
|
||||
path = root / value
|
||||
# Refuse symlink path components even when a link currently points inside root.
|
||||
# This avoids creating a second apparent owner and later retargeting escapes.
|
||||
current = root
|
||||
for part in PurePosixPath(value).parts:
|
||||
current /= part
|
||||
if current.is_symlink():
|
||||
raise WorkspaceError(f"symlink path is not owned by this workspace: {value}")
|
||||
if not path.resolve().is_relative_to(root.resolve()):
|
||||
raise WorkspaceError(f"path escapes workspace: {value}")
|
||||
return path
|
||||
|
||||
|
||||
def read_json(path):
|
||||
if path.is_symlink():
|
||||
raise WorkspaceError(f"refusing symlink metadata: {path}")
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, ValueError) as error:
|
||||
raise WorkspaceError(f"cannot read {path.name}: {error}") from error
|
||||
|
||||
|
||||
def atomic_write(path, content):
|
||||
if path.is_symlink():
|
||||
raise WorkspaceError(f"refusing symlink output: {path}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + "-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .common import WorkspaceError, run
|
||||
|
||||
|
||||
def display_origin(value):
|
||||
parsed = urlsplit(value)
|
||||
netloc = parsed.netloc
|
||||
if parsed.username and (parsed.scheme != "ssh" or parsed.username != "git" or parsed.password):
|
||||
netloc = "<redacted>@" + netloc.rsplit("@", 1)[-1]
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path,
|
||||
"<redacted>" if parsed.query else "", "<redacted>" if parsed.fragment else ""))
|
||||
|
||||
|
||||
class Git:
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def call(self, path, *args, check=True):
|
||||
return run(["git", "-c", "gc.auto=0", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", *args], path, self.env, check)
|
||||
|
||||
def text(self, path, *args, check=True):
|
||||
return self.call(path, *args, check=check).stdout.strip()
|
||||
|
||||
def inspect(self, path):
|
||||
if not (path / ".git").exists():
|
||||
raise WorkspaceError(f"existing path is not an independent Git checkout: {path}")
|
||||
top = Path(self.text(path, "rev-parse", "--show-toplevel"))
|
||||
if top.resolve() != path.resolve():
|
||||
raise WorkspaceError("component path is not a Git root")
|
||||
gitdir = Path(self.text(path, "rev-parse", "--absolute-git-dir"))
|
||||
common = Path(self.text(path, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
||||
locks = sorted(str(file) for directory in {gitdir, common} for file in directory.rglob("*.lock"))
|
||||
operations = [name for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG")
|
||||
if (gitdir / name).exists()]
|
||||
changes = self.text(path, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
ignored = self.text(path, "ls-files", "--others", "--ignored", "--exclude-standard")
|
||||
head = self.text(path, "rev-parse", "--verify", "HEAD", check=False)
|
||||
branch = self.text(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
|
||||
worktrees = self.text(path, "worktree", "list", "--porcelain")
|
||||
return {"head": head or None, "branch": branch or None,
|
||||
"origin": display_origin(self.text(path, "config", "--get", "remote.origin.url", check=False)),
|
||||
"dirty": bool(changes or ignored), "changes": changes.splitlines(), "ignored": ignored.splitlines(),
|
||||
"locks": locks, "operations": operations,
|
||||
"worktrees": [line[9:] for line in worktrees.splitlines() if line.startswith("worktree ")],
|
||||
"git_dir": str(gitdir), "common_dir": str(common)}
|
||||
|
||||
def safe(self, path, origin=None, clean=True, independent=True):
|
||||
actual = self.inspect(path)
|
||||
if origin is not None and actual["origin"] != origin:
|
||||
raise WorkspaceError(f"origin mismatch at {path}")
|
||||
if actual["locks"] or actual["operations"]:
|
||||
raise WorkspaceError(f"Git operation/lock in progress at {path}")
|
||||
if independent and (actual["git_dir"] != actual["common_dir"] or len(actual["worktrees"]) != 1):
|
||||
raise WorkspaceError(f"shared Git worktree ownership at {path}; owner must resolve it")
|
||||
if independent and ((path / ".git").is_symlink() or not (path / ".git").is_dir()
|
||||
or Path(actual["git_dir"]).resolve() != (path / ".git").resolve()):
|
||||
raise WorkspaceError(f"component Git metadata is owned outside its checkout: {path}")
|
||||
if clean and actual["dirty"]:
|
||||
raise WorkspaceError(f"dirty or ignored unique files at {path}; owner must save them")
|
||||
return actual
|
||||
|
||||
def gitlink(self, root, legacy):
|
||||
if not legacy:
|
||||
return None
|
||||
result = self.text(root, "ls-tree", "HEAD", "--", legacy, check=False)
|
||||
if result.startswith("160000 commit "):
|
||||
return result.split()[2]
|
||||
return None
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .common import WorkspaceError, atomic_write, digest, run, safe_path
|
||||
from .manifest import SHA
|
||||
|
||||
|
||||
class Go:
|
||||
def __init__(self, workspace):
|
||||
self.workspace = workspace
|
||||
|
||||
def call(self, path, *args):
|
||||
result = run(["go", *args], path, self.workspace.env)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError as error:
|
||||
raise WorkspaceError("Go returned invalid module metadata") from error
|
||||
|
||||
def modules(self, component):
|
||||
result = []
|
||||
base = safe_path(self.workspace.root, component["path"])
|
||||
for module in component.get("modules", []):
|
||||
directory = safe_path(base, module.get("directory", "."), allow_dot=True)
|
||||
if (directory / "go.mod").is_file():
|
||||
document = self.call(directory, "mod", "edit", "-json")
|
||||
if document["Module"]["Path"] not in (module["path"], module.get("legacy")):
|
||||
raise WorkspaceError(f"module identity mismatch at {directory}")
|
||||
result.append((module, directory, document))
|
||||
return result
|
||||
|
||||
def resolve(self, component):
|
||||
selected = []
|
||||
consumers = sorted(self.workspace.manifest.select(), key=lambda c: ("backend" not in c["id"].lower(), c["id"]))
|
||||
for consumer in consumers:
|
||||
if consumer["role"] != "application":
|
||||
continue
|
||||
for consumer_module, directory, document in self.modules(consumer):
|
||||
if document["Module"]["Path"] != consumer_module["path"]:
|
||||
# An old short-name graph still belongs to the old gitlinks.
|
||||
continue
|
||||
# Package management can update go.sum even when source merely
|
||||
# needs inspection. Resolve an identical isolated manifest so
|
||||
# both editable and historical consumers stay untouched.
|
||||
with tempfile.TemporaryDirectory(prefix="workspace-go-resolve-") as temporary:
|
||||
isolated = Path(temporary)
|
||||
shutil.copy2(directory / "go.mod", isolated / "go.mod")
|
||||
if (directory / "go.sum").exists():
|
||||
shutil.copy2(directory / "go.sum", isolated / "go.sum")
|
||||
output = run(["go", "list", "-mod=mod", "-m", "-json", "all"], isolated, self.workspace.env).stdout
|
||||
graph = {}
|
||||
decoder = json.JSONDecoder()
|
||||
while output.strip():
|
||||
metadata, end = decoder.raw_decode(output.lstrip())
|
||||
output = output.lstrip()[end:]
|
||||
graph[metadata["Path"]] = metadata
|
||||
for module in component.get("modules", []):
|
||||
metadata = graph.get(module["path"])
|
||||
if not metadata:
|
||||
continue
|
||||
if metadata.get("Replace"):
|
||||
raise WorkspaceError(f"local replacement cannot establish version baseline: {module['path']}")
|
||||
version = metadata.get("Version")
|
||||
if not version:
|
||||
raise WorkspaceError("Go module has no fixed selected version")
|
||||
download = self.call(isolated, "mod", "download", "-json", module["path"] + "@" + version)
|
||||
origin = download.get("Origin") or {}
|
||||
if not origin and download.get('Info'):
|
||||
# Cached downloads may omit Origin in stdout; the
|
||||
# Go-owned .info file retains the authenticated source.
|
||||
cached = json.loads(Path(download['Info']).read_text())
|
||||
origin = cached.get('Origin') or {}
|
||||
commit = origin.get("Hash", "")
|
||||
if origin.get("VCS") != "git" or not SHA.fullmatch(commit):
|
||||
raise WorkspaceError(f"Go module has no verifiable Origin.Hash: {module['path']}")
|
||||
if origin.get("URL") != component["repo"]:
|
||||
# Canonical discovery uses HTTPS; transport maps that
|
||||
# exact registered source to SSH, without a new identity.
|
||||
canonical = "https://" + module["path"].split(".git", 1)[0]
|
||||
if origin.get("URL") not in {canonical, canonical + '.git', component['repo'].removesuffix('.git')}:
|
||||
raise WorkspaceError(f"Go Origin URL does not match approved repository: {module['path']}")
|
||||
selected.append((commit, {"kind": "go-module", "module": module["path"],
|
||||
"version": version, "consumer": consumer["id"], "origin": origin}))
|
||||
if not selected:
|
||||
return None
|
||||
if len({entry[0] for entry in selected}) != 1:
|
||||
raise WorkspaceError(f"consumer/submodule versions select different commits for {component['id']}; owner must choose compatible versions")
|
||||
commit, source = selected[0]
|
||||
source = dict(source, consumers=sorted({entry[1]["consumer"] for entry in selected}),
|
||||
selections=[entry[1] for entry in selected])
|
||||
return commit, source
|
||||
|
||||
def check_owned(self):
|
||||
path = safe_path(self.workspace.root, "go.work")
|
||||
recorded = self.workspace.state.data.get("go_work")
|
||||
if path.exists() and (not recorded or digest(path) != recorded.get("sha256")):
|
||||
raise WorkspaceError("go.work is not the registered generated overlay; preserve manual changes")
|
||||
if recorded and not path.exists():
|
||||
raise WorkspaceError("registered go.work is missing; owner must resolve overlay state")
|
||||
|
||||
def update(self):
|
||||
self.check_owned()
|
||||
paths = set()
|
||||
linked_modules = set()
|
||||
cached = {}
|
||||
|
||||
def modules(component):
|
||||
if component["id"] not in cached:
|
||||
cached[component["id"]] = self.modules(component)
|
||||
return cached[component["id"]]
|
||||
|
||||
for name, entry in self.workspace.state.data["components"].items():
|
||||
if entry.get("linked"):
|
||||
for _, directory, document in modules(self.workspace.manifest.components[name]):
|
||||
paths.add(directory)
|
||||
linked_modules.add(document["Module"]["Path"])
|
||||
for consumer in entry.get("source", {}).get("consumers", []):
|
||||
for _, directory, document in modules(self.workspace.manifest.components[consumer]):
|
||||
paths.add(directory)
|
||||
linked_modules.add(document["Module"]["Path"])
|
||||
if linked_modules:
|
||||
# Include only consuming modules, including transitive local consumers.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for component in self.workspace.manifest.select():
|
||||
for _, directory, document in modules(component):
|
||||
required = {item["Path"] for item in document.get("Require") or []}
|
||||
if required & linked_modules and directory not in paths:
|
||||
paths.add(directory)
|
||||
linked_modules.add(document["Module"]["Path"])
|
||||
changed = True
|
||||
path = safe_path(self.workspace.root, "go.work")
|
||||
if not paths:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
self.workspace.state.data.pop("go_work", None)
|
||||
return
|
||||
version = run(["go", "env", "GOVERSION"], self.workspace.root, self.workspace.env).stdout.strip().removeprefix("go")
|
||||
content = "go " + version + "\n\nuse (\n" + "".join("\t" + json.dumps("./" + str(item.relative_to(self.workspace.root))) + "\n" for item in sorted(paths)) + ")\n"
|
||||
documents = [(directory, document) for rows in cached.values() for _, directory, document in rows if directory in paths]
|
||||
directories = {document["Module"]["Path"]: directory for directory, document in documents}
|
||||
aliases = {entry["Old"]["Path"] for _, document in documents for entry in document.get("Replace") or []
|
||||
if not entry["New"].get("Version") and entry["Old"]["Path"] in directories}
|
||||
versions = {(entry["Path"], entry["Version"]) for _, document in documents for entry in document.get("Require") or []
|
||||
if entry["Path"] in aliases}
|
||||
versions.update((entry["Old"]["Path"], entry["Old"]["Version"]) for _, document in documents
|
||||
for entry in document.get("Replace") or []
|
||||
if entry["Old"]["Path"] in aliases and entry["Old"].get("Version"))
|
||||
# Historical modules can replace the same library with different nested
|
||||
# paths. Exact-version local overrides reconcile those paths while Go
|
||||
# forbids an all-version replacement of a workspace main module.
|
||||
if versions:
|
||||
content += "\nreplace (\n" + "".join("\t" + name + " " + selected + " => " + json.dumps("./" + str(directories[name].relative_to(self.workspace.root))) + "\n"
|
||||
for name, selected in sorted(versions)) + ")\n"
|
||||
atomic_write(path, content)
|
||||
self.workspace.state.data["go_work"] = {"sha256": digest(path), "paths": [str(item.relative_to(self.workspace.root)) for item in sorted(paths)]}
|
||||
@@ -0,0 +1,94 @@
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .common import WorkspaceError, read_json, relative_path, safe_path
|
||||
|
||||
ROLES = {"application", "go-library", "npm-library", "documentation", "guidelines"}
|
||||
FIXED_ROLES = {"application", "documentation", "guidelines"}
|
||||
SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
||||
|
||||
|
||||
def validate_repo(repo):
|
||||
if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo):
|
||||
raise WorkspaceError("invalid component repository")
|
||||
if "://" in repo:
|
||||
parsed = urlsplit(repo)
|
||||
if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment:
|
||||
raise WorkspaceError("repository must be a credential-free approved Git URL")
|
||||
if parsed.scheme == "https" and parsed.username:
|
||||
raise WorkspaceError("repository credentials are forbidden")
|
||||
if parsed.scheme == "ssh" and parsed.username not in (None, "git"):
|
||||
raise WorkspaceError("unsupported SSH repository identity")
|
||||
elif not repo.startswith("/"):
|
||||
raise WorkspaceError("repository must be an absolute local path or approved URL")
|
||||
|
||||
|
||||
class Manifest:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
document = read_json(root / "components.json")
|
||||
if not isinstance(document, dict) or document.get("schema") != 1 or not isinstance(document.get("components"), list):
|
||||
raise WorkspaceError("unsupported components schema")
|
||||
self.components = {}
|
||||
modules = set()
|
||||
for component in document["components"]:
|
||||
if not isinstance(component, dict):
|
||||
raise WorkspaceError("invalid component record")
|
||||
name = component.get("id")
|
||||
if not isinstance(name, str) or not IDENTIFIER.fullmatch(name) or name in self.components:
|
||||
raise WorkspaceError("invalid or duplicate component id")
|
||||
if component.get("path") != ".workspace/" + name:
|
||||
raise WorkspaceError(f"component path must be .workspace/{name}")
|
||||
safe_path(root, component["path"])
|
||||
if component.get("legacy_path"):
|
||||
safe_path(root, component["legacy_path"])
|
||||
if component.get("role") not in ROLES:
|
||||
raise WorkspaceError(f"invalid component role: {name}")
|
||||
package = component.get("package_name")
|
||||
if package is not None and (component["role"] != "npm-library" or not isinstance(package, str)
|
||||
or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*", package)):
|
||||
raise WorkspaceError(f"invalid npm package mapping: {name}")
|
||||
validate_repo(component.get("repo"))
|
||||
target = component.get("target", "main")
|
||||
validate_ref(target)
|
||||
entries = component.get("modules", [])
|
||||
if not isinstance(entries, list):
|
||||
raise WorkspaceError("modules must be a list")
|
||||
for module in entries:
|
||||
if not isinstance(module, dict) or not isinstance(module.get("path"), str) or not module["path"]:
|
||||
raise WorkspaceError("invalid module mapping")
|
||||
if module["path"] in modules or any(c.isspace() for c in module["path"]):
|
||||
raise WorkspaceError("duplicate or invalid module identity")
|
||||
relative_path(module.get("directory", "."), allow_dot=True)
|
||||
modules.add(module["path"])
|
||||
self.components[name] = component
|
||||
lock = read_json(root / "components.lock.json")
|
||||
if not isinstance(lock, dict) or lock.get("schema") != 1 or not isinstance(lock.get("components"), dict):
|
||||
raise WorkspaceError("unsupported component lock schema")
|
||||
self.lock = lock["components"]
|
||||
for name, value in self.lock.items():
|
||||
if name not in self.components or not isinstance(value, dict) or not SHA.fullmatch(str(value.get("commit", ""))):
|
||||
raise WorkspaceError(f"invalid locked component: {name}")
|
||||
if self.components[name]["role"] in ("go-library", "npm-library"):
|
||||
raise WorkspaceError(f"language library {name} cannot have a competing source lock")
|
||||
|
||||
def select(self, name=None, fixed=False):
|
||||
if name:
|
||||
if name not in self.components:
|
||||
raise WorkspaceError(f"unknown component: {name}")
|
||||
return [self.components[name]]
|
||||
return [entry for entry in self.components.values() if not fixed or entry["role"] in FIXED_ROLES]
|
||||
|
||||
|
||||
def validate_ref(ref):
|
||||
if not isinstance(ref, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", ref):
|
||||
raise WorkspaceError("invalid branch/ref name")
|
||||
if any(token in ref for token in ("..", "//", "/.", ".lock")) or ref.endswith(("/", ".")):
|
||||
raise WorkspaceError("invalid branch/ref name")
|
||||
|
||||
|
||||
def validate_branch(branch):
|
||||
validate_ref(branch)
|
||||
if not branch.startswith(("feature/", "fix/", "chore/", "codex/")):
|
||||
raise WorkspaceError("editing requires a named task branch")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Resolve editable npm source from a consumer's Git package lock, without npm hooks."""
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
|
||||
from .common import WorkspaceError, read_json, safe_path
|
||||
from .manifest import SHA, validate_repo
|
||||
|
||||
|
||||
def repository_identity(value):
|
||||
value = value.removeprefix("git+")
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme == "file":
|
||||
if parsed.netloc not in ("", "localhost"):
|
||||
raise WorkspaceError("npm Git origin must be local or approved")
|
||||
return str(Path(unquote(parsed.path)).resolve())
|
||||
validate_repo(value)
|
||||
return value
|
||||
|
||||
|
||||
class Npm:
|
||||
def __init__(self, workspace):
|
||||
self.workspace = workspace
|
||||
|
||||
def resolve(self, component):
|
||||
package = component.get("package_name")
|
||||
if not package:
|
||||
return None
|
||||
selected = []
|
||||
consumers = sorted(self.workspace.manifest.select(), key=lambda entry: ("frontend" not in entry["id"].lower(), entry["id"]))
|
||||
for consumer in consumers:
|
||||
if consumer["role"] != "application":
|
||||
continue
|
||||
root = safe_path(self.workspace.root, consumer["path"])
|
||||
path = safe_path(root, "package-lock.json")
|
||||
if not path.exists():
|
||||
continue
|
||||
lock = read_json(path)
|
||||
if not isinstance(lock, dict) or lock.get("lockfileVersion") not in (2, 3) or not isinstance(lock.get("packages"), dict):
|
||||
raise WorkspaceError(f"unsupported npm lock for {consumer['id']}")
|
||||
for location, entry in lock["packages"].items():
|
||||
if location != "node_modules/" + package and not location.endswith("/node_modules/" + package):
|
||||
continue
|
||||
if not isinstance(entry, dict) or entry.get("link"):
|
||||
raise WorkspaceError(f"local npm link cannot establish version baseline: {package}")
|
||||
resolved = entry.get("resolved", "")
|
||||
if not isinstance(resolved, str) or not resolved.startswith("git+"):
|
||||
raise WorkspaceError(f"npm source needs a fixed approved Git origin: {package}")
|
||||
parsed = urlsplit(resolved)
|
||||
if not SHA.fullmatch(parsed.fragment):
|
||||
raise WorkspaceError(f"npm Git source must pin a full commit: {package}")
|
||||
origin = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, parsed.query, ""))
|
||||
if repository_identity(origin) != repository_identity(component["repo"]):
|
||||
raise WorkspaceError(f"npm Git origin mismatch: {package}")
|
||||
selected.append((parsed.fragment, {"kind": "npm-lock", "package": package,
|
||||
"version": entry.get("version"), "resolved": resolved,
|
||||
"consumer": consumer["id"], "lock_path": str(path.relative_to(self.workspace.root))}))
|
||||
if not selected:
|
||||
return None
|
||||
if len({entry[0] for entry in selected}) != 1:
|
||||
raise WorkspaceError(f"consumers select conflicting npm Git commits for {package}; owner must choose compatible versions")
|
||||
commit, source = selected[0]
|
||||
return commit, dict(source, selections=[entry[1] for entry in selected])
|
||||
@@ -0,0 +1,36 @@
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
|
||||
from .common import WorkspaceError, read_json, safe_path, write_json
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.area = safe_path(root, ".workspace")
|
||||
self.path = safe_path(root, ".workspace/state.json")
|
||||
self.data = read_json(self.path) if self.path.exists() else {"schema": 1, "components": {}}
|
||||
if not isinstance(self.data, dict) or self.data.get("schema") != 1 or not isinstance(self.data.get("components"), dict):
|
||||
raise WorkspaceError("unsupported workspace state")
|
||||
for name, entry in self.data["components"].items():
|
||||
if not isinstance(entry, dict) or entry.get("owner_root") != str(root):
|
||||
raise WorkspaceError(f"workspace owner mismatch: {name}")
|
||||
safe_path(root, entry.get("path", ""))
|
||||
|
||||
def save(self):
|
||||
write_json(self.path, self.data)
|
||||
|
||||
@contextmanager
|
||||
def operation(self):
|
||||
self.area.mkdir(exist_ok=True)
|
||||
path = safe_path(self.root, ".workspace/.operation.lock")
|
||||
try:
|
||||
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError as error:
|
||||
raise WorkspaceError("workspace has another writer (.operation.lock); preserve the owner lock") from error
|
||||
try:
|
||||
with os.fdopen(descriptor, "w") as stream:
|
||||
stream.write(str(os.getpid()) + "\n")
|
||||
yield
|
||||
finally:
|
||||
path.unlink()
|
||||
@@ -0,0 +1,284 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from .common import WorkspaceError, safe_path
|
||||
from .manifest import validate_branch
|
||||
|
||||
|
||||
def files_snapshot(path, excluded=()):
|
||||
"""Hash files without following links or opening another repository's metadata."""
|
||||
files = {}
|
||||
for current, directories, names in os.walk(path, followlinks=False):
|
||||
directories[:] = [name for name in directories if name != ".git"
|
||||
and str((type(path)(current) / name).relative_to(path)) not in excluded]
|
||||
for name in directories + names:
|
||||
file = type(path)(current) / name
|
||||
if name == ".git":
|
||||
continue
|
||||
relative = str(file.relative_to(path))
|
||||
if file.is_symlink():
|
||||
files[relative] = {"link": os.readlink(file)}
|
||||
elif file.is_file():
|
||||
files[relative] = {"sha256": hashlib.sha256(file.read_bytes()).hexdigest(),
|
||||
"mode": file.stat().st_mode & 0o777}
|
||||
return files
|
||||
|
||||
|
||||
class Transition:
|
||||
def __init__(self, workspace):
|
||||
self.workspace = workspace
|
||||
|
||||
def inspect(self, component):
|
||||
root = self.workspace.root
|
||||
legacy_name = component.get("legacy_path")
|
||||
legacy = safe_path(root, legacy_name) if legacy_name else None
|
||||
if not legacy or not (legacy / ".git").exists():
|
||||
return {"path": legacy_name, "gitlink": self.workspace.git.gitlink(root, legacy_name)}
|
||||
return self.inspect_at(legacy, self.workspace.git.gitlink(root, legacy_name))
|
||||
|
||||
def inspect_at(self, legacy, pin=None):
|
||||
root = self.workspace.root
|
||||
result = {"path": str(legacy.relative_to(root)), "gitlink": pin}
|
||||
if (legacy / ".git").exists():
|
||||
result.update(self.workspace.git.inspect(legacy))
|
||||
result["refs"] = self.workspace.git.text(legacy, "show-ref", check=False).splitlines()
|
||||
result["reflog_commits"] = sorted(set(self.workspace.git.text(legacy, "reflog", "--all", "--format=%H", check=False).splitlines()))
|
||||
index = type(root)(result["git_dir"]) / "index"
|
||||
result["index_sha256"] = hashlib.sha256(index.read_bytes()).hexdigest() if index.exists() else None
|
||||
result["index_entries"] = self.workspace.git.text(legacy, "ls-files", "--stage", "-z")
|
||||
gitlinks = {}
|
||||
for row in result["index_entries"].split("\0"):
|
||||
if not row:
|
||||
continue
|
||||
metadata, relative = row.split("\t", 1)
|
||||
mode, commit, stage = metadata.split()
|
||||
if mode == "160000" and stage == "0":
|
||||
gitlinks[relative] = commit
|
||||
nested = []
|
||||
for current, directories, names in os.walk(legacy, followlinks=False):
|
||||
if current == str(legacy):
|
||||
directories[:] = [name for name in directories if name != ".git"]
|
||||
continue
|
||||
if ".git" in directories or ".git" in names:
|
||||
path = type(legacy)(current)
|
||||
relative = str(path.relative_to(legacy))
|
||||
child = self.inspect_at(path, gitlinks.get(relative))
|
||||
matches = [entry["id"] for entry in self.workspace.manifest.select()
|
||||
if entry["repo"] == child.get("origin")]
|
||||
nested.append({"path": relative, "id": matches[0] if len(matches) == 1 else None,
|
||||
"tracked": relative in gitlinks, "snapshot": child})
|
||||
directories[:] = []
|
||||
result["nested"] = nested
|
||||
result["files"] = files_snapshot(legacy, {entry["path"] for entry in nested})
|
||||
return result
|
||||
|
||||
def validate_files(self, legacy, snapshot):
|
||||
for relative, entry in snapshot.items():
|
||||
file = legacy / relative
|
||||
if "link" in entry and (os.path.isabs(entry["link"]) or not file.resolve().is_relative_to(legacy.resolve())):
|
||||
raise WorkspaceError(f"legacy symlink escapes component: {relative}; owner must resolve it")
|
||||
|
||||
def content_identity(self, snapshot):
|
||||
return snapshot["head"], snapshot["files"], snapshot["index_entries"]
|
||||
|
||||
def ref_namespace(self, snapshot):
|
||||
captured = {key: value for key, value in snapshot.items() if key != "ref_namespace"}
|
||||
encoded = json.dumps(captured, sort_keys=True, separators=(",", ":")).encode()
|
||||
source = hashlib.sha256(snapshot["path"].encode()).hexdigest()[:24]
|
||||
version = hashlib.sha256(encoded).hexdigest()
|
||||
return "refs/migration/sources/" + source + "/" + version
|
||||
|
||||
def remember_snapshots(self, record, snapshots):
|
||||
history = list(record.get("legacy_snapshots", []))
|
||||
seen = {entry.get("ref_namespace") for entry in history}
|
||||
for snapshot in snapshots:
|
||||
namespace = self.ref_namespace(snapshot)
|
||||
if namespace not in seen:
|
||||
history.append(dict(snapshot, ref_namespace=namespace))
|
||||
seen.add(namespace)
|
||||
record["legacy_snapshots"] = history
|
||||
|
||||
def pin_ref(self, path, reference, object_id):
|
||||
current = self.workspace.git.text(path, "rev-parse", "--verify", "--quiet", reference, check=False)
|
||||
if current:
|
||||
if current != object_id:
|
||||
raise WorkspaceError(f"preserved migration reference has another value: {reference}; owner must inspect it")
|
||||
return
|
||||
# Create only if absent: never overwrite a saved snapshot or a concurrent
|
||||
# owner's reference, even when the expected namespace already exists.
|
||||
self.workspace.git.call(path, "update-ref", reference, object_id, "0" * 40)
|
||||
|
||||
def preserve_refs(self, path, snapshots):
|
||||
workspace = self.workspace
|
||||
for snapshot in snapshots:
|
||||
legacy = safe_path(workspace.root, snapshot["path"])
|
||||
namespace = self.ref_namespace(snapshot)
|
||||
for row in snapshot["refs"]:
|
||||
object_id, reference = row.split(" ", 1)
|
||||
if workspace.git.call(path, "cat-file", "-e", object_id, check=False).returncode:
|
||||
# Fetch the captured object, not a mutable ref's current value.
|
||||
# With no destination refspec this only updates FETCH_HEAD.
|
||||
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), object_id)
|
||||
self.pin_ref(path, namespace + "/" + reference.removeprefix("refs/"), object_id)
|
||||
for commit in snapshot["reflog_commits"]:
|
||||
if workspace.git.call(path, "cat-file", "-e", commit, check=False).returncode:
|
||||
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), commit)
|
||||
self.pin_ref(path, "refs/migration/reflog/" + commit, commit)
|
||||
|
||||
def apply_all(self, components, branch):
|
||||
"""Resolve the whole nested ownership plan before creating any clone."""
|
||||
workspace = self.workspace
|
||||
workspace.go.check_owned()
|
||||
groups = {}
|
||||
|
||||
def add(component, snapshot):
|
||||
name = component["id"]
|
||||
legacy = safe_path(workspace.root, snapshot["path"])
|
||||
if not snapshot.get("head"):
|
||||
raise WorkspaceError(f"legacy checkout is unavailable: {name}")
|
||||
workspace.git.safe(legacy, component["repo"], clean=False, independent=False)
|
||||
self.validate_files(legacy, snapshot["files"])
|
||||
gitdir = type(legacy)(snapshot["git_dir"])
|
||||
if (gitdir / "objects/info/alternates").exists():
|
||||
raise WorkspaceError(f"legacy object store borrows another repository: {name}; owner must make it independent")
|
||||
previous = groups.setdefault(name, [])
|
||||
if previous:
|
||||
if self.content_identity(previous[0]) != self.content_identity(snapshot):
|
||||
raise WorkspaceError(f"conflicting duplicate logical component {name}: {previous[0]['path']} and {snapshot['path']}; owner must choose or reconcile them")
|
||||
old_refs = dict(row.split(" ", 1)[::-1] for old in previous for row in old["refs"])
|
||||
new_refs = dict(row.split(" ", 1)[::-1] for row in snapshot["refs"])
|
||||
if any(old_refs[ref] != new_refs[ref] for ref in old_refs.keys() & new_refs.keys()):
|
||||
raise WorkspaceError(f"conflicting duplicate refs for {name}; owner must reconcile them")
|
||||
if any(old["path"] == snapshot["path"] for old in previous):
|
||||
return
|
||||
previous.append(snapshot)
|
||||
for nested in snapshot.get("nested", []):
|
||||
if not nested["tracked"] or nested["id"] is None:
|
||||
raise WorkspaceError(f"unknown nested repository ownership at {snapshot['path']}/{nested['path']}; register its origin and Git ownership first")
|
||||
add(workspace.manifest.components[nested["id"]], nested["snapshot"])
|
||||
|
||||
for component in components:
|
||||
snapshot = self.inspect(component)
|
||||
# Default transition also lists uninitialized components; a named
|
||||
# unavailable checkout still reports a precise failure to its owner.
|
||||
add(component, snapshot)
|
||||
for name, snapshots in groups.items():
|
||||
component = workspace.manifest.components[name]
|
||||
path = safe_path(workspace.root, component["path"])
|
||||
if path.exists():
|
||||
record = workspace.state.data["components"].get(name)
|
||||
actual = workspace.git.safe(path, component["repo"], clean=False)
|
||||
if not record or record.get("source", {}).get("kind") != "legacy-copy" or actual["branch"] != record.get("branch"):
|
||||
raise WorkspaceError(f"migration destination has another owner: {name}; inspect its registered branch")
|
||||
if actual["head"] != snapshots[0]["head"] or files_snapshot(path) != snapshots[0]["files"] or workspace.git.text(path, "ls-files", "--stage", "-z") != snapshots[0]["index_entries"]:
|
||||
raise WorkspaceError(f"conflicting duplicate destination for {name}; preserve both scenes for the owner")
|
||||
for name, snapshots in groups.items():
|
||||
component = workspace.manifest.components[name]
|
||||
path = safe_path(workspace.root, component["path"])
|
||||
if path.exists():
|
||||
self.preserve_refs(path, snapshots)
|
||||
record = workspace.state.data["components"][name]
|
||||
self.remember_snapshots(record, snapshots)
|
||||
workspace.state.save()
|
||||
else:
|
||||
self.copy_one(component, branch, snapshots)
|
||||
for snapshots in groups.values():
|
||||
for snapshot in snapshots:
|
||||
legacy = safe_path(workspace.root, snapshot["path"])
|
||||
if self.inspect_at(legacy, snapshot["gitlink"]) != snapshot:
|
||||
raise WorkspaceError("legacy checkout changed during migration; independent copies retained for owner review")
|
||||
for name in groups:
|
||||
record = workspace.state.data["components"][name]
|
||||
record["linked"] = bool(workspace.go.modules(workspace.manifest.components[name]))
|
||||
if record["linked"]:
|
||||
record["mode"] = "linked"
|
||||
workspace.go.update()
|
||||
workspace.state.save()
|
||||
|
||||
def copy_one(self, component, branch, snapshots):
|
||||
workspace = self.workspace
|
||||
name = component["id"]
|
||||
snapshot = snapshots[0]
|
||||
legacy = safe_path(workspace.root, snapshot["path"])
|
||||
excluded = {entry["path"] for entry in snapshot.get("nested", [])}
|
||||
gitdir = type(legacy)(snapshot["git_dir"])
|
||||
path = safe_path(workspace.root, component["path"])
|
||||
if path.exists():
|
||||
raise WorkspaceError(f"migration destination already exists: {name}; inspect its owner before retrying")
|
||||
branch = branch or snapshot.get("branch")
|
||||
if not branch or not branch.startswith(("feature/", "fix/", "chore/", "codex/")):
|
||||
branch = "feature/migration-" + name.lower()
|
||||
validate_branch(branch)
|
||||
mappings = [{"id": nested["id"], "legacy_path": nested["snapshot"]["path"],
|
||||
"workspace_path": workspace.manifest.components[nested["id"]]["path"]}
|
||||
for nested in snapshot.get("nested", [])]
|
||||
record = workspace.record(component, snapshot["head"], {"kind": "legacy-copy", "gitlink": snapshot["gitlink"], "nested_components": mappings})
|
||||
record.update({"mode": "migrating", "legacy_snapshot": snapshot})
|
||||
self.remember_snapshots(record, snapshots)
|
||||
workspace.state.save()
|
||||
try:
|
||||
# Local clone copies the complete object store without hardlinks/alternates.
|
||||
# It never shares the submodule's Git common directory with the new root.
|
||||
workspace.git.call(workspace.root, "clone", "--no-hardlinks", "--no-checkout", "--", str(legacy), str(path))
|
||||
workspace.git.call(path, "remote", "set-url", "origin", component["repo"])
|
||||
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), "+refs/*:refs/migration/*")
|
||||
self.preserve_refs(path, snapshots)
|
||||
workspace.git.call(path, "checkout", "--detach", snapshot["head"])
|
||||
if workspace.git.call(path, "show-ref", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0:
|
||||
if workspace.git.text(path, "rev-parse", "refs/heads/" + branch) != snapshot["head"]:
|
||||
raise WorkspaceError("migration branch conflict; original and partial clone retained")
|
||||
workspace.git.call(path, "checkout", branch)
|
||||
else:
|
||||
workspace.git.call(path, "checkout", "-b", branch)
|
||||
# Keep deletions as well as tracked, untracked, and ignored file content.
|
||||
for filename in workspace.git.text(legacy, "diff", "--name-only", "-z", "--diff-filter=D", "HEAD").split("\0"):
|
||||
if not filename:
|
||||
continue
|
||||
relative = type(path)(filename)
|
||||
parent = safe_path(path, str(relative.parent), allow_dot=True)
|
||||
file = parent / relative.name
|
||||
if file.is_file() or file.is_symlink():
|
||||
file.unlink()
|
||||
for current, directories, names in os.walk(legacy, followlinks=False):
|
||||
directories[:] = [entry for entry in directories if entry != ".git"
|
||||
and str((type(legacy)(current) / entry).relative_to(legacy)) not in excluded]
|
||||
relative = type(legacy)(current).relative_to(legacy)
|
||||
destination = path / relative
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for filename in directories + names:
|
||||
if filename == ".git":
|
||||
continue
|
||||
source = type(legacy)(current) / filename
|
||||
target = destination / filename
|
||||
if source.is_symlink():
|
||||
if target.is_symlink() and os.readlink(target) == os.readlink(source):
|
||||
continue
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
raise WorkspaceError(f"migration symlink conflicts with directory: {filename}")
|
||||
target.unlink()
|
||||
target.symlink_to(os.readlink(source), target_is_directory=source.is_dir())
|
||||
elif source.is_file():
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
shutil.copy2(source, target)
|
||||
# The clone copied all local objects, including staged blobs which
|
||||
# are absent from HEAD. Preserve the index and split-index companions
|
||||
# independently so staging is not silently flattened into file copies.
|
||||
for index in list(gitdir.glob("sharedindex.*")) + [gitdir / "index"]:
|
||||
if index.is_file():
|
||||
shutil.copy2(index, path / ".git" / index.name)
|
||||
if self.inspect_at(legacy, snapshot["gitlink"]) != snapshot:
|
||||
raise WorkspaceError("legacy checkout changed during migration; partial clone preserved for owner")
|
||||
if files_snapshot(path) != snapshot["files"]:
|
||||
raise WorkspaceError("migration file snapshot differs; original and partial clone preserved for owner")
|
||||
workspace.git.safe(path, component["repo"], clean=False)
|
||||
record.update({"mode": "editable", "branch": branch, "linked": False})
|
||||
record.pop("error", None)
|
||||
workspace.state.save()
|
||||
except Exception as error:
|
||||
record["error"] = str(error)
|
||||
workspace.state.save()
|
||||
raise
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Process-local module transport; configuration contains no credentials."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, unquote
|
||||
|
||||
|
||||
def _url(value):
|
||||
if not isinstance(value, str) or any(c.isspace() for c in value):
|
||||
raise ValueError('invalid module transport URL')
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {'https', 'ssh', 'git+ssh', 'file'} or parsed.password:
|
||||
raise ValueError('unsupported transport or credentials in URL')
|
||||
if parsed.scheme == 'https' and parsed.username:
|
||||
raise ValueError('transport URL must not contain credentials')
|
||||
if parsed.scheme == 'file' and parsed.netloc not in {'', 'localhost'}:
|
||||
raise ValueError('file transport must be local')
|
||||
if parsed.query or parsed.fragment or not parsed.path:
|
||||
raise ValueError('transport URL must identify a repository path')
|
||||
return value
|
||||
|
||||
|
||||
def _mirror_source(value, target, env):
|
||||
"""Allow injected HTTP only as a verified, credential-free mirror alias."""
|
||||
if isinstance(value, str) and value.startswith('http://'):
|
||||
server = env.get('GITHUB_SERVER_URL', '').rstrip('/')
|
||||
parsed = urlsplit(server)
|
||||
owner = env.get('GITHUB_REPOSITORY', '').partition('/')[0]
|
||||
if (parsed.scheme not in {'http', 'https'} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment or
|
||||
not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*', owner) or
|
||||
not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*\.git', target.name) or
|
||||
value != server + '/' + owner + '/' + target.name):
|
||||
raise ValueError('HTTP mirror source must match the injected server and repository')
|
||||
return value
|
||||
return _url(value)
|
||||
|
||||
|
||||
def environment(root=None):
|
||||
root = Path(root) if root is not None else Path(__file__).resolve().parents[2]
|
||||
path = root / 'components.json'
|
||||
config = json.loads(path.read_text()).get('transport', {}) if path.exists() else {}
|
||||
env = os.environ.copy()
|
||||
env.update(GOWORK='off', GOTOOLCHAIN='local')
|
||||
private = config.get('go_private', [])
|
||||
if not isinstance(private, list) or any(not isinstance(p, str) or not p or any(c.isspace() for c in p) for p in private):
|
||||
raise ValueError('invalid private module prefixes')
|
||||
if private:
|
||||
env.update(GOPRIVATE=','.join(private), GONOPROXY=','.join(private), GONOSUMDB=','.join(private))
|
||||
rewrites = config.get('git_rewrites', [])
|
||||
if not isinstance(rewrites, list):
|
||||
raise ValueError('invalid Git transport mappings')
|
||||
if rewrites:
|
||||
inherited = []
|
||||
marker = env.get('CI_DEPENDENCY_MIRRORS')
|
||||
if marker:
|
||||
mirrors = Path(marker)
|
||||
if not mirrors.is_absolute() or not mirrors.is_dir() or mirrors.is_symlink():
|
||||
raise ValueError('invalid CI dependency mirror identity')
|
||||
mirrors = mirrors.resolve()
|
||||
try:
|
||||
count = int(env.get('GIT_CONFIG_COUNT', '0'))
|
||||
except ValueError as error:
|
||||
raise ValueError('invalid process Git configuration') from error
|
||||
for i in range(count):
|
||||
key, value = env.get(f'GIT_CONFIG_KEY_{i}', ''), env.get(f'GIT_CONFIG_VALUE_{i}', '')
|
||||
if (key, value) in {('protocol.file.allow', 'always'), ('core.hooksPath', '/dev/null'), ('credential.helper', '')}:
|
||||
inherited.append((key, value))
|
||||
elif key.startswith('url.file://') and key.endswith('.insteadOf'):
|
||||
target = Path(unquote(urlsplit(key[4:-10]).path)).resolve()
|
||||
if mirrors not in target.parents or not target.is_dir():
|
||||
raise ValueError('CI mirror source escapes its registered directory')
|
||||
inherited.append((key, _mirror_source(value, target, env)))
|
||||
# Do not inherit temporary authentication configuration into candidate code.
|
||||
for key in list(env):
|
||||
if key == 'GIT_CONFIG_COUNT' or key.startswith(('GIT_CONFIG_KEY_', 'GIT_CONFIG_VALUE_')):
|
||||
del env[key]
|
||||
entries = list(inherited)
|
||||
for rewrite in rewrites:
|
||||
if not isinstance(rewrite, dict) or set(rewrite) != {'from', 'to'}:
|
||||
raise ValueError('invalid Git transport mapping')
|
||||
source, target = _url(rewrite['from']), _url(rewrite['to'])
|
||||
entries.append((f'url.{target}.insteadOf', source))
|
||||
for i, (key, value) in enumerate(entries):
|
||||
env[f'GIT_CONFIG_KEY_{i}'], env[f'GIT_CONFIG_VALUE_{i}'] = key, value
|
||||
env['GIT_CONFIG_COUNT'] = str(len(entries))
|
||||
return env
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-Command":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-Command.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-Command")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Vet, test, and race
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
GOWORK=off go vet -mod=readonly ./... && GOWORK=off go test -mod=readonly -count=1 ./... && GOWORK=off go test -mod=readonly -count=1 -race ./...
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-Intent":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-Intent.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-Intent")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Vet, test, and race
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
GOWORK=off go vet -mod=readonly ./... && GOWORK=off go test -mod=readonly -count=1 ./... && GOWORK=off go test -mod=readonly -count=1 -race ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: *trusted_acquisition_1
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-Native":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-Native.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-Native")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Vet, test, and race
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
go vet -mod=readonly ./... && go test -mod=readonly -count=1 ./... && go test -mod=readonly -count=1 -race ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: *trusted_acquisition_1
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-Squad":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-Squad.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-Squad")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Vet, test, and race
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
for module in . adapters/native adapters/intent adapters/sqlite adapters/a2a cmd/agent-squad; do
|
||||
test -f "$module/go.mod"
|
||||
(cd "$module" && go vet -mod=readonly ./... && go test -mod=readonly -count=1 ./... && go test -mod=readonly -count=1 -race ./...)
|
||||
done
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: *trusted_acquisition_1
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
@@ -0,0 +1,500 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Backend":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-Backend.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-Backend")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install house-style Python packages
|
||||
run: python3 -m pip install --break-system-packages -i https://pypi.tuna.tsinghua.edu.cn/simple python-docx python-pptx openpyxl
|
||||
- name: Vet and test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
GOWORK=off go vet -mod=readonly ./... && GOWORK=off go test -mod=readonly -count=1 ./...
|
||||
@@ -0,0 +1,707 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Core":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-Core.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-Core")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Vet, test, and race
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
GOWORK=off go vet -mod=readonly ./... && GOWORK=off go test -mod=readonly -count=1 ./... && GOWORK=off go test -mod=readonly -count=1 -race ./...
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
|
||||
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||
- name: Prefetch npm packages without lifecycle scripts
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_3 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
def git_package(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith(("file:", "link:", "workspace:")):
|
||||
raise ValueError("Formal npm acquisition cannot consume a local package override")
|
||||
if value.startswith(("http://", "https://")):
|
||||
registry = urlsplit(value)
|
||||
if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password:
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
elif "/" in value and not value.startswith(("git+", "npm:")):
|
||||
raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping")
|
||||
if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")):
|
||||
for repo in ("Agent-System-Core", "Agent-System-UI"):
|
||||
prefix = "git+" + SSH + repo + ".git#"
|
||||
if value.startswith(prefix):
|
||||
return repo, fixed_sha(value[len(prefix):])
|
||||
raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping")
|
||||
return None
|
||||
|
||||
try:
|
||||
targets = []
|
||||
selected = set()
|
||||
for directory in source_roots():
|
||||
if not (directory / "package.json").is_file():
|
||||
continue
|
||||
if (directory / ".npmrc").exists():
|
||||
raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition")
|
||||
package = json.loads((directory / "package.json").read_text())
|
||||
lock = json.loads((directory / "package-lock.json").read_text())
|
||||
if lock.get("lockfileVersion") not in {2, 3}:
|
||||
raise ValueError("npm acquisition requires a package lock")
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in package.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
for entry in lock.get("packages", {}).values():
|
||||
found = git_package(entry.get("resolved"))
|
||||
if found:
|
||||
selected.add(found)
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in entry.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
targets.append(directory)
|
||||
# Fetch only registered fixed Git package commits. npm never receives token config.
|
||||
for repo, sha in sorted(selected):
|
||||
repository = mirror(repo, sha, component_token)
|
||||
package = json.loads(git(repository, "show", sha + ":package.json"))
|
||||
forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"}
|
||||
if forbidden & set(package.get("scripts", {})):
|
||||
raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts")
|
||||
if not git(repository, "ls-tree", sha, "package/").strip():
|
||||
raise ValueError("Git package provider is missing committed package outputs")
|
||||
no_auth = mirror_env()
|
||||
empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-")
|
||||
for directory in targets:
|
||||
result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund",
|
||||
"--userconfig=/dev/null", "--globalconfig=" + empty_config.name],
|
||||
cwd=directory, env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm dependency acquisition failed: " + result.stderr.strip())
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("npm prefetch refused: " + str(error))
|
||||
print("npm packages cached without credentials or lifecycle execution")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Validate package and contracts without credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
npm test
|
||||
npm run typecheck
|
||||
@@ -0,0 +1,501 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Frontend":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-Frontend.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-Frontend")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
|
||||
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||
- name: Prefetch npm packages without lifecycle scripts
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
def git_package(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith(("file:", "link:", "workspace:")):
|
||||
raise ValueError("Formal npm acquisition cannot consume a local package override")
|
||||
if value.startswith(("http://", "https://")):
|
||||
registry = urlsplit(value)
|
||||
if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password:
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
elif "/" in value and not value.startswith(("git+", "npm:")):
|
||||
raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping")
|
||||
if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")):
|
||||
for repo in ("Agent-System-Core", "Agent-System-UI"):
|
||||
prefix = "git+" + SSH + repo + ".git#"
|
||||
if value.startswith(prefix):
|
||||
return repo, fixed_sha(value[len(prefix):])
|
||||
raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping")
|
||||
return None
|
||||
|
||||
def registry_package(location, entry):
|
||||
resolved = entry.get("resolved", "")
|
||||
if not isinstance(resolved, str) or not resolved.startswith(("https://", "http://")):
|
||||
return None
|
||||
# A metadata spec is name@exact-version, never a candidate URL.
|
||||
source = urlsplit(resolved)
|
||||
if (source.scheme != "https" or source.netloc != "registry.npmjs.org" or
|
||||
source.username or source.password or source.query or source.fragment):
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
relative = Path(location)
|
||||
name = entry.get("name") or location.rsplit("node_modules/", 1)[-1]
|
||||
version = entry.get("version", "")
|
||||
if (not location.startswith("node_modules/") or relative.is_absolute() or ".." in relative.parts or
|
||||
not isinstance(name, str) or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*", name) or
|
||||
not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version)):
|
||||
raise ValueError("Invalid locked registry package name or version")
|
||||
return name + "@" + version
|
||||
|
||||
try:
|
||||
targets = []
|
||||
selected = set()
|
||||
for directory in source_roots():
|
||||
if not (directory / "package.json").is_file():
|
||||
continue
|
||||
if (directory / ".npmrc").exists():
|
||||
raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition")
|
||||
package = json.loads((directory / "package.json").read_text())
|
||||
lock_bytes = (directory / "package-lock.json").read_bytes()
|
||||
lock = json.loads(lock_bytes)
|
||||
if lock.get("lockfileVersion") not in {2, 3}:
|
||||
raise ValueError("npm acquisition requires a package lock")
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in package.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
registry_packages = []
|
||||
for location, entry in lock.get("packages", {}).items():
|
||||
spec = registry_package(location, entry)
|
||||
if spec:
|
||||
registry_packages.append((location, spec))
|
||||
found = git_package(entry.get("resolved"))
|
||||
if found:
|
||||
selected.add(found)
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in entry.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
targets.append((directory, registry_packages, lock_bytes))
|
||||
# Fetch only registered fixed Git package commits. npm never receives token config.
|
||||
for repo, sha in sorted(selected):
|
||||
repository = mirror(repo, sha, component_token)
|
||||
package = json.loads(git(repository, "show", sha + ":package.json"))
|
||||
forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"}
|
||||
if forbidden & set(package.get("scripts", {})):
|
||||
raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts")
|
||||
if not git(repository, "ls-tree", sha, "package/").strip():
|
||||
raise ValueError("Git package provider is missing committed package outputs")
|
||||
no_auth = mirror_env()
|
||||
empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-")
|
||||
for directory, registry_packages, lock_bytes in targets:
|
||||
result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund",
|
||||
"--userconfig=/dev/null", "--globalconfig=" + empty_config.name],
|
||||
cwd=directory, env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm dependency acquisition failed: " + result.stderr.strip())
|
||||
# npm ci caches tarballs; packed consumers also need resolver
|
||||
# metadata. Only installed locked registry entries are fetched.
|
||||
specs = sorted({spec for location, spec in registry_packages if (directory / location).is_dir()})
|
||||
if specs:
|
||||
with tempfile.TemporaryDirectory(prefix="ci-npm-metadata-") as metadata_root:
|
||||
result = subprocess.run(["npm", "cache", "add", *specs, "--ignore-scripts",
|
||||
"--registry=https://registry.npmjs.org", "--userconfig=/dev/null",
|
||||
"--globalconfig=" + empty_config.name], cwd=metadata_root,
|
||||
env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm metadata acquisition failed: " + result.stderr.strip())
|
||||
if (directory / "package-lock.json").read_bytes() != lock_bytes:
|
||||
raise ValueError("npm acquisition changed the committed package lock")
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("npm prefetch refused: " + str(error))
|
||||
print("npm packages cached without credentials or lifecycle execution")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Validate package and contracts without credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
npm test
|
||||
npm run build
|
||||
+949
@@ -0,0 +1,949 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-RDM":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-RDM.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-RDM")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Prefetch fixed Dev product composition
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
locked = json.loads((root / "composition.dev.json").read_text())
|
||||
if set(locked) != {"schema", "repo", "commit"} or locked["schema"] != 1:
|
||||
raise ValueError("Unsupported Dev product composition lock")
|
||||
if locked["repo"] != SSH + "Agent-System-Dev.git":
|
||||
raise ValueError("Dev product source is outside the reviewed allowlist")
|
||||
sha = fixed_sha(locked["commit"])
|
||||
directory = root / ".ci-composition-dev"
|
||||
if directory.exists() or directory.is_symlink():
|
||||
raise ValueError("Dev product composition requires a fresh destination")
|
||||
directory.mkdir()
|
||||
git(directory, "init", "-q")
|
||||
url = SERVER + "/weichen/Agent-System-Dev.git"
|
||||
git(directory, "remote", "add", "origin", url)
|
||||
git(directory, "fetch", "--depth=1", "origin", sha, token=component_token, repo="Agent-System-Dev")
|
||||
git(directory, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(directory, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Dev composition differs from the fixed lock")
|
||||
if (directory / ".ci-mirrors").exists() or (directory / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
git(directory, "remote", "set-url", "origin", locked["repo"])
|
||||
materialize_manifest(directory)
|
||||
mirror("Agent-System-Dev", sha, component_token)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Dev product composition prefetch refused: " + str(error))
|
||||
print("Fixed Dev product source cached; candidate scripts run only without credentials")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://mirrors.aliyun.com/nodejs-release/v22.14.0/node-v22.14.0-linux-x64.tar.xz
|
||||
tar -C /usr/local -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||
ln -sfn /usr/local/node-v22.14.0-linux-x64/bin/node /usr/local/bin/node
|
||||
- name: Prefetch npm packages without lifecycle scripts
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_3 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
def git_package(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith(("file:", "link:", "workspace:")):
|
||||
raise ValueError("Formal npm acquisition cannot consume a local package override")
|
||||
if value.startswith(("http://", "https://")):
|
||||
registry = urlsplit(value)
|
||||
if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password:
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
elif "/" in value and not value.startswith(("git+", "npm:")):
|
||||
raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping")
|
||||
if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")):
|
||||
for repo in ("Agent-System-Core", "Agent-System-UI"):
|
||||
prefix = "git+" + SSH + repo + ".git#"
|
||||
if value.startswith(prefix):
|
||||
return repo, fixed_sha(value[len(prefix):])
|
||||
raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping")
|
||||
return None
|
||||
|
||||
try:
|
||||
targets = []
|
||||
selected = set()
|
||||
for directory in source_roots():
|
||||
if not (directory / "package.json").is_file():
|
||||
continue
|
||||
if (directory / ".npmrc").exists():
|
||||
raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition")
|
||||
package = json.loads((directory / "package.json").read_text())
|
||||
lock = json.loads((directory / "package-lock.json").read_text())
|
||||
if lock.get("lockfileVersion") not in {2, 3}:
|
||||
raise ValueError("npm acquisition requires a package lock")
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in package.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
for entry in lock.get("packages", {}).values():
|
||||
found = git_package(entry.get("resolved"))
|
||||
if found:
|
||||
selected.add(found)
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in entry.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
targets.append(directory)
|
||||
# Fetch only registered fixed Git package commits. npm never receives token config.
|
||||
for repo, sha in sorted(selected):
|
||||
repository = mirror(repo, sha, component_token)
|
||||
package = json.loads(git(repository, "show", sha + ":package.json"))
|
||||
forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"}
|
||||
if forbidden & set(package.get("scripts", {})):
|
||||
raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts")
|
||||
if not git(repository, "ls-tree", sha, "package/").strip():
|
||||
raise ValueError("Git package provider is missing committed package outputs")
|
||||
no_auth = mirror_env()
|
||||
empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-")
|
||||
for directory in targets:
|
||||
result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund",
|
||||
"--userconfig=/dev/null", "--globalconfig=" + empty_config.name],
|
||||
cwd=directory, env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm dependency acquisition failed: " + result.stderr.strip())
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("npm prefetch refused: " + str(error))
|
||||
print("npm packages cached without credentials or lifecycle execution")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
# Preserve the Go build, workbench guards and real RDM Go tests.
|
||||
- name: Make test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
export COMPOSITION_ROOT="$PWD/.ci-composition-dev"
|
||||
GOWORK=off make test
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-UI":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-UI.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-UI")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
|
||||
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||
- name: Prefetch npm packages without lifecycle scripts
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
def git_package(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith(("file:", "link:", "workspace:")):
|
||||
raise ValueError("Formal npm acquisition cannot consume a local package override")
|
||||
if value.startswith(("http://", "https://")):
|
||||
registry = urlsplit(value)
|
||||
if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password:
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
elif "/" in value and not value.startswith(("git+", "npm:")):
|
||||
raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping")
|
||||
if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")):
|
||||
for repo in ("Agent-System-Core", "Agent-System-UI"):
|
||||
prefix = "git+" + SSH + repo + ".git#"
|
||||
if value.startswith(prefix):
|
||||
return repo, fixed_sha(value[len(prefix):])
|
||||
raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping")
|
||||
return None
|
||||
|
||||
def registry_package(location, entry):
|
||||
resolved = entry.get("resolved", "")
|
||||
if not isinstance(resolved, str) or not resolved.startswith(("https://", "http://")):
|
||||
return None
|
||||
# A metadata spec is name@exact-version, never a candidate URL.
|
||||
source = urlsplit(resolved)
|
||||
if (source.scheme != "https" or source.netloc != "registry.npmjs.org" or
|
||||
source.username or source.password or source.query or source.fragment):
|
||||
raise ValueError("npm tarball source is outside the reviewed public registry")
|
||||
relative = Path(location)
|
||||
name = entry.get("name") or location.rsplit("node_modules/", 1)[-1]
|
||||
version = entry.get("version", "")
|
||||
if (not location.startswith("node_modules/") or relative.is_absolute() or ".." in relative.parts or
|
||||
not isinstance(name, str) or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*", name) or
|
||||
not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version)):
|
||||
raise ValueError("Invalid locked registry package name or version")
|
||||
return name + "@" + version
|
||||
|
||||
try:
|
||||
targets = []
|
||||
selected = set()
|
||||
for directory in source_roots():
|
||||
if not (directory / "package.json").is_file():
|
||||
continue
|
||||
if (directory / ".npmrc").exists():
|
||||
raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition")
|
||||
package = json.loads((directory / "package.json").read_text())
|
||||
lock_bytes = (directory / "package-lock.json").read_bytes()
|
||||
lock = json.loads(lock_bytes)
|
||||
if lock.get("lockfileVersion") not in {2, 3}:
|
||||
raise ValueError("npm acquisition requires a package lock")
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in package.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
registry_packages = []
|
||||
for location, entry in lock.get("packages", {}).items():
|
||||
spec = registry_package(location, entry)
|
||||
if spec:
|
||||
registry_packages.append((location, spec))
|
||||
found = git_package(entry.get("resolved"))
|
||||
if found:
|
||||
selected.add(found)
|
||||
for group in ("dependencies", "devDependencies", "optionalDependencies"):
|
||||
for value in entry.get(group, {}).values():
|
||||
found = git_package(value)
|
||||
if found:
|
||||
selected.add(found)
|
||||
targets.append((directory, registry_packages, lock_bytes))
|
||||
# Fetch only registered fixed Git package commits. npm never receives token config.
|
||||
for repo, sha in sorted(selected):
|
||||
repository = mirror(repo, sha, component_token)
|
||||
package = json.loads(git(repository, "show", sha + ":package.json"))
|
||||
forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"}
|
||||
if forbidden & set(package.get("scripts", {})):
|
||||
raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts")
|
||||
if not git(repository, "ls-tree", sha, "package/").strip():
|
||||
raise ValueError("Git package provider is missing committed package outputs")
|
||||
no_auth = mirror_env()
|
||||
empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-")
|
||||
for directory, registry_packages, lock_bytes in targets:
|
||||
result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund",
|
||||
"--userconfig=/dev/null", "--globalconfig=" + empty_config.name],
|
||||
cwd=directory, env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm dependency acquisition failed: " + result.stderr.strip())
|
||||
# npm ci caches tarballs; packed consumers also need resolver
|
||||
# metadata. Only installed locked registry entries are fetched.
|
||||
specs = sorted({spec for location, spec in registry_packages if (directory / location).is_dir()})
|
||||
if specs:
|
||||
with tempfile.TemporaryDirectory(prefix="ci-npm-metadata-") as metadata_root:
|
||||
result = subprocess.run(["npm", "cache", "add", *specs, "--ignore-scripts",
|
||||
"--registry=https://registry.npmjs.org", "--userconfig=/dev/null",
|
||||
"--globalconfig=" + empty_config.name], cwd=metadata_root,
|
||||
env=no_auth, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("npm metadata acquisition failed: " + result.stderr.strip())
|
||||
if (directory / "package-lock.json").read_bytes() != lock_bytes:
|
||||
raise ValueError("npm acquisition changed the committed package lock")
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("npm prefetch refused: " + str(error))
|
||||
print("npm packages cached without credentials or lifecycle execution")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
npm run typecheck
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
npm test
|
||||
@@ -0,0 +1,508 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Error-Code-System":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Error-Code-System.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Error-Code-System")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
GOWORK=off go vet -mod=readonly ./... && go test -mod=readonly -count=1 ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: *trusted_acquisition_1
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
GOWORK: "off"
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_1 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def materialize_legacy(directory, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Historical submodule depth exceeds the checkout limit")
|
||||
links = {}
|
||||
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if entry:
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match fixed gitlink paths")
|
||||
for name, fields in entries.items():
|
||||
repo = LEGACY.get(name)
|
||||
if not repo or fields.get("path") != name or fields.get("url") not in {
|
||||
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
|
||||
raise ValueError("Historical submodule source is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
repo = LEGACY[name]
|
||||
sha = links[name]
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(directory, "config", f"submodule.{name}.url", url)
|
||||
# Trusted explicit checkout prevents candidate update commands from running.
|
||||
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
|
||||
child = directory / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Historical submodule does not match its fixed SHA")
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(child, depth + 1)
|
||||
|
||||
def materialize_manifest(directory):
|
||||
manifest = directory / "components.json"
|
||||
lock = directory / "components.lock.json"
|
||||
if not manifest.exists() and not lock.exists():
|
||||
materialize_legacy(directory)
|
||||
return
|
||||
data = json.loads(manifest.read_text())
|
||||
locked = json.loads(lock.read_text())
|
||||
if data.get("schema") != 1 or locked.get("schema") != 1:
|
||||
raise ValueError("Unsupported source manifest schema")
|
||||
entries = {}
|
||||
for item in data["components"]:
|
||||
repo = item["id"]
|
||||
path = ".workspace/" + repo
|
||||
if (repo not in REPOS or repo in entries or item["path"] != path or
|
||||
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
|
||||
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
|
||||
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
|
||||
for module in item.get("modules", []):
|
||||
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
|
||||
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
|
||||
entries[repo] = item
|
||||
selected = locked["components"]
|
||||
if set(selected) - set(entries):
|
||||
raise ValueError("Source lock refers to an unregistered repository")
|
||||
for repo, item in entries.items():
|
||||
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
|
||||
raise ValueError("Application and documentation source needs a fixed lock")
|
||||
for repo, pin in selected.items():
|
||||
fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
|
||||
raise ValueError("Source materialization requires a fresh destination")
|
||||
# Validate the complete lock before any authenticated component fetch.
|
||||
for repo, pin in selected.items():
|
||||
sha = fixed_sha(pin["commit"])
|
||||
destination = directory / entries[repo]["path"]
|
||||
destination.mkdir(parents=True)
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
git(destination, "init", "-q")
|
||||
git(destination, "remote", "add", "origin", url)
|
||||
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
|
||||
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Materialized source differs from the fixed lock")
|
||||
# Persist the registered identity; process config chooses the transport.
|
||||
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
|
||||
mirror(repo, sha, component_token)
|
||||
materialize_legacy(destination)
|
||||
|
||||
try:
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Logging-System":
|
||||
raise ValueError("Checkout repository is outside the reviewed allowlist")
|
||||
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
|
||||
if not root_token:
|
||||
raise ValueError("Checkout requires TOKEN")
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
git(root, "remote", "add", "origin", SERVER + "/weichen/Logging-System.git")
|
||||
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Logging-System")
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
|
||||
raise ValueError("Checked-out commit differs from the platform commit")
|
||||
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
|
||||
raise ValueError("Candidate cannot provide trusted prefetch state")
|
||||
materialize_manifest(root)
|
||||
mirror_env()
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
# Do not print child stderr or environment: a failed credential fetch stays redacted.
|
||||
sys.exit("Checkout refused: " + str(error))
|
||||
print("Fixed source checkout complete; credentials were not persisted")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Prefetch Go modules without executing candidate code
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: &trusted_acquisition_2 |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY_TRUSTED'
|
||||
# Trusted workflow code only: never import or execute checkout scripts here.
|
||||
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed = urlsplit(SERVER)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
sys.exit("Acquisition requires the platform-injected http(s) server")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
|
||||
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
|
||||
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
|
||||
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
|
||||
"Development-Guidelines", "Agent-System-Documents"}
|
||||
LEGACY = {
|
||||
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
|
||||
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
|
||||
"pins/agent-system-ui": "Agent-System-UI",
|
||||
}
|
||||
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
|
||||
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
|
||||
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
|
||||
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
# Go environment files and inherited Git tracing/config must not change acquisition.
|
||||
clean = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
|
||||
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
|
||||
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
|
||||
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
|
||||
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
|
||||
if os.environ.get("npm_config_cache"):
|
||||
clean["npm_config_cache"] = os.environ["npm_config_cache"]
|
||||
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
|
||||
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
|
||||
root = Path.cwd()
|
||||
mirrors = root / ".ci-mirrors"
|
||||
|
||||
def process_env(values):
|
||||
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return env
|
||||
|
||||
def git(directory, *args, token="", repo=""):
|
||||
values = config.copy()
|
||||
if token:
|
||||
if repo not in REPOS:
|
||||
raise ValueError("Credential destination is outside the reviewed repository allowlist")
|
||||
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
|
||||
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.stdout
|
||||
|
||||
def fixed_sha(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
||||
raise ValueError("Source acquisition requires a fixed commit SHA")
|
||||
return value.lower()
|
||||
|
||||
def mirror(repo, sha, token):
|
||||
sha = fixed_sha(sha)
|
||||
destination = mirrors / (repo + ".git")
|
||||
if destination.is_symlink() or mirrors.is_symlink():
|
||||
raise ValueError("Mirror directory cannot be a symlink")
|
||||
mirrors.mkdir(exist_ok=True)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
git(destination, "init", "--bare", "-q")
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
try:
|
||||
git(destination, "cat-file", "-e", sha + "^{commit}")
|
||||
except subprocess.CalledProcessError:
|
||||
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
|
||||
git(destination, "update-ref", "refs/heads/main", sha)
|
||||
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
git(destination, "update-ref", "refs/ci/" + sha, sha)
|
||||
return destination
|
||||
|
||||
def mirror_env():
|
||||
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
|
||||
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
|
||||
pairs = list(values.items())
|
||||
for repo in sorted(REPOS):
|
||||
destination = mirrors / (repo + ".git")
|
||||
if not destination.exists():
|
||||
continue
|
||||
key = "url." + destination.as_uri() + ".insteadOf"
|
||||
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
|
||||
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
|
||||
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
|
||||
for index, (key, value) in enumerate(pairs):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
|
||||
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
|
||||
return env
|
||||
|
||||
def source_roots():
|
||||
bases = [root]
|
||||
composition = root / ".ci-composition-dev"
|
||||
if composition.is_dir() and not composition.is_symlink():
|
||||
bases.append(composition)
|
||||
paths = []
|
||||
for base in bases:
|
||||
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
|
||||
return [path for path in paths if path.is_dir() and not path.is_symlink()]
|
||||
try:
|
||||
targets = []
|
||||
for source in source_roots():
|
||||
for suffix in (".", "examples/trace", "adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad"):
|
||||
directory = source / suffix
|
||||
if (directory / "go.mod").is_file() and directory.resolve() not in targets:
|
||||
targets.append(directory.resolve())
|
||||
# Parse using the installed local Go tool without credentials or candidate execution.
|
||||
original_files = {}
|
||||
for directory in targets:
|
||||
parsed_mod = json.loads(subprocess.check_output(["go", "mod", "edit", "-json"], cwd=directory, env=clean))
|
||||
canonical = parsed_mod["Module"]["Path"].startswith(GO_PREFIX)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
dependency_file = directory / name
|
||||
if dependency_file.is_file():
|
||||
original_files[(directory, name)] = dependency_file.read_bytes()
|
||||
elif canonical and name == "go.sum":
|
||||
original_files[(directory, name)] = None
|
||||
for dep in (parsed_mod.get("Require") or []):
|
||||
path = dep["Path"]
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go dependency is outside the reviewed module mapping")
|
||||
for replacement in (parsed_mod.get("Replace") or []):
|
||||
path = replacement["New"]["Path"]
|
||||
if canonical and not replacement["New"].get("Version"):
|
||||
raise ValueError("Canonical module cannot acquire a local path replacement")
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Go replacement is outside the reviewed module mapping")
|
||||
auth = config.copy()
|
||||
for repo in sorted(GO_REPOS):
|
||||
url = f"{SERVER}/weichen/{repo}.git"
|
||||
auth[f"url.{url}.insteadOf"] = "https://" + GO_PREFIX + repo
|
||||
auth[f"http.{url}.extraheader"] = "AUTHORIZATION: token " + component_token
|
||||
def objects(output):
|
||||
decoder = json.JSONDecoder()
|
||||
remaining = output.strip()
|
||||
while remaining:
|
||||
item, end = decoder.raw_decode(remaining)
|
||||
remaining = remaining[end:].lstrip()
|
||||
yield item
|
||||
def unchanged():
|
||||
for (owner, name), contents in original_files.items():
|
||||
dependency_file = owner / name
|
||||
current = dependency_file.read_bytes() if dependency_file.exists() else None
|
||||
if current != contents:
|
||||
raise ValueError("Go acquisition modified a committed Go dependency file: " + str(dependency_file))
|
||||
for directory in targets:
|
||||
# Resolve private go.mod metadata only through exact repository headers;
|
||||
# validate the complete selected graph before acquiring module zip files.
|
||||
graph = subprocess.run(["go", "list", "-mod=readonly", "-m", "-json", "all"], cwd=directory,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if graph.returncode:
|
||||
raise ValueError("Go selected graph resolution failed: " + graph.stderr.strip())
|
||||
for item in objects(graph.stdout):
|
||||
if item.get("Main"):
|
||||
continue
|
||||
selected = item.get("Replace") or item
|
||||
for path in (item.get("Path", ""), selected.get("Path", "")):
|
||||
if path.startswith(GO_PREFIX) and path not in GO_MODULES:
|
||||
raise ValueError("Selected Go module is outside the reviewed mapping")
|
||||
unchanged()
|
||||
# Go 1.27 can prune go.sum even under -mod=readonly for this
|
||||
# metadata-only command. Use a disposable copy, then validate actual
|
||||
# package loading against the unchanged committed source locks.
|
||||
local_replacements = json.loads(subprocess.check_output(
|
||||
["go", "mod", "edit", "-json"], cwd=directory, env=clean)).get("Replace") or []
|
||||
downloaded = ""
|
||||
if not any(not item["New"].get("Version") for item in local_replacements):
|
||||
with tempfile.TemporaryDirectory(prefix="ci-go-prefetch-") as temporary:
|
||||
download_root = Path(temporary)
|
||||
for name in ("go.mod", "go.sum"):
|
||||
if (directory / name).is_file():
|
||||
(download_root / name).write_bytes((directory / name).read_bytes())
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=download_root,
|
||||
env=process_env(auth), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("Go module acquisition failed: " + result.stderr.strip())
|
||||
downloaded = result.stdout
|
||||
unchanged()
|
||||
for item in objects(downloaded):
|
||||
path = item.get("Path", "")
|
||||
if path.startswith(GO_PREFIX):
|
||||
if path not in GO_MODULES:
|
||||
raise ValueError("Resolved Go module is outside the reviewed mapping")
|
||||
repo = path[len(GO_PREFIX):].split(".git", 1)[0]
|
||||
origin = item.get("Origin") or json.loads(Path(item["Info"]).read_text()).get("Origin") or {}
|
||||
mirror(repo, fixed_sha(origin.get("Hash")), component_token)
|
||||
# Package metadata loads actual test/build dependencies without running
|
||||
# vet, compilers, package init functions or candidate tests. No CGO tools.
|
||||
packages = subprocess.run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], cwd=directory,
|
||||
env=process_env(auth) | {"CGO_ENABLED": "0"}, capture_output=True, text=True)
|
||||
if packages.returncode:
|
||||
raise ValueError("Go test/build dependency acquisition failed: " + packages.stderr.strip())
|
||||
unchanged()
|
||||
mirror_env()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit("Go prefetch refused: " + str(error))
|
||||
print("Go modules cached with GOTOOLCHAIN=local; no candidate code executed")
|
||||
PY_TRUSTED
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
source .ci-mirrors.env
|
||||
for module in . examples/trace; do
|
||||
test -f "$module/go.mod"
|
||||
(cd "$module" && go vet -mod=readonly ./... && go test -mod=readonly -count=1 ./...)
|
||||
done
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: *trusted_acquisition_1
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"usage": "test-input-only",
|
||||
"capture": "git-show",
|
||||
"components": {
|
||||
"Logging-System": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git",
|
||||
"source_sha": "2e2620009fcb21c302369c1125bea33e2a383c71",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "163d62d87654c98506a97bff6188c6d4c2338c3abfce1bec110b1de6c872cb24"
|
||||
},
|
||||
"Error-Code-System": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git",
|
||||
"source_sha": "f55c6b11b6eeda36b9d30fb9e92dcecc44a9fcae",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "3fbbc1a91412bcac32a9eec8deaded8ce1b5151e512987a4669030a2cf5b776e"
|
||||
},
|
||||
"Agent-System-Core": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git",
|
||||
"source_sha": "dc4440d5d81a7ff9a79349c9c6a390cc2f9cb60a",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "6b0b5568bffa26709469fc655dee61210300156dba79bb619a7017162b1846a6"
|
||||
},
|
||||
"Agent-Native": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git",
|
||||
"source_sha": "3b550d783dd7d4dd37d30bb312f0c4369a3102a5",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "8d0cdab26b2758318b3bffeeb90b9c1ae947bc3f232e89e56ebf2f8edf45420d"
|
||||
},
|
||||
"Agent-Intent": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git",
|
||||
"source_sha": "c29cf6a283d0a2d93099e703d76b08c8a7801c90",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "c15dde13c41be0437db8d5b7e7e99c4ff37cab1af66515971400bfd7e5c94c55"
|
||||
},
|
||||
"Agent-Command": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git",
|
||||
"source_sha": "63dc7e06412af632c6b9c32ca3d71fb7cd8235fc",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "b12f615219a32dc4c477658e9abedbbe250aee260baf9c8cbffdb9a9d645f24e"
|
||||
},
|
||||
"Agent-Squad": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git",
|
||||
"source_sha": "42e1c4cd3296c7c68eef9473f34e50d59ba1412b",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "641f7e84fae05b99397ffc3b311031099526a29e8c6e39ae6b43617e7e4b9fd4"
|
||||
},
|
||||
"Agent-System-Backend": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git",
|
||||
"source_sha": "fffd5790cfab317fadafb40fa55a11bab6e9a847",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "c14a60739881d243cf459e05e8859976e6e86e6df3a272410cd2df3e0d43d063"
|
||||
},
|
||||
"Agent-System-UI": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git",
|
||||
"source_sha": "63d289e6bc15b701356cfe2d5b8740210fac0dcc",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "03fb580f523649db843a21ff70e3d21c5165198c1a8cf0a0bea882c3ec04f8f1"
|
||||
},
|
||||
"Agent-System-Frontend": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Frontend.git",
|
||||
"source_sha": "5467517e1831e7ca8b58bb5df52494a3a2c9d468",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "802e5c199afd8805d87f0fd1958cd001960d8d820a4e6acb14a401007d278012"
|
||||
},
|
||||
"Agent-System-RDM": {
|
||||
"repository": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git",
|
||||
"source_sha": "8a18dfa86e9afb1be231661e82cde8c0db7ee31b",
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_sha256": "47693ea41584cae52a32e291fae5fa995b5ca746b41cf150d6dac709e6a83b55"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Historical fixture: Agent-System-RDM c058bfcf651c42b2dfc9c93ec7cb1f1036d3fd95; original production script follows.
|
||||
# U1/KTD1 组合产物构建:frontend dist 构建 → 装入 backend web embed → RDM 组合二进制。
|
||||
# 物化布局(pins 检出保持只读,AGENTS 红线):
|
||||
# .build/product/frontend/ ← pins/agent-system-frontend(源码,无 node_modules/dist)
|
||||
# .build/product/ui/ ← pins/agent-system-ui(frontend 的 @ui 邻接依赖)
|
||||
# .build/product/backend/ ← pins/agent-system-backend(web/dist 注入点)
|
||||
# .build/product/rdm/ ← 本仓模块(go.mod 的 backend replace 重写为 ../backend;
|
||||
# 其余 8 条 replace 均在 backend/deps/ 之下,随前缀一并重写)
|
||||
# 产物:bin/agent-system-rdm-product + bin/PRODUCT_MANIFEST.json(组件 SHA/dist 摘要/二进制摘要)。
|
||||
# 完整性守卫(KTD1):dist 缺 index.html / assets 骨架 → 拒绝产出(不装半成品)。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ROOT="$PWD"
|
||||
BUILD="$ROOT/.build/product"
|
||||
OUT_BIN="$ROOT/bin/agent-system-rdm-product"
|
||||
OUT_MAN="$ROOT/bin/PRODUCT_MANIFEST.json"
|
||||
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { echo "product-build: 缺少 $1(完整 Web 产物不可构建)" >&2; exit 2; }; }
|
||||
need node; need npm; need go
|
||||
|
||||
# copy_tree SRC DST EXCLUDE...:tar 管道拷贝 + 拷贝期排除(CI runner 无 rsync;
|
||||
# cp -a 无法在拷贝中排除——源在目标祖先目录内时会「拷入自身」)。
|
||||
copy_tree() {
|
||||
local src="$1" dst="$2"; shift 2
|
||||
mkdir -p "$dst"
|
||||
local excl=() rel
|
||||
for rel in "$@"; do excl+=(--exclude "./$rel"); done
|
||||
( cd "$src" && tar cf - "${excl[@]}" . ) | ( cd "$dst" && tar xf - )
|
||||
}
|
||||
|
||||
echo "[product] 物化组件(pins 只读;.build/product 全新重建)"
|
||||
rm -rf "$BUILD"
|
||||
mkdir -p "$BUILD"
|
||||
copy_tree pins/agent-system-frontend "$BUILD/frontend" .git node_modules dist tsconfig.tsbuildinfo
|
||||
copy_tree pins/agent-system-ui "$BUILD/ui" .git node_modules dist
|
||||
copy_tree pins/agent-system-backend "$BUILD/backend" .git internal/runtime/data internal/server/data
|
||||
copy_tree "$ROOT" "$BUILD/rdm" .git .build bin node_modules data .task .zcode dist
|
||||
|
||||
echo "[product] 构建前端(frontend+ui 邻接布局;npm ci + vite build)"
|
||||
# ui 仓有自己的 node_modules(marked/dompurify 等,见 frontend vite.config dedupe 注释)。
|
||||
( cd "$BUILD/ui" && npm ci --no-audit --no-fund >/dev/null 2>&1 )
|
||||
( cd "$BUILD/frontend" && npm ci --no-audit --no-fund >/dev/null 2>&1 && npm run build 2>&1 | tail -4 )
|
||||
|
||||
INDEX="$BUILD/frontend/dist/index.html"
|
||||
[ -f "$INDEX" ] || { echo "product-build: frontend/dist 缺 index.html——拒绝作为完整 Web 产物" >&2; exit 3; }
|
||||
ls "$BUILD/frontend/dist/assets" >/dev/null 2>&1 || { echo "product-build: dist 缺 assets/ 骨架" >&2; exit 3; }
|
||||
|
||||
echo "[product] 注入 backend web embed(dist → backend/web/dist)"
|
||||
rm -rf "$BUILD/backend/web/dist"
|
||||
mkdir -p "$BUILD/backend/web/dist"
|
||||
cp -r "$BUILD/frontend/dist/." "$BUILD/backend/web/dist/"
|
||||
touch "$BUILD/backend/web/dist/.gitkeep"
|
||||
|
||||
echo "[product] 重写 rdm/go.mod 的 backend replace(→ ../backend;其余随前缀)"
|
||||
sed -i 's#=> \./pins/agent-system-backend#=> ../backend#g' "$BUILD/rdm/go.mod"
|
||||
|
||||
echo "[product] 编译组合二进制"
|
||||
( cd "$BUILD/rdm" && go build -trimpath -buildvcs=false -o "$OUT_BIN" . )
|
||||
# 同源 admin 工具(backend cmd/agent-system:apikey 等 CLI;烟测/运维用,同 embed 源)。
|
||||
( cd "$BUILD/backend" && go build -trimpath -buildvcs=false -o "$ROOT/bin/agent-system-admin" ./cmd/agent-system )
|
||||
|
||||
DIST_SHA=$(find "$BUILD/backend/web/dist" -type f ! -name .gitkeep -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-16)
|
||||
BIN_SHA=$(sha256sum "$OUT_BIN" | cut -c1-16)
|
||||
BE_SHA=$(git -C pins/agent-system-backend rev-parse HEAD)
|
||||
FE_SHA=$(git -C pins/agent-system-frontend rev-parse HEAD)
|
||||
UI_SHA=$(git -C pins/agent-system-ui rev-parse HEAD)
|
||||
RDM_SHA=$(git rev-parse HEAD)
|
||||
cat > "$OUT_MAN" <<EOF
|
||||
{
|
||||
"kind": "rdm-product/1",
|
||||
"rdm": "$RDM_SHA",
|
||||
"backend": "$BE_SHA",
|
||||
"frontend": "$FE_SHA",
|
||||
"ui": "$UI_SHA",
|
||||
"dist_sha256_16": "$DIST_SHA",
|
||||
"binary_sha256_16": "$BIN_SHA",
|
||||
"built_at": "$(date -u +%FT%TZ)"
|
||||
}
|
||||
EOF
|
||||
echo "[product] OK binary=$OUT_BIN ($BIN_SHA) dist=$DIST_SHA"
|
||||
echo "[product] manifest=$OUT_MAN"
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Execute the workflow checkout against real disposable HTTP Git repositories."""
|
||||
import http.server
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
@@ -15,22 +18,73 @@ SERVER = "https://git-home.u1680832.nyat.app:30319"
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
ROOT_TOKEN = "synthetic-root-token-not-a-real-secret"
|
||||
SUB_TOKEN = "synthetic-component-token-not-a-real-secret"
|
||||
COMPONENT_REPOSITORIES = (
|
||||
"Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native", "Agent-Intent",
|
||||
"Agent-Command", "Agent-Squad", "Agent-System-Backend", "Agent-System-UI",
|
||||
"Agent-System-Frontend", "Agent-System-RDM",
|
||||
)
|
||||
COMPONENT_FIXTURES = Path(__file__).resolve().parent / "fixtures/component-ci"
|
||||
|
||||
|
||||
def component_workflow(repository, *, snapshots=COMPONENT_FIXTURES, source_root=None):
|
||||
"""Read committed test input; never use these snapshots for real acquisition.
|
||||
|
||||
Optional COMPONENT_CI_SOURCE_ROOT verifies the recorded immutable Git object
|
||||
and current source HEAD. An archive requires only its committed fixture set.
|
||||
"""
|
||||
if repository not in COMPONENT_REPOSITORIES:
|
||||
raise ValueError("Unknown component workflow fixture")
|
||||
provenance = json.loads((snapshots / "provenance.json").read_text())
|
||||
if provenance.get("schema") != 1 or set(provenance.get("components", {})) != set(COMPONENT_REPOSITORIES):
|
||||
raise ValueError("Component workflow provenance must cover every reviewed repository")
|
||||
record = provenance["components"][repository]
|
||||
if (set(record) != {"repository", "source_sha", "workflow_path", "workflow_sha256"} or
|
||||
record["repository"] != SSH + repository + ".git" or
|
||||
record["workflow_path"] != ".gitea/workflows/ci.yml" or
|
||||
not re.fullmatch(r"[0-9a-f]{40}", record["source_sha"]) or
|
||||
not re.fullmatch(r"[0-9a-f]{64}", record["workflow_sha256"])):
|
||||
raise ValueError("Invalid fixed component workflow provenance")
|
||||
path = snapshots / repository / "ci.yml"
|
||||
if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != record["workflow_sha256"]:
|
||||
raise ValueError("Component workflow snapshot differs from its recorded source digest")
|
||||
source_root = source_root or os.environ.get("COMPONENT_CI_SOURCE_ROOT")
|
||||
if source_root:
|
||||
source = Path(source_root) / repository
|
||||
head = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
||||
if head != record["source_sha"]:
|
||||
raise ValueError("Component source HEAD differs from fixture provenance")
|
||||
captured = subprocess.check_output(["git", "-C", str(source), "show",
|
||||
record["source_sha"] + ":" + record["workflow_path"]])
|
||||
if captured != path.read_bytes():
|
||||
raise ValueError("Component workflow snapshot differs from its immutable Git source")
|
||||
return path
|
||||
|
||||
|
||||
def workflow_script(workflow, name):
|
||||
"""Extract an inline workflow run block, resolving workflow-local YAML aliases."""
|
||||
lines = workflow.read_text().splitlines()
|
||||
start = next((i for i, line in enumerate(lines) if line.strip() == "- name: " + name), None)
|
||||
if start is None:
|
||||
raise AssertionError(f"Workflow has no executable phase: {name}")
|
||||
run = next(i for i in range(start, len(lines)) if lines[i].strip().startswith("run: "))
|
||||
value = lines[run].strip().removeprefix("run: ")
|
||||
if value.startswith("*"):
|
||||
run = next(i for i, line in enumerate(lines) if line.strip() == "run: &" + value[1:] + " |")
|
||||
value = "|"
|
||||
if value != "|" and not (value.startswith("&") and value.endswith(" |")):
|
||||
return value
|
||||
start = run + 1
|
||||
end = next((i for i in range(start, len(lines))
|
||||
if lines[i].strip() and not lines[i].startswith(" ")), len(lines))
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
|
||||
|
||||
def checkout_script():
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == "- name: Checkout")
|
||||
start = next(i for i in range(start, len(lines)) if lines[i].strip() == "run: |") + 1
|
||||
end = next(i for i in range(start, len(lines)) if lines[i].startswith(" - name:"))
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
return workflow_script(WORKFLOW, "Checkout")
|
||||
|
||||
|
||||
def rdm_clone_script():
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if "Clone Agent-System-RDM" in line)
|
||||
start = next(i for i in range(start, len(lines)) if lines[i].strip() == "run: |") + 1
|
||||
end = next(i for i in range(start, len(lines)) if lines[i].startswith(" - name:"))
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
return workflow_script(WORKFLOW, "Clone Agent-System-RDM with nested pins (reviewed allowlist addition)")
|
||||
|
||||
|
||||
class CheckoutTests(unittest.TestCase):
|
||||
@@ -283,14 +337,31 @@ class CheckoutTests(unittest.TestCase):
|
||||
for sha in shas:
|
||||
self.assertEqual(self.git(work / "rdm", "cat-file", "-t", sha), "commit")
|
||||
self.assertEqual(self.git(work / "rdm", "rev-parse", "HEAD"), shas[2])
|
||||
# A lifecycle cache clones this isolated checkout. Local clones copy
|
||||
# heads, so the fixed source must expose a deterministic main head.
|
||||
lifecycle = self.base / "lifecycle-source"
|
||||
self.git(self.base, "clone", "--no-hardlinks", str(work / "rdm"), str(lifecycle))
|
||||
self.assertEqual(self.git(lifecycle, "rev-parse", "origin/main"), shas[2])
|
||||
# Candidate builds are a separate phase: the shell has no checkout token,
|
||||
# and lifecycle source fetches resolve through public local mirror paths.
|
||||
from tests.test_dependency_ci import phase
|
||||
built = subprocess.run(
|
||||
["bash", "-c", phase(WORKFLOW, "Build fixed RDM pins without credentials")],
|
||||
cwd=work, env=self.env, capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
self.assertNotIn(SUB_TOKEN, built.stdout + built.stderr)
|
||||
digests = (work / "rdm" / "pin-build-digests.txt").read_text()
|
||||
for sha in shas:
|
||||
self.assertIn(sha, digests)
|
||||
gate = WORKFLOW.read_text().split(
|
||||
"Real-combination lifecycle gate", 1)[1].split("\n svc-realcombo:", 1)[0]
|
||||
self.assertIn('export SVC_CI_GIT_TOKEN="$SUBMODULE_TOKEN"', gate)
|
||||
self.assertNotRegex(gate, r"(?m)^\s*source\s")
|
||||
self.assertNotIn(">>", gate)
|
||||
clone = work / "lifecycle-source"
|
||||
replay = subprocess.run(
|
||||
["bash", "-c", 'source .ci-mirrors.env; git clone -q "$1" "$2"; '
|
||||
'git -C "$2" fetch -q origin; env', "fixture",
|
||||
SSH + "Agent-System-RDM.git", str(clone)], cwd=work, env=self.env,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(replay.returncode, 0, replay.stderr)
|
||||
self.assertNotIn(SUB_TOKEN, replay.stdout + replay.stderr)
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD"), shas[2])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Local named checks prepare fixed sources and retain edits with real Go/Git."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("go"), "Go required")
|
||||
class ComponentCheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="component-check-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.root = self.base / "dev"
|
||||
self.root.mkdir()
|
||||
self.env = os.environ.copy()
|
||||
for key in list(self.env):
|
||||
if key.startswith("GIT_CONFIG_") or key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GOWORK", "COMPOSITION_SOURCE_MAP", "CI_DEPENDENCY_MIRRORS"):
|
||||
self.env.pop(key, None)
|
||||
self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null", GIT_ALLOW_PROTOCOL="file",
|
||||
GIT_AUTHOR_NAME="Checks", GIT_AUTHOR_EMAIL="check@example.invalid",
|
||||
GIT_COMMITTER_NAME="Checks", GIT_COMMITTER_EMAIL="check@example.invalid",
|
||||
GOTOOLCHAIN="local", GOPROXY="off", GOSUMDB="off")
|
||||
self.call(self.root, "git", "init", "--initial-branch=main")
|
||||
shutil.copytree(SOURCE / "scripts", self.root / "scripts")
|
||||
shutil.copyfile(SOURCE / "Makefile", self.root / "Makefile")
|
||||
(self.root / ".gitignore").write_text(".workspace/\ngo.work\ngo.work.sum\ntests/integration/multi-instance/.build/\n")
|
||||
app = self.base / "backend"
|
||||
self.backend = app
|
||||
app.mkdir()
|
||||
self.call(app, "git", "init", "--initial-branch=main")
|
||||
(app / "go.mod").write_text("module example.com/backend\n\ngo 1.23\n")
|
||||
(app / "source.go").write_text("package backend\nfunc Value() string {return \"fixed\"}\n")
|
||||
(app / "source_test.go").write_text('package backend\nimport "testing"\nfunc TestValue(t *testing.T) {if Value() != "fixed" {t.Fatal(Value())}}\n')
|
||||
(app / "cmd/agent-system").mkdir(parents=True)
|
||||
(app / "cmd/agent-system/main.go").write_text('package main\nimport "fmt"\nfunc main(){fmt.Print("fixed-binary")}\n')
|
||||
self.call(app, "git", "add", ".")
|
||||
self.call(app, "git", "commit", "-m", "Fixed Backend")
|
||||
sha = self.call(app, "git", "rev-parse", "HEAD").stdout.strip()
|
||||
manifest = {"schema": 1, "components": [{"id": "Agent-System-Backend", "repo": str(app), "path": ".workspace/Agent-System-Backend", "legacy_path": "modules/backend", "role": "application", "modules": [{"path": "example.com/backend", "directory": "."}], "target": "main"}]}
|
||||
(self.root / "components.json").write_text(json.dumps(manifest))
|
||||
(self.root / "components.lock.json").write_text(json.dumps({"schema": 1, "components": {"Agent-System-Backend": {"commit": sha}}}))
|
||||
self.call(self.root, "git", "add", ".")
|
||||
self.call(self.root, "git", "commit", "-m", "Fixed Dev")
|
||||
|
||||
def call(self, cwd, *args, check=True, env=None):
|
||||
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def test_backend_check_prepares_fixed_source_and_replays_owner_edits(self):
|
||||
result = self.call(self.root, "make", "test-backend")
|
||||
self.assertIn("GOWORK=off", result.stdout)
|
||||
child = self.root / ".workspace/Agent-System-Backend"
|
||||
self.assertEqual((child / "source.go").read_text(), 'package backend\nfunc Value() string {return "fixed"}\n')
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "edit", "--name", "Agent-System-Backend", "--branch", "feature/owned")
|
||||
(child / "source.go").write_text('package backend\nfunc Value() string {return "owner-edit"}\n')
|
||||
result = self.call(self.root, "make", "test-backend", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("owner-edit", result.stdout)
|
||||
self.assertEqual(self.call(child, "git", "branch", "--show-current").stdout.strip(), "feature/owned")
|
||||
self.assertIn('"owner-edit"', (child / "source.go").read_text())
|
||||
|
||||
def test_pg_gate_requires_environment_and_forwards_it_to_real_go_tests(self):
|
||||
result = self.call(self.root, "make", "test-backend-pg", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse((self.root / ".workspace").exists())
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "edit", "--name", "Agent-System-Backend", "--branch", "feature/pg-gate")
|
||||
child = self.root / ".workspace/Agent-System-Backend"
|
||||
(child / "pg_gate_test.go").write_text('package backend\nimport("os";"testing")\nfunc TestGateEnv(t *testing.T){if os.Getenv("AGENT_PG_TEST_DSN")!="fixture-dsn"||os.Getenv("AGENT_REDIS_TEST_ADDR")!="fixture-redis"{t.Fatal("gate environment missing")}}\n')
|
||||
self.call(self.root, "make", "test-backend-pg", env=self.env | {"AGENT_PG_TEST_DSN": "fixture-dsn", "AGENT_REDIS_TEST_ADDR": "fixture-redis"})
|
||||
|
||||
def test_multi_instance_binary_is_built_inside_dev_root_and_reached_by_suite(self):
|
||||
suite = self.add_multi_instance_suite()
|
||||
self.call(self.root, "make", "test-multi-instance", env=self.env | {"AGENT_PG_TEST_DSN": "fixture-dsn"})
|
||||
self.assertTrue((suite / ".build/agent-system").is_file())
|
||||
self.assertFalse((self.root / ".workspace/tests").exists())
|
||||
|
||||
def add_multi_instance_suite(self):
|
||||
suite = self.root / "tests/integration/multi-instance"
|
||||
suite.mkdir(parents=True)
|
||||
(suite / "go.mod").write_text("module example.com/multi\n\ngo 1.23\n")
|
||||
(suite / "binary_test.go").write_text('package multi\nimport("os";"os/exec";"testing")\nfunc TestBinary(t *testing.T){b,e:=exec.Command(os.Getenv("AGENT_BIN")).Output();if e!=nil||string(b)!="fixed-binary"{t.Fatalf("%s %v",b,e)}}\n')
|
||||
return suite
|
||||
|
||||
def add_go_byproduct_fixture(self, fail=False):
|
||||
(self.backend / ".gitignore").write_text("__pycache__/\n.fixture-output\n")
|
||||
(self.backend / "fixture_module.py").write_text("VALUE = 'imported from Go'\n")
|
||||
(self.backend / "byproduct_test.go").write_text('''package backend
|
||||
import("os";"os/exec";"testing")
|
||||
func TestByproducts(t *testing.T) {
|
||||
if err := os.WriteFile(".fixture-output", []byte("generated by Go test"), 0600); err != nil {t.Fatal(err)}
|
||||
cmd := exec.Command("python3", "-c", "import fixture_module")
|
||||
cmd.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=", "PYTHONPYCACHEPREFIX=")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {t.Fatalf("%s %v", out, err)}
|
||||
''' + (' t.Fatal("deliberate fixture failure")\n' if fail else '') + '}\n')
|
||||
self.call(self.backend, "git", "add", ".")
|
||||
self.call(self.backend, "git", "commit", "-m", "Add Go and Python byproducts fixture")
|
||||
lock = json.loads((self.root / "components.lock.json").read_text())
|
||||
lock["components"]["Agent-System-Backend"]["commit"] = self.call(self.backend, "git", "rev-parse", "HEAD").stdout.strip()
|
||||
(self.root / "components.lock.json").write_text(json.dumps(lock))
|
||||
self.call(self.root, "git", "add", "components.lock.json")
|
||||
self.call(self.root, "git", "commit", "-m", "Pin byproducts fixture")
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "prepare")
|
||||
return self.root / ".workspace/Agent-System-Backend"
|
||||
|
||||
def source_snapshot(self, child):
|
||||
return {"head": self.call(child, "git", "rev-parse", "HEAD").stdout,
|
||||
"branch": self.call(child, "git", "branch", "--show-current").stdout,
|
||||
"index": (child / ".git/index").read_bytes(),
|
||||
"status": self.call(child, "git", "status", "--porcelain", "--untracked-files=all").stdout,
|
||||
"ignored": self.call(child, "git", "ls-files", "--others", "--ignored", "--exclude-standard").stdout,
|
||||
"files": {path.relative_to(child).as_posix(): path.read_bytes() for path in child.rglob("*")
|
||||
if path.is_file() and ".git" not in path.relative_to(child).parts}}
|
||||
|
||||
def test_fixed_go_checks_keep_source_clean_across_pg_and_multi_instance(self):
|
||||
child = self.add_go_byproduct_fixture()
|
||||
suite = self.add_multi_instance_suite()
|
||||
before = self.source_snapshot(child)
|
||||
env = self.env | {"AGENT_PG_TEST_DSN": "fixture-dsn", "AGENT_REDIS_TEST_ADDR": "fixture-redis"}
|
||||
self.call(self.root, "make", "test-backend-pg", env=env)
|
||||
generated = self.call(child, "git", "ls-files", "--others", "--ignored", "--exclude-standard").stdout
|
||||
print("fixed checkout byproducts after PostgreSQL fixture: " + repr(generated), flush=True)
|
||||
self.call(self.root, "make", "test-multi-instance", env=env)
|
||||
self.assertEqual(self.source_snapshot(child), before)
|
||||
self.assertTrue((suite / ".build/agent-system").is_file())
|
||||
|
||||
def test_fixed_go_failure_is_reported_without_polluting_source(self):
|
||||
child = self.add_go_byproduct_fixture(fail=True)
|
||||
before = self.source_snapshot(child)
|
||||
result = self.call(self.root, "make", "test-backend", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("deliberate fixture failure", result.stdout)
|
||||
self.assertIn("local component command failed (1)", result.stdout)
|
||||
self.assertEqual(self.source_snapshot(child), before)
|
||||
|
||||
def test_fixed_go_checks_refuse_and_preserve_owner_changes_and_ignored_files(self):
|
||||
child = self.add_go_byproduct_fixture()
|
||||
for kind in ("unstaged", "staged", "ignored"):
|
||||
with self.subTest(kind=kind):
|
||||
if kind == "ignored":
|
||||
(child / ".fixture-output").write_text("owner's unique ignored file")
|
||||
else:
|
||||
(child / "source.go").write_text('package backend\nfunc Value() string {return "owner-edit"}\n')
|
||||
if kind == "staged":
|
||||
self.call(child, "git", "add", "source.go")
|
||||
before = self.source_snapshot(child)
|
||||
result = self.call(self.root, "make", "test-backend", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("dirty or ignored unique files", result.stdout)
|
||||
self.assertEqual(self.source_snapshot(child), before)
|
||||
# This fixture owns these files and its index; reset only between cases.
|
||||
if kind != "ignored":
|
||||
self.call(child, "git", "restore", "--source=HEAD", "--staged", "--worktree", "source.go")
|
||||
|
||||
def test_pg_and_multi_instance_prerequisites_still_refuse_before_preparing_sources(self):
|
||||
for target, supplied in (("test-backend-pg", {}),
|
||||
("test-backend-pg", {"AGENT_PG_TEST_DSN": "fixture-dsn"}),
|
||||
("test-multi-instance", {})):
|
||||
with self.subTest(target=target, supplied=supplied):
|
||||
env = {key: value for key, value in self.env.items()
|
||||
if key not in ("AGENT_PG_TEST_DSN", "AGENT_REDIS_TEST_ADDR")}
|
||||
result = self.call(self.root, "make", target, check=False, env=env | supplied)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse((self.root / ".workspace").exists())
|
||||
|
||||
def test_new_pin_gate_validates_fixed_sources_and_rejects_local_replaces(self):
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "prepare")
|
||||
self.call(self.root, "bash", "scripts/check-pins.sh")
|
||||
child = self.root / ".workspace/Agent-System-Backend"
|
||||
(child / "go.mod").write_text((child / "go.mod").read_text() + "\nreplace example.com/dependency => ../local\n")
|
||||
result = self.call(self.root, "bash", "scripts/check-pins.sh", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("local replace", result.stdout)
|
||||
|
||||
def test_registered_private_transport_reaches_real_go_process_without_global_change(self):
|
||||
manifest = json.loads((self.root / "components.json").read_text())
|
||||
manifest["transport"] = {"go_private": ["private.example.test"], "git_rewrites": [{"from": "https://private.example.test/", "to": self.base.as_uri() + "/"}]}
|
||||
(self.root / "components.json").write_text(json.dumps(manifest))
|
||||
global_config = self.base / "global.gitconfig"
|
||||
global_config.write_text("[user]\n name = existing-user\n")
|
||||
env = self.env | {"GIT_CONFIG_GLOBAL": str(global_config)}
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "edit", "--name", "Agent-System-Backend", "--branch", "feature/transport", env=env)
|
||||
child = self.root / ".workspace/Agent-System-Backend"
|
||||
(child / "transport_test.go").write_text('package backend\nimport("os";"testing")\nfunc TestTransport(t *testing.T){if os.Getenv("GOPRIVATE")!="private.example.test"||os.Getenv("GOWORK")!="off"||os.Getenv("GIT_CONFIG_COUNT")==""{t.Fatal("process-scoped transport missing")}}\n')
|
||||
before = global_config.read_bytes()
|
||||
self.call(self.root, "make", "test-backend", env=env)
|
||||
self.assertEqual(global_config.read_bytes(), before)
|
||||
|
||||
def test_explicit_overlay_uses_registered_workspace_and_preserves_default_version_mode(self):
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "edit", "--name", "Agent-System-Backend", "--branch", "feature/overlay")
|
||||
child = self.root / ".workspace/Agent-System-Backend"
|
||||
work = self.root / "go.work"
|
||||
(child / "overlay_test.go").write_text('package backend\nimport("os";"testing")\nfunc TestOverlay(t *testing.T){if os.Getenv("GOWORK")!="' + str(work) + '"{t.Fatal("registered overlay missing")}}\n')
|
||||
self.call(self.root, "make", "test-backend", "LOCAL_OVERLAY=1")
|
||||
default = self.call(self.root, "make", "test-backend", check=False)
|
||||
self.assertNotEqual(default.returncode, 0, default.stdout)
|
||||
self.assertIn("registered overlay missing", default.stdout)
|
||||
self.assertTrue(work.is_file())
|
||||
|
||||
def test_make_pin_gate_uses_map_in_gitless_formal_root_and_ignores_external_rdm_identity(self):
|
||||
self.call(self.root, "python3", "scripts/workspace.py", "--root", str(self.root), "prepare")
|
||||
formal = self.base / "formal"
|
||||
formal.mkdir()
|
||||
shutil.copytree(self.root / "scripts", formal / "scripts")
|
||||
for name in ("Makefile", "components.json", "components.lock.json"):
|
||||
shutil.copy2(self.root / name, formal / name)
|
||||
entry = json.loads((self.root / "components.json").read_text())["components"][0]
|
||||
locked = json.loads((self.root / "components.lock.json").read_text())["components"][entry["id"]]["commit"]
|
||||
mapping = {"schema": 1, "root_commit": self.call(self.root, "git", "rev-parse", "HEAD").stdout.strip(),
|
||||
"components": {entry["id"]: {"path": str(self.root / entry["path"]), "repo": entry["repo"], "commit": locked},
|
||||
"Agent-System-RDM": {"path": str(self.base / "external-rdm"), "repo": "external registered by composer", "commit": locked}}}
|
||||
source_map = self.base / "map.json"
|
||||
source_map.write_text(json.dumps(mapping))
|
||||
self.call(formal, "make", "test-pins", env=self.env | {"COMPOSITION_SOURCE_MAP": str(source_map)})
|
||||
self.assertFalse((formal / ".workspace").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / 'scripts'
|
||||
|
||||
class ComponentGovernanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.run_git(self.root, 'init', '-q')
|
||||
self.run_git(self.root, 'config', 'user.name', 'Fixture')
|
||||
self.run_git(self.root, 'config', 'user.email', 'fixture@example.test')
|
||||
self.child = self.root / '.workspace/Provider'
|
||||
self.child.mkdir(parents=True)
|
||||
self.run_git(self.child, 'init', '-q')
|
||||
self.run_git(self.child, 'config', 'user.name', 'Fixture')
|
||||
self.run_git(self.child, 'config', 'user.email', 'fixture@example.test')
|
||||
(self.child/'value').write_text('one')
|
||||
self.run_git(self.child, 'add', 'value')
|
||||
self.run_git(self.child, 'commit', '-qm', 'Add value')
|
||||
(self.root/'.gitignore').write_text('.workspace/\n')
|
||||
(self.root/'components.json').write_text(json.dumps({'schema':1,'components':[{
|
||||
'id':'Provider','repo':self.child.as_uri(),'path':'.workspace/Provider',
|
||||
'role':'documentation','modules':[],'target':'main'}]}))
|
||||
self.run_git(self.root, 'add', '.gitignore', 'components.json')
|
||||
self.run_git(self.root, 'commit', '-qm', 'Add component mapping')
|
||||
self.old = Path.cwd()
|
||||
os.chdir(self.root)
|
||||
self.addCleanup(os.chdir,self.old)
|
||||
|
||||
def run_git(self,root,*args):
|
||||
return subprocess.check_output(['git','-C',str(root),*args],stderr=subprocess.STDOUT,text=True)
|
||||
|
||||
def snapshot(self):
|
||||
spec=importlib.util.spec_from_file_location('component_evidence',SCRIPTS/'evidence.py')
|
||||
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
return module.snapshot()
|
||||
|
||||
def test_evidence_captures_ignored_component_revision_and_dirty_bytes(self):
|
||||
first=self.snapshot()
|
||||
(self.child/'value').write_text('two')
|
||||
second=self.snapshot()
|
||||
self.assertIn('components',first,'receipts must identify ordinary ignored component checkouts')
|
||||
self.assertNotEqual(first['components'],second['components'])
|
||||
self.run_git(self.child,'commit','-qam','Change value')
|
||||
third=self.snapshot()
|
||||
self.assertNotEqual(second['components'],third['components'])
|
||||
self.assertEqual(first['head'],third['head'])
|
||||
|
||||
def test_before_sync_rejects_component_git_lock(self):
|
||||
(self.child/'.git/index.lock').write_text('active owner')
|
||||
result=subprocess.run(['python3',str(SCRIPTS/'check-worktree.py'),'before-sync'],cwd=self.root,capture_output=True,text=True)
|
||||
self.assertNotEqual(result.returncode,0,'a hidden component writer must block synchronization')
|
||||
self.assertTrue((self.child/'.git/index.lock').exists())
|
||||
|
||||
def test_startup_receipt_rejects_changed_ignored_component_objects(self):
|
||||
spec=importlib.util.spec_from_file_location('component_evidence_validation',SCRIPTS/'evidence.py')
|
||||
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
self.run_git(self.root,'update-ref','refs/remotes/origin/main','HEAD')
|
||||
before=module.snapshot()
|
||||
receipt=module.create_receipt('make precheck','[verdict] READY\n',0,before,before,
|
||||
timestamp='2026-10-13T00:00:00+00:00',cwd=str(self.root),
|
||||
git_common_dir=module.common_dir())
|
||||
module.validate_precheck(receipt)
|
||||
(self.child/'value').write_text('changed after startup')
|
||||
with self.assertRaises(module.EvidenceError):
|
||||
module.validate_precheck(receipt)
|
||||
|
||||
def test_new_layout_cannot_use_receipt_without_component_identity(self):
|
||||
spec=importlib.util.spec_from_file_location('component_evidence_legacy',SCRIPTS/'evidence.py')
|
||||
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
self.run_git(self.root,'update-ref','refs/remotes/origin/main','HEAD')
|
||||
before=module.snapshot();before.pop('components')
|
||||
receipt=module.create_receipt('make precheck','[verdict] READY\n',0,before,before,
|
||||
timestamp='2026-10-13T00:00:00+00:00',cwd=str(self.root),
|
||||
git_common_dir=module.common_dir())
|
||||
with self.assertRaises(module.EvidenceError):
|
||||
module.validate_precheck(receipt)
|
||||
|
||||
def test_receipts_reject_component_path_escape(self):
|
||||
data=json.loads((self.root/'components.json').read_text())
|
||||
data['components'][0]['path']='../outside'
|
||||
(self.root/'components.json').write_text(json.dumps(data))
|
||||
with self.assertRaises(Exception):
|
||||
self.snapshot()
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Real Git/npm/Go fixtures for immutable product composition."""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/build-composition.py"
|
||||
RDM_SCRIPTS = Path(os.environ.get("COMPOSITION_RDM_SOURCE", str(SCRIPT.parent.parent / ".migration/Agent-System-RDM")))
|
||||
LEGACY_PRODUCT = Path(__file__).resolve().parent / "fixtures/rdm-legacy-product.sh"
|
||||
|
||||
|
||||
class LocalNpmRegistry:
|
||||
"""Real npm cache/registry traffic with no public-network dependency."""
|
||||
name = "composition-cold-fixture"
|
||||
|
||||
def __init__(self, base, env):
|
||||
self.base, self.env = base, env
|
||||
self.requests = []
|
||||
self.tarballs = {}
|
||||
self.integrities = {}
|
||||
for version in ("1.0.0", "1.0.1"):
|
||||
payload = json.dumps({"name": self.name, "version": version,
|
||||
"scripts": {"install": "node -e 'process.exit(91)'"}}).encode()
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
||||
entry = tarfile.TarInfo("package/package.json")
|
||||
entry.size = len(payload)
|
||||
archive.addfile(entry, io.BytesIO(payload))
|
||||
self.tarballs[version] = stream.getvalue()
|
||||
self.integrities[version] = "sha512-" + base64.b64encode(hashlib.sha512(stream.getvalue()).digest()).decode()
|
||||
registry = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
registry.requests.append((self.path, self.headers.get("Authorization")))
|
||||
if self.path == "/" + registry.name:
|
||||
versions = {v: {"name": registry.name, "version": v, "dist": {
|
||||
"tarball": registry.url + "/" + registry.name + "/-/" + v + ".tgz",
|
||||
"integrity": registry.integrities[v]}} for v in registry.tarballs}
|
||||
body = json.dumps({"name": registry.name, "dist-tags": {"latest": "1.0.1"}, "versions": versions}).encode()
|
||||
content_type = "application/json"
|
||||
else:
|
||||
version = self.path.rsplit("/", 1)[-1].removesuffix(".tgz")
|
||||
if version not in registry.tarballs:
|
||||
self.send_error(404)
|
||||
return
|
||||
body, content_type = registry.tarballs[version], "application/octet-stream"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.url = "http://127.0.0.1:" + str(self.server.server_port)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.cache = base / "cold-npm-cache"
|
||||
self.global_config = base / "empty-npm-globalrc"
|
||||
self.global_config.write_text("")
|
||||
self.home = base / "cold-home"
|
||||
self.home.mkdir()
|
||||
self.tools = base / "npm-tools"
|
||||
self.tools.mkdir()
|
||||
self.calls = base / "npm-calls.jsonl"
|
||||
npm = shutil.which("npm")
|
||||
wrapper = self.tools / "npm"
|
||||
wrapper.write_text("#!" + sys.executable + "\n" +
|
||||
"import json, os, sys\nfrom pathlib import Path\nargs = sys.argv[1:]\n" +
|
||||
"with Path(" + repr(str(self.calls)) + ").open('a') as output:\n" +
|
||||
" output.write(json.dumps({'args': args, 'credential_keys': [k for k in os.environ if any(w in k.upper() for w in ('TOKEN', 'SECRET', 'PASSWORD'))]}) + '\\n')\n" +
|
||||
"if args[:2] == ['cache', 'add'] and args[2].startswith(" + repr(self.name + "@") + "):\n" +
|
||||
" args = [a.replace('https://registry.npmjs.org', " + repr(self.url) + ") for a in args]\n" +
|
||||
"if args[:1] == ['ci']: args.append('--offline')\n" +
|
||||
"os.execv(" + repr(npm) + ", [" + repr(npm) + ", *args])\n")
|
||||
wrapper.chmod(0o755)
|
||||
self.env = env | {"HOME": str(self.home), "npm_config_cache": str(self.cache),
|
||||
"PATH": str(self.tools) + os.pathsep + env["PATH"]}
|
||||
# Seed only the locked tarball, exactly the state produced by npm ci.
|
||||
tarball = base / "locked.tgz"
|
||||
tarball.write_bytes(self.tarballs["1.0.0"])
|
||||
subprocess.run([npm, "cache", "add", str(tarball), "--ignore-scripts", "--userconfig=/dev/null",
|
||||
"--globalconfig=" + str(self.global_config)], env=self.env, check=True, capture_output=True)
|
||||
|
||||
def close(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
|
||||
def manifests(self, source):
|
||||
source.mkdir(exist_ok=True)
|
||||
manifest = {"name": "fixture", "version": "1.0.0", "dependencies": {self.name: "1.0.0"}}
|
||||
(source / "package.json").write_text(json.dumps(manifest))
|
||||
lock = {"name": "fixture", "version": "1.0.0", "lockfileVersion": 3, "requires": True,
|
||||
"packages": {"": manifest, "node_modules/" + self.name: {
|
||||
"version": "1.0.0", "resolved": "https://registry.npmjs.org/" + self.name + "/-/1.0.0.tgz",
|
||||
"integrity": self.integrities["1.0.0"]}}}
|
||||
(source / "package-lock.json").write_text(json.dumps(lock))
|
||||
return (source / "package-lock.json").read_bytes()
|
||||
|
||||
def consumer(self, directory, env=None):
|
||||
directory.mkdir(exist_ok=True)
|
||||
(directory / "package.json").write_text(json.dumps({"dependencies": {self.name: "1.0.0"}}))
|
||||
return subprocess.run(["npm", "install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund",
|
||||
"--registry=" + self.url, "--userconfig=/dev/null", "--globalconfig=" + str(self.global_config)],
|
||||
cwd=directory, env=env or self.env, capture_output=True, text=True)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("npm"), "npm required")
|
||||
class NpmAcquisitionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="composition-cold-npm-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.base = Path(self.temporary.name)
|
||||
self.registry = LocalNpmRegistry(self.base, os.environ.copy())
|
||||
self.addCleanup(self.registry.close)
|
||||
spec = importlib.util.spec_from_file_location("cold_npm_composer", SCRIPT)
|
||||
self.composer = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.composer)
|
||||
|
||||
def test_locked_metadata_is_acquired_before_offline_consumer_without_credentials(self):
|
||||
source = self.base / "source"
|
||||
original = self.registry.manifests(source)
|
||||
red = self.registry.consumer(self.base / "before")
|
||||
self.assertNotEqual(red.returncode, 0)
|
||||
self.assertIn("ENOTCACHED", red.stderr)
|
||||
self.composer.npm_dependencies(source, {}, self.registry.env | {"GITEA_TOKEN": "synthetic-secret"})
|
||||
self.assertEqual((source / "package-lock.json").read_bytes(), original)
|
||||
green = self.registry.consumer(self.base / "after", self.composer.execution_environment(self.registry.env))
|
||||
self.assertEqual(green.returncode, 0, green.stdout + green.stderr)
|
||||
actual = json.loads((self.base / "after/node_modules" / self.registry.name / "package.json").read_text())
|
||||
self.assertEqual(actual["version"], "1.0.0")
|
||||
calls = [json.loads(line) for line in self.registry.calls.read_text().splitlines()]
|
||||
metadata = [call for call in calls if call["args"][:2] == ["cache", "add"]]
|
||||
self.assertTrue(metadata, "tarball acquisition alone does not populate offline resolver metadata")
|
||||
self.assertEqual(metadata[0]["args"][2], self.registry.name + "@1.0.0")
|
||||
self.assertFalse(metadata[0]["credential_keys"])
|
||||
self.assertTrue(self.registry.requests)
|
||||
self.assertTrue(all(auth is None for _, auth in self.registry.requests))
|
||||
|
||||
def test_unreviewed_tarball_source_is_refused_before_acquisition(self):
|
||||
source = self.base / "source"
|
||||
self.registry.manifests(source)
|
||||
path = source / "package-lock.json"
|
||||
lock = json.loads(path.read_text())
|
||||
lock["packages"]["node_modules/" + self.registry.name]["resolved"] = self.registry.url + "/untrusted.tgz"
|
||||
path.write_text(json.dumps(lock))
|
||||
with self.assertRaisesRegex(self.composer.CompositionError, "reviewed public registry"):
|
||||
self.composer.npm_dependencies(source, {}, self.registry.env | {"GITEA_TOKEN": "synthetic-secret"})
|
||||
self.assertFalse(self.registry.requests)
|
||||
self.assertFalse(self.registry.calls.exists())
|
||||
|
||||
|
||||
@unittest.skipUnless(all(shutil.which(c) for c in ("git", "go", "node", "npm")), "Git/Go/Node/npm required")
|
||||
class CompositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="composition-test-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.root = self.base / "dev"
|
||||
self.backend = self.base / "backend"
|
||||
self.frontend = self.base / "frontend"
|
||||
self.out = self.base / "output"
|
||||
self.env = os.environ.copy()
|
||||
self.env.update(GOWORK="off", GOPROXY="off", GOSUMDB="off", GOTOOLCHAIN="local")
|
||||
self.env.pop("GOFLAGS", None)
|
||||
for repo in (self.root, self.backend, self.frontend):
|
||||
repo.mkdir()
|
||||
self.run_command(["git", "init", "-q", "-b", "main"], repo)
|
||||
self.run_command(["git", "config", "user.email", "fixture@example.invalid"], repo)
|
||||
self.run_command(["git", "config", "user.name", "Composition Fixture"], repo)
|
||||
self.write(self.backend, "go.mod", "module example.com/backend\n\ngo 1.23\n")
|
||||
self.write(self.backend, "web/dist/.gitkeep", "")
|
||||
self.write(self.backend, "web/embed.go", '''package web
|
||||
import "embed"
|
||||
//go:embed all:dist
|
||||
var Files embed.FS
|
||||
func Page() string { b, _ := Files.ReadFile("dist/index.html"); return string(b) }
|
||||
''')
|
||||
self.write(self.backend, "cmd/agent-system/main.go", '''package main
|
||||
import ("fmt"; "os"; "example.com/backend/web")
|
||||
var version = "fixture-dev"
|
||||
func main() { if len(os.Args)>1 && os.Args[1]=="version" {fmt.Print(version)} else {fmt.Print(web.Page())} }
|
||||
''')
|
||||
self.write(self.backend, "cmd/storage-migrate/main.go", '''package main
|
||||
import "fmt"
|
||||
func main() { fmt.Print("migration-tool") }
|
||||
''')
|
||||
self.write(self.backend, "web/embed_test.go", '''package web
|
||||
import "testing"
|
||||
func TestProduct(t *testing.T) { if Page() != "immutable-page" { t.Fatalf("unexpected page: %q", Page()) } }
|
||||
''')
|
||||
self.backend_sha = self.commit(self.backend)
|
||||
self.write(self.frontend, "package.json", json.dumps({
|
||||
"name": "composition-fixture", "version": "1.0.0", "private": True,
|
||||
"scripts": {"build": "node build.cjs", "test": "node test.cjs", "postinstall": "node -e \"process.exit(91)\""},
|
||||
}))
|
||||
self.write(self.frontend, "build.cjs", '''const fs = require('node:fs');
|
||||
if (process.env.GITEA_TOKEN || process.env.SSH_AUTH_SOCK || process.env.GIT_CONFIG_COUNT) process.exit(90);
|
||||
fs.mkdirSync('dist/assets', { recursive: true });
|
||||
fs.writeFileSync('dist/index.html', 'immutable-page');
|
||||
fs.writeFileSync('dist/assets/main.js', 'console.log("fixed")');
|
||||
''')
|
||||
self.write(self.frontend, "test.cjs", '''const fs = require('node:fs');
|
||||
if (fs.readFileSync('dist/index.html', 'utf8') !== 'immutable-page') process.exit(92);
|
||||
''')
|
||||
self.run_command(["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], self.frontend)
|
||||
self.frontend_sha = self.commit(self.frontend)
|
||||
self.manifest = {"schema": 1, "components": [
|
||||
{"id": "Agent-System-Backend", "repo": str(self.backend), "path": ".workspace/Agent-System-Backend", "role": "application", "modules": [{"path": "example.com/backend", "directory": "."}], "target": "main"},
|
||||
{"id": "Agent-System-Frontend", "repo": str(self.frontend), "path": ".workspace/Agent-System-Frontend", "role": "application", "modules": [], "target": "main"},
|
||||
]}
|
||||
self.lock = {"schema": 1, "components": {
|
||||
"Agent-System-Backend": {"commit": self.backend_sha},
|
||||
"Agent-System-Frontend": {"commit": self.frontend_sha},
|
||||
}}
|
||||
self.save_inputs()
|
||||
self.root_sha = self.commit(self.root)
|
||||
|
||||
def write(self, repo, path, value):
|
||||
destination = repo / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(value)
|
||||
|
||||
def run_command(self, command, cwd=None, env=None, check=True):
|
||||
result = subprocess.run(command, cwd=cwd, env=env or self.env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def commit(self, repo):
|
||||
self.run_command(["git", "add", "."], repo)
|
||||
self.run_command(["git", "commit", "-qm", "Fixture"], repo)
|
||||
return self.run_command(["git", "rev-parse", "HEAD"], repo).stdout.strip()
|
||||
|
||||
def save_inputs(self):
|
||||
self.write(self.root, "components.json", json.dumps(self.manifest))
|
||||
self.write(self.root, "components.lock.json", json.dumps(self.lock))
|
||||
|
||||
def compose(self, target="build", extra=(), env=None):
|
||||
return self.run_command([sys.executable, "-B", str(SCRIPT), "--root", str(self.root), "--target", target, "--output", str(self.out), *extra], env=env, check=False)
|
||||
|
||||
def acquisition_guard(self, env, *, mark_frontend=True):
|
||||
"""Forward to real tools, refusing acquisition after candidate execution."""
|
||||
marker = self.base / "candidate-executed"
|
||||
tools = self.base / "guarded-tools"
|
||||
tools.mkdir()
|
||||
for name in ("git", "go", "npm"):
|
||||
executable = shutil.which(name)
|
||||
wrapper = tools / name
|
||||
wrapper.write_text("#!" + sys.executable + "\n" + '''import os, pathlib, sys
|
||||
args = sys.argv[1:]
|
||||
name = pathlib.Path(sys.argv[0]).name
|
||||
acquisition = (name == 'git' and args and args[0] in {'clone', 'fetch', 'archive'}
|
||||
or name == 'go' and args and (args[0] == 'mod' or args[0] == 'list')
|
||||
or name == 'npm' and args and (args[0] in {'ci', 'install'} or args[:2] == ['cache', 'add']))
|
||||
if acquisition and pathlib.Path(os.environ['COMPOSITION_EXEC_MARKER']).exists():
|
||||
print('acquisition-after-execution: ' + name + ' ' + ' '.join(args[:2]), file=sys.stderr)
|
||||
sys.exit(86)
|
||||
''' + "os.execv(" + repr(executable) + ", [" + repr(executable) + ", *args])\n")
|
||||
wrapper.chmod(0o755)
|
||||
if mark_frontend:
|
||||
self.write(self.frontend, "build.cjs", (self.frontend / "build.cjs").read_text()
|
||||
+ "\nrequire('node:fs').writeFileSync(process.env.COMPOSITION_EXEC_MARKER, 'frontend-executed')\n")
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
return env | {"PATH": str(tools) + os.pathsep + env["PATH"],
|
||||
"COMPOSITION_EXEC_MARKER": str(marker), "GITEA_TOKEN": "synthetic-phase-token"}
|
||||
|
||||
def test_all_product_acquisition_precedes_candidate_execution(self):
|
||||
self.test_rdm_vendored_remote_backend_embeds_assets_without_mutating_module_cache()
|
||||
self.write(self.root, "plugins/example/plugin.json", '{"name":"fixed-plugin"}')
|
||||
self.commit(self.root)
|
||||
env = self.acquisition_guard(self.env | {"GOPROXY": (self.base / "proxy").as_uri(),
|
||||
"GOMODCACHE": str(self.base / "module-cache")})
|
||||
result = self.compose(extra=("--rdm-source", str(self.base / "rdm")), env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual((self.base / "candidate-executed").read_text(), "frontend-executed")
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system-rdm-product")]).stdout, "immutable-page")
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system-admin")]).stdout, "immutable-page")
|
||||
self.assertEqual((self.out / "plugins/example/plugin.json").read_text(), '{"name":"fixed-plugin"}')
|
||||
|
||||
def test_fixed_commits_embed_assets_without_touching_developer_checkout(self):
|
||||
# Reading working trees or allowing go.work/npm overlays would produce DIRTY or fail.
|
||||
self.write(self.backend, "web/dist/index.html", "DIRTY")
|
||||
self.write(self.backend, "cmd/agent-system/main.go", "invalid local Go")
|
||||
self.write(self.frontend, "build.cjs", "throw new Error('dirty local frontend')")
|
||||
self.write(self.root, "go.work", "invalid developer workspace")
|
||||
self.write(self.root, ".workspace/Agent-System-Frontend/package.json", '{"dependencies":{"bad":"file:/missing"}}')
|
||||
env = self.env | {"GITEA_TOKEN": "fixture-secret", "SSH_AUTH_SOCK": "/missing-agent", "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "credential.helper", "GIT_CONFIG_VALUE_0": "bad-helper"}
|
||||
result = self.compose(env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system")]).stdout, "immutable-page")
|
||||
self.assertEqual(self.run_command([str(self.out / "storage-migrate")]).stdout, "migration-tool")
|
||||
self.assertEqual((self.backend / "web/dist/index.html").read_text(), "DIRTY")
|
||||
resolved = json.loads((self.out / "resolved-manifest.json").read_text())
|
||||
self.assertEqual(resolved["root"]["commit"], self.root_sha)
|
||||
self.assertEqual(resolved["applications"]["Agent-System-Backend"]["commit"], self.backend_sha)
|
||||
self.assertEqual(resolved["applications"]["Agent-System-Frontend"]["commit"], self.frontend_sha)
|
||||
self.assertEqual(resolved["artifacts"]["agent-system"]["sha256"], hashlib.sha256((self.out / "agent-system").read_bytes()).hexdigest())
|
||||
self.assertEqual(resolved["go"]["backend"]["modules"][0]["Path"], "example.com/backend")
|
||||
self.assertTrue(resolved["npm"]["lock_sha256"])
|
||||
self.assertTrue(resolved["frontend"]["sha256"])
|
||||
self.assertNotIn("fixture-secret", (self.out / "resolved-manifest.json").read_text())
|
||||
|
||||
def test_test_target_runs_real_frontend_and_go_tests(self):
|
||||
result = self.compose("test")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("example.com/backend/web", result.stdout)
|
||||
self.assertEqual(json.loads((self.out / "resolved-manifest.json").read_text())["target"], "test")
|
||||
|
||||
def test_unused_transitive_zip_does_not_require_consumer_sum_rewrite(self):
|
||||
proxy = self.base / "unused-proxy"
|
||||
for name, mod, body in (
|
||||
("unused", "module example.com/unused\n\ngo 1.16\n", "package unused\n"),
|
||||
("used", "module example.com/used\n\ngo 1.16\nrequire example.com/unused v1.0.0\n", "package used\nfunc Value() int { return 7 }\n"),
|
||||
):
|
||||
directory = proxy / "example.com" / name / "@v"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "v1.0.0.mod").write_text(mod)
|
||||
(directory / "v1.0.0.info").write_text('{"Version":"v1.0.0","Time":"2026-10-13T00:00:00Z"}')
|
||||
(directory / "list").write_text("v1.0.0\n")
|
||||
with zipfile.ZipFile(directory / "v1.0.0.zip", "w") as archive:
|
||||
archive.writestr(f"example.com/{name}@v1.0.0/go.mod", mod)
|
||||
archive.writestr(f"example.com/{name}@v1.0.0/value.go", body)
|
||||
env = self.env | {"GOPROXY": proxy.as_uri(), "GOMODCACHE": str(self.base / "unused-cache")}
|
||||
self.write(self.backend, "go.mod", "module example.com/backend\n\ngo 1.23\nrequire example.com/used v1.0.0\n")
|
||||
self.write(self.backend, "web/value.go", 'package web\nimport "example.com/used"\nvar Value = used.Value()\n')
|
||||
self.run_command(["go", "mod", "tidy"], self.backend, env=env)
|
||||
sums = (self.backend / "go.sum").read_text()
|
||||
self.assertNotIn("example.com/unused v1.0.0 h1:", sums)
|
||||
self.lock["components"]["Agent-System-Backend"]["commit"] = self.commit(self.backend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose(env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual((self.backend / "go.sum").read_text(), sums)
|
||||
graph = json.loads((self.out / "resolved-manifest.json").read_text())["go"]["backend"]["modules"]
|
||||
self.assertIn("example.com/used", {module["Path"] for module in graph})
|
||||
|
||||
def test_developer_package_tarball_overrides_do_not_reach_formal_tests(self):
|
||||
self.write(self.frontend, "test.cjs", "if (process.env.CONTRACTS_TARBALL || process.env.UI_TARBALL) process.exit(93)")
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose("test", env=self.env | {"CONTRACTS_TARBALL": "/tmp/dirty-contracts.tgz", "UI_TARBALL": "/tmp/dirty-ui.tgz"})
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_test_target_checks_fixed_go_library_and_ui_sources(self):
|
||||
library = self.base / "probe-library"
|
||||
library.mkdir()
|
||||
self.run_command(["git", "init", "-q", "-b", "main"], library)
|
||||
self.run_command(["git", "config", "user.email", "fixture@example.invalid"], library)
|
||||
self.run_command(["git", "config", "user.name", "Fixture"], library)
|
||||
self.write(library, "go.mod", "module example.com/probe\n\ngo 1.23\n")
|
||||
self.write(library, "probe.go", "package probe\nfunc Text() string { return \"\" }\n")
|
||||
self.write(library, "probe_test.go", '''package probe
|
||||
import ("testing"; "os")
|
||||
func TestFixedLibrary(t *testing.T) {
|
||||
if err:=os.WriteFile(os.Getenv("COMPOSITION_GO_MARKER"), []byte("fixed-go-test"), 0600); err!=nil {t.Fatal(err)}
|
||||
if path:=os.Getenv("COMPOSITION_EXEC_MARKER"); path!="" { if err:=os.WriteFile(path, []byte("library-executed"), 0600); err!=nil {t.Fatal(err)} }
|
||||
}
|
||||
''')
|
||||
library_sha = self.commit(library)
|
||||
version = "v0.0.0-" + self.run_command(["git", "show", "-s", "--format=%cd", "--date=format-local:%Y%m%d%H%M%S", library_sha], library).stdout.strip() + "-" + library_sha[:12]
|
||||
proxy = self.base / "probe-proxy/example.com/probe/@v"
|
||||
proxy.mkdir(parents=True)
|
||||
(proxy / (version + ".mod")).write_text((library / "go.mod").read_text())
|
||||
(proxy / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(library), "Hash": library_sha}}))
|
||||
(proxy / "list").write_text(version + "\n")
|
||||
with zipfile.ZipFile(proxy / (version + ".zip"), "w") as archive:
|
||||
for path in self.run_command(["git", "ls-files"], library).stdout.splitlines():
|
||||
archive.write(library / path, "example.com/probe@" + version + "/" + path)
|
||||
self.write(self.backend, "go.mod", f"module example.com/backend\n\ngo 1.23\nrequire example.com/probe {version}\n")
|
||||
self.write(self.backend, "web/probe.go", 'package web\nimport "example.com/probe"\nvar _ = probe.Text()\n')
|
||||
env = self.env | {"GOPROXY": (self.base / "probe-proxy").as_uri(), "GOMODCACHE": str(self.base / "probe-cache"),
|
||||
"COMPOSITION_GO_MARKER": str(self.base / "go-tested"), "COMPOSITION_UI_MARKER": str(self.base / "ui-tested")}
|
||||
self.run_command(["go", "mod", "download", "all"], self.backend, env=env)
|
||||
self.lock["components"]["Agent-System-Backend"]["commit"] = self.commit(self.backend)
|
||||
self.write(library, "probe_test.go", "invalid dirty library tests")
|
||||
ui = self.base / "ui"
|
||||
ui.mkdir()
|
||||
self.run_command(["git", "init", "-q", "-b", "main"], ui)
|
||||
self.run_command(["git", "config", "user.email", "fixture@example.invalid"], ui)
|
||||
self.run_command(["git", "config", "user.name", "Fixture"], ui)
|
||||
self.write(ui, "package.json", json.dumps({"name": "@agent-system/ui", "version": "1.0.0", "scripts": {"test": "node test.cjs", "typecheck": "node -e \"process.exit(0)\""}}))
|
||||
self.write(ui, "test.cjs", "require('node:fs').writeFileSync(process.env.COMPOSITION_UI_MARKER, 'fixed-ui-test')")
|
||||
self.run_command(["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], ui)
|
||||
ui_sha = self.commit(ui)
|
||||
package = json.loads((self.frontend / "package.json").read_text())
|
||||
package["dependencies"] = {"@agent-system/ui": "git+" + ui.as_uri() + "#" + ui_sha}
|
||||
self.write(self.frontend, "package.json", json.dumps(package))
|
||||
self.run_command(["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], self.frontend)
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.write(ui, "test.cjs", "throw Error('dirty ui tests')")
|
||||
self.manifest["components"].extend([
|
||||
{"id": "Probe", "repo": str(library), "path": ".workspace/Probe", "role": "go-library", "modules": [{"path": "example.com/probe", "directory": "."}], "target": "main"},
|
||||
{"id": "Agent-System-UI", "repo": ui.as_uri(), "path": ".workspace/Agent-System-UI", "role": "npm-library", "modules": [], "target": "main"},
|
||||
])
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
env = self.acquisition_guard(env, mark_frontend=False)
|
||||
result = self.compose("test", env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue((self.base / "go-tested").is_file(), "resolved Go provider tests were not executed")
|
||||
self.assertEqual((self.base / "go-tested").read_text(), "fixed-go-test")
|
||||
self.assertTrue((self.base / "ui-tested").is_file(), "fixed npm UI provider tests were not executed")
|
||||
self.assertEqual((self.base / "ui-tested").read_text(), "fixed-ui-test")
|
||||
self.assertIn("go vet", result.stdout)
|
||||
|
||||
def test_version_and_plugin_assets_come_from_committed_root(self):
|
||||
self.write(self.root, "plugins/example/plugin.json", '{"name":"immutable-plugin"}')
|
||||
self.root_sha = self.commit(self.root)
|
||||
self.write(self.root, "plugins/example/plugin.json", '{"name":"dirty-plugin"}')
|
||||
result = self.compose()
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
resolved = json.loads((self.out / "resolved-manifest.json").read_text())
|
||||
self.assertIn("version", resolved)
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system"), "version"]).stdout, resolved["version"])
|
||||
self.assertIn(self.root_sha[:12], resolved["version"])
|
||||
self.assertIn(self.backend_sha[:12], resolved["version"])
|
||||
self.assertIn(self.frontend_sha[:12], resolved["version"])
|
||||
self.assertEqual((self.out / "plugins/example/plugin.json").read_text(), '{"name":"immutable-plugin"}')
|
||||
self.assertTrue(resolved["assets"]["plugins"]["sha256"])
|
||||
|
||||
def test_npm_local_dependency_overlay_is_rejected(self):
|
||||
lock = json.loads((self.frontend / "package-lock.json").read_text())
|
||||
lock["packages"]["node_modules/local"] = {"resolved": "file:../dirty"}
|
||||
self.write(self.frontend, "package-lock.json", json.dumps(lock))
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("local", result.stdout)
|
||||
self.assertFalse((self.out / "agent-system").exists())
|
||||
|
||||
def test_frontend_dist_symlink_cannot_import_assets_from_outside_source(self):
|
||||
external = self.base / "external-assets"
|
||||
self.write(external, "index.html", "escaped-page")
|
||||
self.write(external, "assets/main.js", "escaped-code")
|
||||
self.write(self.frontend, "build.cjs", "require('node:fs').symlinkSync(" + json.dumps(str(external)) + ", 'dist', 'dir')")
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("symlink", result.stdout)
|
||||
self.assertFalse((self.out / "agent-system").exists())
|
||||
|
||||
def test_project_test_mode_refuses_missing_boundary_gate(self):
|
||||
self.write(self.root, "Makefile", "test-governance:\n\ttrue\n")
|
||||
self.commit(self.root)
|
||||
result = self.compose("test")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("lint-boundaries", result.stdout)
|
||||
|
||||
def test_fixed_root_governance_and_boundaries_receive_proven_source_map(self):
|
||||
self.write(self.root, "Makefile", "test-governance:\n\tpython3 -B scripts/map-check.py governance\n")
|
||||
self.write(self.root, "scripts/lint-boundaries.sh", "#!/bin/sh\npython3 -B scripts/map-check.py boundaries\n")
|
||||
self.write(self.root, "scripts/check-pins.sh", "#!/bin/sh\npython3 -B scripts/map-check.py pins\n")
|
||||
self.write(self.root, "scripts/map-check.py", '''import json, os, pathlib, sys
|
||||
root = pathlib.Path.cwd()
|
||||
assert not (root / '.git').exists(), 'gates must run from the immutable archive'
|
||||
mapping = json.loads(pathlib.Path(os.environ['COMPOSITION_SOURCE_MAP']).read_text())
|
||||
lock = json.loads((root / 'components.lock.json').read_text())
|
||||
for name in ('Agent-System-Backend', 'Agent-System-Frontend'):
|
||||
assert mapping['components'][name]['commit'] == lock['components'][name]['commit']
|
||||
assert (pathlib.Path(mapping['components']['Agent-System-Backend']['path']) / 'web/dist/index.html').read_text() == 'immutable-page'
|
||||
with pathlib.Path(os.environ['COMPOSITION_GATE_MARKER']).open('a') as output: output.write(sys.argv[1]+'\\n')
|
||||
''')
|
||||
self.commit(self.root)
|
||||
# Dirty gate code is excluded, just like dirty application code.
|
||||
self.write(self.root, "scripts/map-check.py", "raise Exception('dirty governance')")
|
||||
marker = self.base / "gates-run"
|
||||
result = self.compose("test", extra=("--source-map", str(self.base / "source-map.json")), env=self.env | {"COMPOSITION_GATE_MARKER": str(marker)})
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(marker.read_text(), "boundaries\npins\ngovernance\n")
|
||||
self.assertEqual(json.loads((self.base / "source-map.json").read_text())["components"]["Agent-System-Backend"]["commit"], self.backend_sha)
|
||||
|
||||
def test_missing_frontend_assets_fails_before_publishing_binary(self):
|
||||
self.write(self.frontend, "build.cjs", "require('node:fs').mkdirSync('dist', {recursive:true});")
|
||||
self.lock["components"]["Agent-System-Frontend"]["commit"] = self.commit(self.frontend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("index.html", result.stdout)
|
||||
self.assertFalse((self.out / "agent-system").exists())
|
||||
|
||||
def test_dirty_root_lock_is_rejected(self):
|
||||
self.lock["components"]["Agent-System-Backend"]["commit"] = "a" * 40
|
||||
self.save_inputs()
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_dirty_root_composer_cannot_publish_under_committed_identity(self):
|
||||
tools = self.root / "scripts"
|
||||
(tools / "workspace").mkdir(parents=True)
|
||||
shutil.copy2(SCRIPT, tools / "build-composition.py")
|
||||
shutil.copy2(SCRIPT.parent / "workspace/transport.py", tools / "workspace/transport.py")
|
||||
self.commit(self.root)
|
||||
composer = tools / "build-composition.py"
|
||||
composer.write_text(composer.read_text().replace(
|
||||
'(destination / ".gitkeep").touch()',
|
||||
'(destination / ".gitkeep").touch(); (destination / "index.html").write_text("dirty-tool-page")'))
|
||||
result = self.run_command([sys.executable, str(composer), "--root", str(self.root),
|
||||
"--target", "build", "--output", str(self.out)], check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("tool", result.stdout)
|
||||
self.assertFalse((self.out / "agent-system").exists())
|
||||
|
||||
def test_dirty_root_transport_is_refused_before_import(self):
|
||||
tools = self.root / "scripts"
|
||||
(tools / "workspace").mkdir(parents=True)
|
||||
shutil.copy2(SCRIPT, tools / "build-composition.py")
|
||||
shutil.copy2(SCRIPT.parent / "workspace/transport.py", tools / "workspace/transport.py")
|
||||
self.commit(self.root)
|
||||
marker = self.base / "transport-executed"
|
||||
(tools / "workspace/transport.py").write_text(
|
||||
"from pathlib import Path\nPath(" + repr(str(marker)) + ").write_text('executed')\n")
|
||||
result = self.run_command([sys.executable, str(tools / "build-composition.py"), "--root", str(self.root),
|
||||
"--target", "build", "--output", str(self.out)], check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse(marker.exists(), "dirty transport executed before source identity validation")
|
||||
self.assertIn("committed", result.stdout)
|
||||
|
||||
def test_locked_missing_revision_is_rejected(self):
|
||||
self.lock["components"]["Agent-System-Backend"]["commit"] = "a" * 40
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse((self.out / "agent-system").exists())
|
||||
|
||||
def test_source_path_escape_is_rejected(self):
|
||||
self.manifest["components"][0]["path"] = "../outside"
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("path", result.stdout)
|
||||
|
||||
def test_local_go_replace_is_rejected(self):
|
||||
self.write(self.backend, "go.mod", "module example.com/backend\n\ngo 1.23\nreplace example.com/dirty => /missing/local\n")
|
||||
self.lock["components"]["Agent-System-Backend"]["commit"] = self.commit(self.backend)
|
||||
self.save_inputs()
|
||||
self.commit(self.root)
|
||||
result = self.compose()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("replace", result.stdout)
|
||||
|
||||
def test_rdm_vendored_remote_backend_embeds_assets_without_mutating_module_cache(self):
|
||||
rdm = self.base / "rdm"
|
||||
rdm.mkdir()
|
||||
self.run_command(["git", "init", "-q", "-b", "main"], rdm)
|
||||
self.run_command(["git", "config", "user.email", "fixture@example.invalid"], rdm)
|
||||
self.run_command(["git", "config", "user.name", "Fixture"], rdm)
|
||||
version = "v0.0.0-" + self.run_command(["git", "show", "-s", "--format=%cd", "--date=format-local:%Y%m%d%H%M%S", self.backend_sha], self.backend).stdout.strip() + "-" + self.backend_sha[:12]
|
||||
proxy = self.base / "proxy/example.com/backend/@v"
|
||||
proxy.mkdir(parents=True)
|
||||
(proxy / (version + ".mod")).write_text((self.backend / "go.mod").read_text())
|
||||
(proxy / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-13T00:00:00Z"}))
|
||||
(proxy / "list").write_text(version + "\n")
|
||||
with zipfile.ZipFile(proxy / (version + ".zip"), "w") as archive:
|
||||
paths = self.run_command(["git", "ls-files"], self.backend).stdout.splitlines()
|
||||
for path in paths:
|
||||
archive.write(self.backend / path, "example.com/backend@" + version + "/" + path)
|
||||
self.write(rdm, "go.mod", f"module example.com/rdm\n\ngo 1.23\nrequire example.com/backend {version}\n")
|
||||
self.write(rdm, "main.go", 'package main\nimport ("fmt"; "example.com/backend/web")\nfunc main() { fmt.Print(web.Page()) }\n')
|
||||
module_cache = self.base / "module-cache"
|
||||
env = self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(module_cache)}
|
||||
self.run_command(["go", "mod", "download", "all"], rdm, env=env)
|
||||
rdm_sha = self.commit(rdm)
|
||||
cached_asset = module_cache / ("example.com/backend@" + version) / "web/dist/.gitkeep"
|
||||
self.assertTrue(cached_asset.exists())
|
||||
result = self.compose(extra=("--rdm-source", str(rdm)), env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system-rdm-product")]).stdout, "immutable-page")
|
||||
self.assertFalse(cached_asset.with_name("index.html").exists())
|
||||
resolved = json.loads((self.out / "resolved-manifest.json").read_text())
|
||||
self.assertEqual(resolved["applications"]["Agent-System-RDM"]["commit"], rdm_sha)
|
||||
self.assertTrue(resolved["go"]["rdm"]["vendor_modules_sha256"])
|
||||
self.assertTrue(resolved["go"]["rdm"]["vendor_backend_source_sha256"])
|
||||
self.assertEqual(resolved["go"]["rdm"]["modules"][1]["Version"], version)
|
||||
|
||||
@unittest.skipUnless((RDM_SCRIPTS / "scripts/build-product.sh").is_file(), "external RDM source was not supplied; host must replay the actual wrapper")
|
||||
def test_versioned_rdm_product_entry_uses_fixed_composer(self):
|
||||
# Reuse the real module fixture first; its independent embed test remains
|
||||
# mandatory even when an external RDM wrapper is unavailable.
|
||||
self.test_rdm_vendored_remote_backend_embeds_assets_without_mutating_module_cache()
|
||||
rdm = self.base / "rdm"
|
||||
version = self.run_command(["go", "list", "-m", "-f", "{{.Version}}", "example.com/backend"], rdm,
|
||||
env=self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")}).stdout.strip()
|
||||
env = self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")}
|
||||
|
||||
# The versioned RDM entry invokes the same fixed Dev composer, even when
|
||||
# local RDM Go source is dirty. No API-only fallback is allowed.
|
||||
self.write(rdm, "go.mod", f"module git-ssh.u1680832.nyat.app/weichen/Agent-System-RDM.git\n\ngo 1.23\nrequire example.com/backend {version}\n")
|
||||
shutil.copy2(RDM_SCRIPTS / "Makefile", rdm / "Makefile")
|
||||
(rdm / "scripts").mkdir()
|
||||
shutil.copy2(RDM_SCRIPTS / "scripts/build-product.sh", rdm / "scripts/build-product.sh")
|
||||
self.commit(rdm)
|
||||
self.write(rdm, "main.go", "invalid local RDM")
|
||||
(self.root / "scripts/workspace").mkdir(parents=True)
|
||||
shutil.copy2(SCRIPT, self.root / "scripts/build-composition.py")
|
||||
shutil.copy2(SCRIPT.parent / "workspace/transport.py", self.root / "scripts/workspace/transport.py")
|
||||
self.commit(self.root)
|
||||
result = self.run_command(["make", "product", "COMPOSITION_ROOT=" + str(self.root)], rdm, env=env, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.run_command([str(rdm / "bin/agent-system-rdm-product")]).stdout, "immutable-page")
|
||||
self.assertTrue((rdm / "bin/agent-system-admin").exists())
|
||||
self.assertEqual(json.loads((rdm / "bin/PRODUCT_MANIFEST.json").read_text())["kind"], "agent-system-composition/1")
|
||||
|
||||
def test_modified_go_module_cache_is_rejected_before_product_publication(self):
|
||||
self.test_rdm_vendored_remote_backend_embeds_assets_without_mutating_module_cache()
|
||||
cached = next((self.base / "module-cache/example.com").glob("backend@*/web/embed.go"))
|
||||
cached.chmod(0o644)
|
||||
cached.write_text(cached.read_text() + "\n// unauthorized cache mutation\n")
|
||||
result = self.compose(extra=("--rdm-source", str(self.base / "rdm")),
|
||||
env=self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("modified", result.stdout)
|
||||
|
||||
def test_rdm_product_and_admin_follow_its_selected_backend_not_dev_source_lock(self):
|
||||
self.test_rdm_vendored_remote_backend_embeds_assets_without_mutating_module_cache()
|
||||
selected_embed = (self.backend / "web/embed.go").read_text().replace("return string(b)", 'return "selected-rdm:" + string(b)')
|
||||
self.write(self.backend, "web/embed.go", selected_embed)
|
||||
selected_sha = self.commit(self.backend)
|
||||
selected_version = "v0.0.0-" + self.run_command(["git", "show", "-s", "--format=%cd", "--date=format-local:%Y%m%d%H%M%S", selected_sha], self.backend).stdout.strip() + "-" + selected_sha[:12]
|
||||
proxy = self.base / "proxy/example.com/backend/@v"
|
||||
(proxy / (selected_version + ".mod")).write_text((self.backend / "go.mod").read_text())
|
||||
(proxy / (selected_version + ".info")).write_text(json.dumps({"Version": selected_version, "Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(self.backend), "Hash": selected_sha}}))
|
||||
with zipfile.ZipFile(proxy / (selected_version + ".zip"), "w") as archive:
|
||||
for path in self.run_command(["git", "ls-files"], self.backend).stdout.splitlines():
|
||||
archive.write(self.backend / path, "example.com/backend@" + selected_version + "/" + path)
|
||||
rdm = self.base / "rdm"
|
||||
self.write(rdm, "go.mod", f"module example.com/rdm\n\ngo 1.23\nrequire example.com/backend {selected_version}\n")
|
||||
env = self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")}
|
||||
self.run_command(["go", "mod", "download", "all"], rdm, env=env)
|
||||
self.commit(rdm)
|
||||
result = self.compose(extra=("--rdm-source", str(rdm)), env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system")]).stdout, "immutable-page")
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system-rdm-product")]).stdout, "selected-rdm:immutable-page")
|
||||
self.assertEqual(self.run_command([str(self.out / "agent-system-admin")]).stdout, "selected-rdm:immutable-page")
|
||||
resolved = json.loads((self.out / "resolved-manifest.json").read_text())
|
||||
self.assertEqual(resolved["applications"]["Agent-System-Backend"]["commit"], self.backend_sha)
|
||||
self.assertEqual(resolved["go"]["rdm"]["backend_source"]["commit"], selected_sha)
|
||||
self.assertEqual(resolved["go"]["rdm"]["backend_source"]["version"], selected_version)
|
||||
admin_version = self.run_command([str(self.out / "agent-system-admin"), "version"]).stdout
|
||||
self.assertEqual(admin_version, resolved["go"]["rdm"]["admin_version"])
|
||||
self.assertIn(selected_sha[:12], admin_version)
|
||||
self.assertFalse((self.base / "module-cache/example.com" / ("backend@" + selected_version) / "web/dist/index.html").exists())
|
||||
|
||||
def test_historical_rdm_product_still_builds_from_legacy_pins(self):
|
||||
rdm = self.base / "legacy-rdm"
|
||||
rdm.mkdir()
|
||||
self.run_command(["git", "init", "-q", "-b", "main"], rdm)
|
||||
self.run_command(["git", "config", "user.email", "fixture@example.invalid"], rdm)
|
||||
self.run_command(["git", "config", "user.name", "Fixture"], rdm)
|
||||
self.write(rdm, "go.mod", "module legacy-rdm\n\ngo 1.23\nrequire example.com/backend v0.0.0\nreplace example.com/backend => ./pins/agent-system-backend\n")
|
||||
self.write(rdm, "main.go", 'package main\nimport ("fmt"; "example.com/backend/web")\nfunc main() { fmt.Print(web.Page()) }\n')
|
||||
(rdm / "scripts").mkdir()
|
||||
shutil.copy2(LEGACY_PRODUCT, rdm / "scripts/build-product.sh")
|
||||
self.commit(rdm)
|
||||
pins = rdm / "pins"
|
||||
pins.mkdir()
|
||||
for name, source in (("agent-system-backend", self.backend), ("agent-system-frontend", self.frontend), ("agent-system-ui", self.frontend)):
|
||||
self.run_command(["git", "clone", "-q", str(source), str(pins / name)])
|
||||
for name in ("agent-system-frontend", "agent-system-ui"):
|
||||
package = json.loads((pins / name / "package.json").read_text())
|
||||
del package["scripts"]["postinstall"]
|
||||
self.write(pins / name, "package.json", json.dumps(package))
|
||||
self.run_command(["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], pins / name)
|
||||
result = self.run_command(["bash", "scripts/build-product.sh"], rdm, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.run_command([str(rdm / "bin/agent-system-rdm-product")]).stdout, "immutable-page")
|
||||
self.assertEqual(json.loads((rdm / "bin/PRODUCT_MANIFEST.json").read_text())["kind"], "rdm-product/1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Replay trusted workflow phases with real Git, Go, npm and disposable HTTP repos."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shutil
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
from tests import test_ci_credentials as fixture
|
||||
from tests.test_composition import LocalNpmRegistry
|
||||
|
||||
ROOT_TOKEN, SUB_TOKEN, SSH, WORKFLOW = fixture.ROOT_TOKEN, fixture.SUB_TOKEN, fixture.SSH, fixture.WORKFLOW
|
||||
|
||||
MODULE = "git-ssh.u1680832.nyat.app/weichen/Logging-System.git"
|
||||
|
||||
|
||||
def phase(workflow, name):
|
||||
return fixture.workflow_script(workflow, name)
|
||||
|
||||
|
||||
class ComponentWorkflowFixtureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.base = Path(self.temporary.name)
|
||||
self.snapshots = self.base / "snapshots"
|
||||
shutil.copytree(fixture.COMPONENT_FIXTURES, self.snapshots)
|
||||
|
||||
def test_all_required_component_snapshots_have_fixed_provenance(self):
|
||||
for repository in fixture.COMPONENT_REPOSITORIES:
|
||||
with self.subTest(repository=repository):
|
||||
path = fixture.component_workflow(repository)
|
||||
self.assertEqual(path, fixture.COMPONENT_FIXTURES / repository / "ci.yml")
|
||||
self.assertTrue(phase(path, "Checkout"))
|
||||
|
||||
def test_modified_workflow_snapshot_is_rejected_before_execution(self):
|
||||
path = self.snapshots / "Agent-Native/ci.yml"
|
||||
path.write_bytes(path.read_bytes() + b"\n# unexpected workflow replacement\n")
|
||||
with self.assertRaisesRegex(ValueError, "recorded source digest"):
|
||||
fixture.component_workflow("Agent-Native", snapshots=self.snapshots)
|
||||
|
||||
def test_missing_component_snapshot_is_not_skipped(self):
|
||||
(self.snapshots / "Agent-Native/ci.yml").unlink()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
fixture.component_workflow("Agent-Native", snapshots=self.snapshots)
|
||||
|
||||
def test_omitted_repository_provenance_is_rejected(self):
|
||||
path = self.snapshots / "provenance.json"
|
||||
provenance = json.loads(path.read_text())
|
||||
del provenance["components"]["Agent-Native"]
|
||||
path.write_text(json.dumps(provenance))
|
||||
with self.assertRaisesRegex(ValueError, "every reviewed repository"):
|
||||
fixture.component_workflow("Agent-Native", snapshots=self.snapshots)
|
||||
|
||||
def test_optional_source_validation_uses_immutable_commit_and_rejects_stale_head(self):
|
||||
source_root = self.base / "sources"
|
||||
source = source_root / "Agent-Native"
|
||||
workflow = source / ".gitea/workflows/ci.yml"
|
||||
workflow.parent.mkdir(parents=True)
|
||||
captured = self.snapshots / "Agent-Native/ci.yml"
|
||||
workflow.write_bytes(captured.read_bytes())
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
env.update(GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", "-C", str(source), *args], env=env, text=True).strip()
|
||||
|
||||
git("init", "-q")
|
||||
git("add", ".")
|
||||
git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "Source")
|
||||
metadata = self.snapshots / "provenance.json"
|
||||
provenance = json.loads(metadata.read_text())
|
||||
provenance["components"]["Agent-Native"]["source_sha"] = git("rev-parse", "HEAD")
|
||||
metadata.write_text(json.dumps(provenance))
|
||||
# A mutable file must not substitute for the committed Git object.
|
||||
workflow.write_text("uncommitted workflow replacement\n")
|
||||
self.assertEqual(fixture.component_workflow("Agent-Native", snapshots=self.snapshots,
|
||||
source_root=source_root), captured)
|
||||
git("add", ".")
|
||||
git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "Changed")
|
||||
with self.assertRaisesRegex(ValueError, "source HEAD differs"):
|
||||
fixture.component_workflow("Agent-Native", snapshots=self.snapshots, source_root=source_root)
|
||||
provenance["components"]["Agent-Native"]["source_sha"] = git("rev-parse", "HEAD")
|
||||
metadata.write_text(json.dumps(provenance))
|
||||
with self.assertRaisesRegex(ValueError, "immutable Git source"):
|
||||
fixture.component_workflow("Agent-Native", snapshots=self.snapshots, source_root=source_root)
|
||||
|
||||
|
||||
class DependencyCITests(unittest.TestCase):
|
||||
def test_boundary_gate_refuses_missing_ripgrep_before_resolving_sources(self):
|
||||
with tempfile.TemporaryDirectory(prefix="boundary-no-tools-") as directory:
|
||||
result = subprocess.run([shutil.which("bash"), str(WORKFLOW.parents[2] / "scripts/lint-boundaries.sh")],
|
||||
cwd=directory, env=os.environ | {"PATH": directory}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertIn("requires ripgrep", result.stderr)
|
||||
|
||||
def test_privileged_acquisition_finishes_before_candidate_execution_in_each_job(self):
|
||||
content = WORKFLOW.read_text()
|
||||
jobs = re.split(r"^ ([A-Za-z0-9_-]+):\n", content, flags=re.M)
|
||||
for index in range(1, len(jobs), 2):
|
||||
job, body = jobs[index:index+2]
|
||||
executed = False
|
||||
steps = re.split(r"^ - name: ([^\n]+)\n", body, flags=re.M)
|
||||
for step in range(1, len(steps), 2):
|
||||
name, script = steps[step:step+2]
|
||||
with self.subTest(job=job, step=name):
|
||||
if re.search(r"\$\{\{\s*secrets\.", script):
|
||||
self.assertFalse(executed, "privileged acquisition follows candidate execution")
|
||||
# run may be an inline YAML scalar or a multiline block;
|
||||
# both shell and Python project entry points execute code.
|
||||
if re.search(r"(?:^|\n)\s*(?:run:\s*)?(?:make\s|go\s+(?:build|test|vet)\s|npm\s+(?:run|test)\s|bash\s+scripts/|python3?\s+(?:-m\s+unittest|scripts/))", script):
|
||||
executed = True
|
||||
|
||||
setUp = fixture.CheckoutTests.setUp
|
||||
git = fixture.CheckoutTests.git
|
||||
publish = fixture.CheckoutTests.publish
|
||||
run_checkout = fixture.CheckoutTests.run_checkout
|
||||
|
||||
def run_phase(self, name, directory=None, workflow=WORKFLOW, extra=None, after=""):
|
||||
env = self.env | {"SUBMODULE_TOKEN": SUB_TOKEN, "GITHUB_SERVER_URL": self.url,
|
||||
"GITHUB_REPOSITORY": "weichen/Agent-System-Dev",
|
||||
"GOCACHE": str(self.base / "gocache"),
|
||||
"GOMODCACHE": str(self.base / "gomodcache"),
|
||||
"npm_config_cache": str(self.base / "npm-cache")}
|
||||
return subprocess.run(["bash", "-c", phase(workflow, name) + "\n" + after],
|
||||
cwd=directory or self.checkout, env=env | (extra or {}),
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
def commit_fixture(self, name, files):
|
||||
sha = self.publish(name)
|
||||
source = self.base / name
|
||||
for path, content in files.items():
|
||||
dest = source / path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(content)
|
||||
self.git(source, "add", ".")
|
||||
subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture",
|
||||
"-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "Package"],
|
||||
env=self.env | {"GIT_AUTHOR_DATE": "2026-01-02T03:04:05Z",
|
||||
"GIT_COMMITTER_DATE": "2026-01-02T03:04:05Z"}, check=True)
|
||||
sha = self.git(source, "rev-parse", "HEAD")
|
||||
self.git(source, "push", "-q", str(self.published / "weichen" / (name + ".git")), "main")
|
||||
return sha
|
||||
|
||||
def fixture_checksums(self, module, sha):
|
||||
"""Use Go's checksum algorithm on a local fixture, outside the cache under test."""
|
||||
directory = self.base / "checksum-fixture"
|
||||
directory.mkdir()
|
||||
version = "v0.0.0-20260102030405-" + sha[:12]
|
||||
(directory / "go.mod").write_text(f"module checksumfixture\n\ngo 1.25\nrequire {module} {version}\n")
|
||||
repo = self.published / "weichen/Logging-System.git"
|
||||
env = self.env | {"GOWORK": "off", "GOTOOLCHAIN": "local", "GOENV": "off", "GOFLAGS": "",
|
||||
"GOPROXY": "off", "GOSUMDB": "off", "GOMODCACHE": str(self.base / "checksum-cache"),
|
||||
"GOPRIVATE": "git-ssh.u1680832.nyat.app/weichen", "GONOPROXY": "git-ssh.u1680832.nyat.app/weichen",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_COUNT": "3",
|
||||
"GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "always",
|
||||
"GIT_CONFIG_KEY_1": "url." + repo.as_uri() + ".insteadOf",
|
||||
"GIT_CONFIG_VALUE_1": "https://git-ssh.u1680832.nyat.app/weichen/Logging-System",
|
||||
"GIT_CONFIG_KEY_2": "core.hooksPath", "GIT_CONFIG_VALUE_2": "/dev/null"}
|
||||
result = subprocess.run(["go", "mod", "download", "-json"], cwd=directory, env=env,
|
||||
capture_output=True, text=True, check=True)
|
||||
info = json.loads(result.stdout)
|
||||
return f"{module} {version} {info['Sum']}\n{module} {version}/go.mod {info['GoModSum']}\n"
|
||||
|
||||
def test_registered_application_lock_materializes_fixed_source(self):
|
||||
backend = self.publish("Agent-System-Backend")
|
||||
root = self.commit_fixture("Agent-System-Dev", {
|
||||
"components.json": json.dumps({"schema": 1, "components": [{
|
||||
"id": "Agent-System-Backend", "repo": SSH + "Agent-System-Backend.git",
|
||||
"path": ".workspace/Agent-System-Backend", "role": "application", "modules": []}, {
|
||||
"id": "Agent-System-UI", "repo": SSH + "Agent-System-UI.git",
|
||||
"path": ".workspace/Agent-System-UI", "role": "npm-library", "modules": []}]}),
|
||||
"components.lock.json": json.dumps({"schema": 1, "components": {
|
||||
"Agent-System-Backend": {"commit": backend}}})})
|
||||
result = self.run_checkout(root)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((self.checkout / ".workspace/Agent-System-Backend/.git").exists(),
|
||||
"versioned application source was not materialized")
|
||||
self.assertEqual(self.git(self.checkout / ".workspace/Agent-System-Backend", "rev-parse", "HEAD"), backend)
|
||||
self.assertEqual(self.git(self.checkout / ".workspace/Agent-System-Backend", "config", "remote.origin.url"),
|
||||
SSH + "Agent-System-Backend.git")
|
||||
|
||||
def test_unregistered_source_lock_is_rejected_before_component_fetch(self):
|
||||
root = self.commit_fixture("Agent-System-Dev", {
|
||||
"components.json": json.dumps({"schema": 1, "components": [{
|
||||
"id": "Agent-System-Backend", "repo": self.url + "/untrusted/receiver",
|
||||
"path": ".workspace/Agent-System-Backend", "role": "application", "modules": []}]}),
|
||||
"components.lock.json": json.dumps({"schema": 1, "components": {
|
||||
"Agent-System-Backend": {"commit": "a" * 40}}})})
|
||||
result = self.run_checkout(root)
|
||||
self.assertNotEqual(result.returncode, 0, "untrusted manifest was accepted")
|
||||
self.assertFalse(any("Backend" in path or "untrusted" in path for path, _ in self.requests))
|
||||
|
||||
def test_go_prefetch_uses_only_registered_private_repo_and_local_toolchain(self):
|
||||
sha = self.commit_fixture("Logging-System", {
|
||||
"go.mod": f"module {MODULE}\n\ngo 1.25\n", "logging.go": "package logging\nconst Value = 42\n"})
|
||||
(self.checkout / "go.mod").write_text(
|
||||
f"module fixture\n\ngo 1.25\n\nrequire {MODULE} v0.0.0-20260102030405-{sha[:12]}\n")
|
||||
(self.checkout / "go.sum").write_text(self.fixture_checksums(MODULE, sha))
|
||||
(self.checkout / "probe.go").write_text(f'package fixture\nimport logging "{MODULE}"\nvar Value = logging.Value\n')
|
||||
result = self.run_phase("Prefetch Go modules without executing candidate code",
|
||||
extra={"GOTOOLCHAIN": "auto", "GIT_TRACE": "1"})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(self.requests)
|
||||
for path, auth in self.requests:
|
||||
self.assertRegex(path, r"^/weichen/Logging-System\.git/")
|
||||
self.assertEqual(auth, "token " + SUB_TOKEN)
|
||||
self.assertNotIn(SUB_TOKEN, result.stdout + result.stderr)
|
||||
tested = subprocess.run(["go", "test", "./..."], cwd=self.checkout,
|
||||
env=self.env | {"GOTOOLCHAIN": "local", "GOWORK": "off", "GOPROXY": "off",
|
||||
"GOSUMDB": "off", "GOMODCACHE": str(self.base / "gomodcache"),
|
||||
"GOCACHE": str(self.base / "gocache")}, capture_output=True, text=True)
|
||||
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
|
||||
for config in self.base.rglob("config"):
|
||||
if config.is_file():
|
||||
self.assertNotIn(SUB_TOKEN, config.read_text(errors="replace"))
|
||||
|
||||
def test_unregistered_go_module_rejected_before_authenticated_request(self):
|
||||
(self.checkout / "go.mod").write_text(
|
||||
"module fixture\n\ngo 1.25\nrequire git-ssh.u1680832.nyat.app/weichen/Unlisted.git v1.0.0\n")
|
||||
result = self.run_phase("Prefetch Go modules without executing candidate code")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.requests)
|
||||
|
||||
def test_npm_git_package_installs_committed_output_without_prepare(self):
|
||||
marker = self.base / "prepare-ran"
|
||||
sha = self.commit_fixture("Agent-System-Core", {
|
||||
"package.json": json.dumps({"name": "@agent-system/contracts", "version": "1.0.0",
|
||||
"files": ["package"], "main": "package/index.js"}),
|
||||
"package/index.js": "module.exports = {value: 42}\n"})
|
||||
spec = "git+" + SSH + "Agent-System-Core.git#" + sha
|
||||
(self.checkout / "package.json").write_text(json.dumps({"name": "fixture", "version": "1.0.0",
|
||||
"dependencies": {"@agent-system/contracts": spec},
|
||||
"scripts": {"postinstall": f"touch '{marker}'"}}))
|
||||
(self.checkout / "package-lock.json").write_text(json.dumps({"name": "fixture", "version": "1.0.0",
|
||||
"lockfileVersion": 3, "requires": True, "packages": {"": {"name": "fixture", "version": "1.0.0",
|
||||
"dependencies": {"@agent-system/contracts": spec}}, "node_modules/@agent-system/contracts": {
|
||||
"version": "1.0.0", "resolved": spec}}}))
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(marker.exists(), "candidate lifecycle ran while credential config was present")
|
||||
consumer = subprocess.run(["node", "-e", "if(require('@agent-system/contracts').value !== 42)process.exit(1)"],
|
||||
cwd=self.checkout, env=self.env, capture_output=True, text=True)
|
||||
self.assertEqual(consumer.returncode, 0, consumer.stderr)
|
||||
self.assertNotIn(SUB_TOKEN, result.stdout + result.stderr)
|
||||
for path, auth in self.requests:
|
||||
self.assertRegex(path, r"^/weichen/Agent-System-Core\.git/")
|
||||
self.assertEqual(auth, "token " + SUB_TOKEN)
|
||||
|
||||
def test_npm_prefetch_populates_cold_offline_resolver_metadata_without_credentials(self):
|
||||
workflows = {"Agent-System-Dev": WORKFLOW, **{repo: fixture.component_workflow(repo)
|
||||
for repo in ("Agent-System-UI", "Agent-System-Frontend")}}
|
||||
for repo, workflow in workflows.items():
|
||||
with self.subTest(repo=repo):
|
||||
base = self.base / repo
|
||||
base.mkdir()
|
||||
registry = LocalNpmRegistry(base, self.env)
|
||||
self.addCleanup(registry.close)
|
||||
original = registry.manifests(self.checkout)
|
||||
red = registry.consumer(base / "cold-before")
|
||||
self.assertNotEqual(red.returncode, 0)
|
||||
self.assertIn("ENOTCACHED", red.stderr)
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts", workflow=workflow, extra=registry.env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual((self.checkout / "package-lock.json").read_bytes(), original)
|
||||
green = registry.consumer(base / "cold-after")
|
||||
self.assertEqual(green.returncode, 0, green.stdout + green.stderr)
|
||||
installed = json.loads((base / "cold-after/node_modules" / registry.name / "package.json").read_text())
|
||||
self.assertEqual(installed["version"], "1.0.0")
|
||||
calls = [json.loads(line) for line in registry.calls.read_text().splitlines()]
|
||||
metadata = [call for call in calls if call["args"][:2] == ["cache", "add"]]
|
||||
self.assertTrue(metadata)
|
||||
self.assertEqual(metadata[0]["args"][2], registry.name + "@1.0.0")
|
||||
self.assertFalse(metadata[0]["credential_keys"])
|
||||
self.assertTrue(all(auth is None for _, auth in registry.requests))
|
||||
|
||||
def test_npm_metadata_version_cannot_be_a_destination(self):
|
||||
(self.checkout / "package.json").write_text(json.dumps({"name": "fixture"}))
|
||||
(self.checkout / "package-lock.json").write_text(json.dumps({"lockfileVersion": 3, "packages": {
|
||||
"node_modules/evil": {"version": self.url + "/untrusted.tgz",
|
||||
"resolved": "https://registry.npmjs.org/evil/-/evil-1.0.0.tgz"}}}))
|
||||
workflows = {"Agent-System-Dev": WORKFLOW, **{repo: fixture.component_workflow(repo)
|
||||
for repo in ("Agent-System-UI", "Agent-System-Frontend")}}
|
||||
for repo, workflow in workflows.items():
|
||||
with self.subTest(repo=repo):
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts", workflow=workflow)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("locked registry package", result.stderr)
|
||||
self.assertFalse(self.requests)
|
||||
|
||||
def test_npm_provider_prepare_is_rejected_without_execution(self):
|
||||
marker = self.base / "prepare-ran"
|
||||
sha = self.commit_fixture("Agent-System-Core", {
|
||||
"package.json": json.dumps({"name": "@agent-system/contracts", "version": "1.0.0",
|
||||
"files": ["package"], "scripts": {"prepare": f"touch '{marker}'"}}),
|
||||
"package/index.js": "module.exports = {value: 42}\n"})
|
||||
spec = "git+" + SSH + "Agent-System-Core.git#" + sha
|
||||
(self.checkout / "package.json").write_text(json.dumps({"name": "fixture", "dependencies": {"contracts": spec}}))
|
||||
(self.checkout / "package-lock.json").write_text(json.dumps({"lockfileVersion": 3,
|
||||
"packages": {"node_modules/contracts": {"resolved": spec}}}))
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("without acquisition lifecycle", result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_external_npm_git_source_rejected_before_authenticated_request(self):
|
||||
spec = self.url + "/untrusted/receiver.git#" + "a" * 40
|
||||
(self.checkout / "package.json").write_text(json.dumps({"name": "fixture", "dependencies": {"evil": "git+" + spec}}))
|
||||
(self.checkout / "package-lock.json").write_text(json.dumps({"lockfileVersion": 3,
|
||||
"packages": {"node_modules/evil": {"resolved": "git+" + spec}}}))
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.requests)
|
||||
|
||||
def test_component_checkout_removes_tokens_before_candidate_executes(self):
|
||||
for repo in ("Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native", "Agent-Intent",
|
||||
"Agent-Command", "Agent-Squad", "Agent-System-Backend", "Agent-System-UI",
|
||||
"Agent-System-Frontend", "Agent-System-RDM"):
|
||||
with self.subTest(repo=repo):
|
||||
sha = self.publish(repo)
|
||||
self.checkout = self.base / (repo + "-checkout")
|
||||
self.checkout.mkdir()
|
||||
result = self.run_phase("Checkout", workflow=fixture.component_workflow(repo),
|
||||
extra={"TOKEN": ROOT_TOKEN, "GITHUB_SHA": sha, "GITHUB_REPOSITORY": "weichen/" + repo},
|
||||
after="python3 probe.py")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_go_requirement_for_new_toolchain_fails_without_downloading_one(self):
|
||||
(self.checkout / "go.mod").write_text("module fixture\n\ngo 99.0\n")
|
||||
result = self.run_phase("Prefetch Go modules without executing candidate code", extra={"GOTOOLCHAIN": "auto"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.requests)
|
||||
self.assertIn("GOTOOLCHAIN=local", result.stderr)
|
||||
|
||||
def test_npm_nonregistry_tarball_rejected_before_request(self):
|
||||
spec = self.url + "/untrusted/payload.tgz"
|
||||
(self.checkout / "package.json").write_text(json.dumps({"name": "fixture", "dependencies": {"evil": spec}}))
|
||||
(self.checkout / "package-lock.json").write_text(json.dumps({"lockfileVersion": 3,
|
||||
"packages": {"node_modules/evil": {"resolved": spec}}}))
|
||||
result = self.run_phase("Prefetch npm packages without lifecycle scripts")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.requests, "candidate tarball source was contacted")
|
||||
|
||||
def test_component_ci_executes_nested_go_module_tests(self):
|
||||
(self.checkout / "go.mod").write_text("module fixture\n\ngo 1.25\n")
|
||||
(self.checkout / "main.go").write_text("package fixture\nconst Value = 42\n")
|
||||
(self.checkout / ".ci-mirrors.env").write_text("export GOTOOLCHAIN=local GOWORK=off GOENV=off\n")
|
||||
for repo, suffix in (("Logging-System", "examples/trace"), ("Agent-Squad", "adapters/native")):
|
||||
with self.subTest(repo=repo):
|
||||
directory = self.checkout / suffix
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "go.mod").write_text("module nestedfixture\n\ngo 1.25\n")
|
||||
(directory / "regression_test.go").write_text(
|
||||
'package nestedfixture\nimport "testing"\nfunc TestNested(t *testing.T) {t.Fatal("intentional nested regression")}\n')
|
||||
lines = fixture.component_workflow(repo).read_text().splitlines()
|
||||
command = next(i for i, line in enumerate(lines) if "go vet" in line)
|
||||
start = max(i for i in range(command) if lines[i].strip().endswith("run: |")) + 1
|
||||
end = next((i for i in range(start, len(lines))
|
||||
if lines[i].strip() and not lines[i].startswith(" ")), len(lines))
|
||||
script = textwrap.dedent("\n".join(lines[start:end]))
|
||||
# Component CI's race gate uses the native runner C compiler;
|
||||
# formal product builds independently set CGO_ENABLED=0.
|
||||
result = subprocess.run(["bash", "-c", script], cwd=self.checkout, env=self.env | {"CGO_ENABLED": "1"},
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertNotEqual(result.returncode, 0, "nested regression was skipped by component CI")
|
||||
self.assertIn("intentional nested regression", result.stdout + result.stderr)
|
||||
|
||||
def test_go_prefetch_covers_logging_trace_module(self):
|
||||
sha = self.commit_fixture("Logging-System", {
|
||||
"go.mod": f"module {MODULE}\n\ngo 1.25\n", "logging.go": "package logging\nconst Value = 42\n"})
|
||||
(self.checkout / "go.mod").write_text("module fixture\n\ngo 1.25\n")
|
||||
example = self.checkout / "examples/trace"
|
||||
example.mkdir(parents=True)
|
||||
(example / "go.mod").write_text(
|
||||
f"module tracefixture\n\ngo 1.27\n\nrequire {MODULE} v0.0.0-20260102030405-{sha[:12]}\n")
|
||||
(example / "go.sum").write_text(self.fixture_checksums(MODULE, sha))
|
||||
before = (example / "go.sum").read_bytes()
|
||||
result = self.run_phase("Prefetch Go modules without executing candidate code",
|
||||
workflow=fixture.component_workflow("Logging-System"))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(self.requests, "trace module's private requirement was not prefetched")
|
||||
self.assertEqual((example / "go.sum").read_bytes(), before, "Go 1.27 prefetch pruned source checksums")
|
||||
|
||||
def test_go_prefetch_rejects_mutation_of_committed_checksums(self):
|
||||
sha = self.commit_fixture("Logging-System", {
|
||||
"go.mod": f"module {MODULE}\n\ngo 1.25\n", "logging.go": "package logging\nconst Value = 42\n"})
|
||||
(self.checkout / "go.mod").write_text(
|
||||
f"module fixture\n\ngo 1.25\n\nrequire {MODULE} v0.0.0-20260102030405-{sha[:12]}\n")
|
||||
(self.checkout / "go.sum").write_text("")
|
||||
result = self.run_phase("Prefetch Go modules without executing candidate code")
|
||||
self.assertNotEqual(result.returncode, 0, "private acquisition silently rewrote committed checksums")
|
||||
self.assertTrue("modified a committed Go dependency file" in result.stderr or "missing go.sum" in result.stderr,
|
||||
result.stderr)
|
||||
|
||||
def test_rdm_composition_lock_fetches_fixed_dev_without_executing_code(self):
|
||||
dev = self.commit_fixture("Agent-System-Dev", {"should-not-run.py": "raise RuntimeError('candidate executed')\n"})
|
||||
(self.checkout / "composition.dev.json").write_text(json.dumps({
|
||||
"schema": 1, "repo": SSH + "Agent-System-Dev.git", "commit": dev}))
|
||||
result = self.run_phase("Prefetch fixed Dev product composition",
|
||||
workflow=fixture.component_workflow("Agent-System-RDM"))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.git(self.checkout / ".ci-composition-dev", "rev-parse", "HEAD"), dev)
|
||||
for path, auth in self.requests:
|
||||
self.assertRegex(path, r"^/weichen/Agent-System-Dev\.git/")
|
||||
self.assertEqual(auth, "token " + SUB_TOKEN)
|
||||
self.assertNotIn(SUB_TOKEN, result.stdout + result.stderr)
|
||||
|
||||
def test_rdm_composition_lock_rejects_external_missing_and_malformed_commits(self):
|
||||
for lock in ({"schema": 1, "repo": self.url + "/untrusted/receiver", "commit": "a" * 40},
|
||||
{"schema": 1, "repo": SSH + "Agent-System-Dev.git"},
|
||||
{"schema": 1, "repo": SSH + "Agent-System-Dev.git", "commit": "main"}):
|
||||
with self.subTest(lock=lock):
|
||||
(self.checkout / "composition.dev.json").write_text(json.dumps(lock))
|
||||
result = self.run_phase("Prefetch fixed Dev product composition",
|
||||
workflow=fixture.component_workflow("Agent-System-RDM"))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.requests)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Source adapters exercise real Git/Go/npm builds without starting services."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
import zipfile
|
||||
import test_composition
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(all(shutil.which(command) for command in ("git", "go", "node", "npm")), "Git/Go/Node/npm required")
|
||||
class SvcBuildCutoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = test_composition.CompositionTests(methodName="runTest")
|
||||
self.fixture.setUp()
|
||||
self.addCleanup(self.fixture.doCleanups)
|
||||
self.base, self.root, self.env = self.fixture.base, self.fixture.root, self.fixture.env
|
||||
self.env = self.env.copy()
|
||||
for key in ("COMPOSITION_ROOT", "DEV_ROOT", "COMPOSITION_SOURCE_MAP"):
|
||||
self.env.pop(key, None)
|
||||
(self.root / "scripts/workspace").mkdir(parents=True)
|
||||
shutil.copy2(SOURCE / "scripts/build-composition.py", self.root / "scripts/build-composition.py")
|
||||
shutil.copy2(SOURCE / "scripts/workspace/transport.py", self.root / "scripts/workspace/transport.py")
|
||||
(self.root / ".gitignore").write_text("bin/\nprivate/\n")
|
||||
(self.root / "Makefile").write_text("build frontend:\n\tpython3 scripts/build-composition.py --root . --target $@\n")
|
||||
self.sha = self.fixture.commit(self.root)
|
||||
self.cache = self.base / "cache"
|
||||
self.call(self.base, "git", "clone", str(self.root), str(self.cache))
|
||||
self.runtime = self.base / "runtime"
|
||||
self.runtime.mkdir()
|
||||
wrappers = self.base / "tools"
|
||||
wrappers.mkdir()
|
||||
git = shutil.which("git")
|
||||
(wrappers / "git").write_text('#!/usr/bin/env bash\nfor arg in "$@"; do if [ "$arg" = submodule ]; then echo "recursive submodule acquisition forbidden" >&2; exit 63; fi; done\nexec ' + git + ' "$@"\n')
|
||||
(wrappers / "git").chmod(0o755)
|
||||
self.env["PATH"] = str(wrappers) + os.pathsep + self.env["PATH"]
|
||||
|
||||
def call(self, cwd, *args, check=True, env=None):
|
||||
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def adapter(self, commands, check=True, env=None):
|
||||
code = 'source "$1"\nROOT="$2"; SRC_DIR="$3"; RUNTIME="$4"; ENV_NAME=fixture\n' + commands
|
||||
return self.call(self.base, "bash", "-c", code, "cutover", str(SOURCE / "scripts/svc.sh"), str(self.root), str(self.cache), str(self.runtime), check=check, env=env)
|
||||
|
||||
def test_versioned_dev_materialization_uses_composer_without_submodules(self):
|
||||
self.adapter('materialize_dev_commit "' + self.sha + '" dev build')
|
||||
binary = self.runtime / ".worktrees/dev/bin/agent-system"
|
||||
self.assertEqual(self.call(self.base, str(binary)).stdout, "immutable-page")
|
||||
resolved = json.loads((binary.parent / "resolved-manifest.json").read_text())
|
||||
self.assertEqual(resolved["root"]["commit"], self.sha)
|
||||
|
||||
def test_versioned_static_adapter_resolves_published_frontend_artifact(self):
|
||||
self.adapter('wt=$(materialize_dev_commit "' + self.sha + '" frontend frontend)\nsvc_frontend_dist "$wt"')
|
||||
dist = self.runtime / ".worktrees/frontend/bin/frontend-dist"
|
||||
self.assertEqual((dist / "index.html").read_text(), "immutable-page")
|
||||
|
||||
def test_mismatched_existing_cache_is_rejected_before_candidate_build(self):
|
||||
path = self.runtime / ".worktrees/reused"
|
||||
self.call(self.base, "git", "-C", str(self.cache), "worktree", "add", "--detach", str(path), self.sha)
|
||||
(self.root / "new-marker").write_text("new candidate\n")
|
||||
new_sha = self.fixture.commit(self.root)
|
||||
self.call(self.base, "git", "-C", str(self.cache), "fetch", "origin")
|
||||
result = self.adapter('materialize_dev_commit "' + new_sha + '" reused build', check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.call(path, "git", "rev-parse", "HEAD").stdout.strip(), self.sha)
|
||||
self.assertFalse((path / "bin").exists())
|
||||
|
||||
def test_cache_recycling_preserves_ignored_unique_files(self):
|
||||
path = self.runtime / ".worktrees/unique"
|
||||
self.call(self.base, "git", "-C", str(self.cache), "worktree", "add", "--detach", str(path), self.sha)
|
||||
(path / "private").mkdir()
|
||||
(path / "private/unique").write_text("owner data\n")
|
||||
self.adapter('recycle_worktree unique')
|
||||
self.assertTrue((path / "private/unique").is_file())
|
||||
self.assertEqual((path / "private/unique").read_text(), "owner data\n")
|
||||
|
||||
def test_versioned_rdm_adapter_passes_fixed_dev_root_to_product_wrapper(self):
|
||||
rdm = self.base / "rdm"
|
||||
rdm.mkdir()
|
||||
self.call(rdm, "git", "init", "-q", "-b", "main")
|
||||
self.call(rdm, "git", "config", "user.email", "fixture@example.invalid")
|
||||
self.call(rdm, "git", "config", "user.name", "Fixture")
|
||||
version = "v0.0.0-" + self.call(self.fixture.backend, "git", "show", "-s", "--format=%cd", "--date=format-local:%Y%m%d%H%M%S", self.fixture.backend_sha).stdout.strip() + "-" + self.fixture.backend_sha[:12]
|
||||
proxy = self.base / "proxy/example.com/backend/@v"
|
||||
proxy.mkdir(parents=True)
|
||||
(proxy / (version + ".mod")).write_text((self.fixture.backend / "go.mod").read_text())
|
||||
(proxy / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-13T00:00:00Z"}))
|
||||
(proxy / "list").write_text(version + "\n")
|
||||
with zipfile.ZipFile(proxy / (version + ".zip"), "w") as archive:
|
||||
for relative in self.call(self.fixture.backend, "git", "ls-files").stdout.splitlines():
|
||||
archive.write(self.fixture.backend / relative, "example.com/backend@" + version + "/" + relative)
|
||||
(rdm / "go.mod").write_text("module git-ssh.u1680832.nyat.app/weichen/Agent-System-RDM.git\n\ngo 1.23\nrequire example.com/backend " + version + "\n")
|
||||
(rdm / "main.go").write_text('package main\nimport("fmt";"example.com/backend/web")\nfunc main(){fmt.Print(web.Page())}\n')
|
||||
(rdm / "Makefile").write_text('build:\n\t@test -n "$(COMPOSITION_ROOT)"\n\tpython3 "$(COMPOSITION_ROOT)/scripts/build-composition.py" --root "$(COMPOSITION_ROOT)" --target build --rdm-source . --output bin\n\tcp bin/agent-system-rdm-product bin/agent-system-rdm\n')
|
||||
env = self.env | {"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")}
|
||||
self.call(rdm, "go", "mod", "download", "all", env=env)
|
||||
rdm_sha = self.fixture.commit(rdm)
|
||||
self.adapter('RDM_SRC_DIR="' + str(rdm) + '"\nmaterialize_rdm_commit "' + rdm_sha + '" rdm', env=env)
|
||||
binary = self.runtime / ".worktrees/rdm-rdm/bin/agent-system-rdm"
|
||||
self.assertEqual(self.call(self.base, str(binary)).stdout, "immutable-page")
|
||||
self.assertEqual(json.loads((binary.parent / "resolved-manifest.json").read_text())["root"]["commit"], self.sha)
|
||||
|
||||
def test_e2e_build_only_uses_versioned_sources_without_runtime_or_api_calls(self):
|
||||
self.test_versioned_rdm_adapter_passes_fixed_dev_root_to_product_wrapper()
|
||||
rdm = self.base / "rdm"
|
||||
rdm_sha = self.call(rdm, "git", "rev-parse", "HEAD").stdout.strip()
|
||||
self.call(rdm, "git", "update-ref", "refs/remotes/origin/main", rdm_sha)
|
||||
shutil.copytree(SOURCE / "scripts/workspace", self.root / "scripts/workspace", dirs_exist_ok=True)
|
||||
for name in ("workspace.py", "component-source.py", "svc.sh", "e2e.sh"):
|
||||
shutil.copy2(SOURCE / "scripts" / name, self.root / "scripts" / name)
|
||||
(self.root / ".gitignore").write_text("bin/\nprivate/\n.workspace/\ngo.work\n")
|
||||
self.fixture.commit(self.root)
|
||||
curl = self.base / "tools/curl"
|
||||
curl.write_text('#!/usr/bin/env bash\necho "runtime API must not execute in build-only mode" >&2\nexit 71\n')
|
||||
curl.chmod(0o755)
|
||||
output = self.base / "e2e"
|
||||
env = self.env | {"RDM_REPO": str(rdm), "E2E_OUT": str(output), "GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "module-cache")}
|
||||
unrelated = subprocess.Popen(["sleep", "60"])
|
||||
self.addCleanup(lambda: (unrelated.terminate(), unrelated.wait()) if unrelated.poll() is None else None)
|
||||
env.update(API_PID=str(unrelated.pid), UI_PID=str(unrelated.pid))
|
||||
result = self.call(self.root, "bash", "scripts/e2e.sh", "--build-only", env=env)
|
||||
self.assertIsNone(unrelated.poll(), "build-only must not kill inherited process identities")
|
||||
self.assertIn("runtime/browser/model acceptance NOT_RUN", result.stdout)
|
||||
self.assertEqual(self.call(self.base, str(output / "agent-system-rdm")).stdout, "immutable-page")
|
||||
self.assertEqual((self.root / "bin/frontend-dist/index.html").read_text(), "immutable-page")
|
||||
self.assertFalse((output / "data").exists())
|
||||
self.assertFalse((output / "cookies.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -236,5 +236,86 @@ class TaskRecoveryTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
|
||||
class WorkspaceRecoveryTests(unittest.TestCase):
|
||||
"""New-format recovery keeps its branch and fixes startup rule availability."""
|
||||
def setUp(self):
|
||||
import test_workspace_cutover as cutover
|
||||
self.fixture = cutover.WorkspaceCutoverTests(methodName="runTest")
|
||||
self.fixture.setUp()
|
||||
self.addCleanup(self.fixture.doCleanups)
|
||||
f = self.fixture
|
||||
f.adopt()
|
||||
self.wt = f.worktree()
|
||||
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
self.card = self.wt / ".task/feature-old.md"
|
||||
self.card.parent.mkdir()
|
||||
self.card.write_text("# Recovery\n任务主记录:fixture;状态:进行中;owner:Cutover Test\n"
|
||||
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
|
||||
f"- 工作区:{self.wt}(worktree);分支:feature/old\n")
|
||||
f.git(self.wt, "add", ".task")
|
||||
f.git(self.wt, "commit", "-m", "Register current task")
|
||||
self.env = f.env | {"TASK_WORK_MODE": "local"}
|
||||
|
||||
def precheck(self):
|
||||
return self.fixture.task(self.wt, "precheck", env=self.env, check=False)
|
||||
|
||||
def test_local_recovery_verifies_fixed_rules_without_sync_or_application_materialization(self):
|
||||
f = self.fixture
|
||||
head = f.git(self.wt, "rev-parse", "HEAD").stdout
|
||||
(f.repo / "new-main").write_text("main advanced\n")
|
||||
f.git(f.repo, "add", "new-main")
|
||||
f.git(f.repo, "commit", "-m", "Advance main")
|
||||
f.git(f.repo, "push", "origin", "main")
|
||||
result = self.precheck()
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("[verdict] LOCAL_READY", result.stdout)
|
||||
self.assertIn("[rules]", result.stdout)
|
||||
self.assertNotIn("[verdict] READY", result.stdout)
|
||||
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
|
||||
self.assertFalse((self.wt / ".workspace").exists())
|
||||
|
||||
def test_local_recovery_refuses_missing_fixed_rules_before_readiness(self):
|
||||
f = self.fixture
|
||||
lock = self.wt / "components.lock.json"
|
||||
record = json.loads(lock.read_text())
|
||||
record["components"]["Rules"]["commit"] = "0" * 40
|
||||
lock.write_text(json.dumps(record))
|
||||
f.git(self.wt, "commit", "-am", "Pin unavailable rules")
|
||||
before = self.card.read_bytes()
|
||||
result = self.precheck()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[verdict] LOCAL_READY", result.stdout)
|
||||
self.assertEqual(self.card.read_bytes(), before)
|
||||
self.assertFalse((self.wt / ".workspace").exists())
|
||||
|
||||
def test_resume_rechecks_fixed_rules_before_importing_receipt(self):
|
||||
f = self.fixture
|
||||
receipt = f.base / "resume.json"
|
||||
f.call(self.wt, "bash", "scripts/evidence.sh", env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"})
|
||||
# The cache is not an application overlay; a changed rule source cannot
|
||||
# turn a formerly valid local precheck into current startup authority.
|
||||
rules = list((f.base / "cache").rglob("core/baseline.md"))
|
||||
self.assertTrue(rules, "fixed startup rules were never prepared")
|
||||
rules[0].write_text("owner changed rules\n")
|
||||
before = self.card.read_bytes()
|
||||
result = f.task(self.wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)}, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[resumed]", result.stdout)
|
||||
self.assertEqual(self.card.read_bytes(), before)
|
||||
self.assertEqual(rules[0].read_text(), "owner changed rules\n")
|
||||
|
||||
def test_local_resume_preserves_registered_edit_branch_and_unsaved_component(self):
|
||||
f = self.fixture
|
||||
f.workspace(self.wt, "edit", "--name", "App", "--branch", "feature/component-owner")
|
||||
child = self.wt / ".workspace/App"
|
||||
(child / "content").write_text("preserve author edit\n")
|
||||
receipt = f.base / "resume-edit.json"
|
||||
f.call(self.wt, "bash", "scripts/evidence.sh", env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"})
|
||||
result = f.task(self.wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
self.assertIn("[resumed]", result.stdout)
|
||||
self.assertEqual((child / "content").read_text(), "preserve author edit\n")
|
||||
self.assertEqual(f.git(child, "branch", "--show-current").stdout.strip(), "feature/component-owner")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/workspace/transport.py'
|
||||
|
||||
class TransportTests(unittest.TestCase):
|
||||
def load(self):
|
||||
self.assertTrue(SCRIPT.is_file(), 'process-scoped module transport is required')
|
||||
spec = importlib.util.spec_from_file_location('workspace_transport', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_remote_revision_download_without_global_git_change(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
source = root / 'provider'
|
||||
source.mkdir()
|
||||
subprocess.run(['git', 'init', '-q', str(source)], check=True)
|
||||
subprocess.run(['git', '-C', str(source), 'config', 'user.name', 'Fixture'], check=True)
|
||||
subprocess.run(['git', '-C', str(source), 'config', 'user.email', 'fixture@example.test'], check=True)
|
||||
(source / 'go.mod').write_text('module example.test/provider.git\n\ngo 1.27\n')
|
||||
(source / 'answer.go').write_text('package provider\nfunc Answer() int { return 42 }\n')
|
||||
subprocess.run(['git', '-C', str(source), 'add', 'go.mod', 'answer.go'], check=True)
|
||||
subprocess.run(['git', '-C', str(source), 'commit', '-qm', 'Add provider'], check=True)
|
||||
sha = subprocess.check_output(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True).strip()
|
||||
(root / 'components.json').write_text(json.dumps({'schema': 1, 'components': [], 'transport': {
|
||||
'go_private': ['example.test'], 'git_rewrites': [{'from': 'https://example.test/provider', 'to': source.as_uri()}]}}))
|
||||
consumer = root / 'consumer'
|
||||
consumer.mkdir()
|
||||
(consumer / 'go.mod').write_text('module example.test/consumer\n\ngo 1.27\n')
|
||||
env = transport.environment(root)
|
||||
env['GOMODCACHE'] = str(root / 'module-cache')
|
||||
before = subprocess.run(['git', 'config', '--global', '--list'], capture_output=True).stdout
|
||||
result = subprocess.run(['go', 'get', 'example.test/provider.git@' + sha], cwd=consumer, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
(consumer / 'consumer_test.go').write_text('package consumer\nimport("testing"; "example.test/provider.git")\nfunc TestValue(t *testing.T) { if provider.Answer()!=42 { t.Fatal("wrong provider") } }\n')
|
||||
result = subprocess.run(['go', 'test', '-mod=readonly', './...'], cwd=consumer, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
after = subprocess.run(['git', 'config', '--global', '--list'], capture_output=True).stdout
|
||||
self.assertEqual(before, after)
|
||||
self.assertNotIn('replace ', (consumer / 'go.mod').read_text())
|
||||
|
||||
def test_credentials_in_transport_url_are_rejected(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
|
||||
{'from': 'https://example.test/', 'to': 'https://secret-token@example.test/'}]}}))
|
||||
with self.assertRaises(ValueError):
|
||||
transport.environment(root)
|
||||
|
||||
def test_verified_local_mirror_mapping_survives_without_authentication(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
mirror = root / 'mirrors'
|
||||
mirror.mkdir()
|
||||
target = mirror / 'Provider.git'
|
||||
target.mkdir()
|
||||
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
|
||||
{'from':'https://example.test/', 'to':'ssh://git@example.test:31411/'}]}}))
|
||||
inherited = {'CI_DEPENDENCY_MIRRORS':str(mirror), 'GIT_CONFIG_COUNT':'3',
|
||||
'GIT_CONFIG_KEY_0':f'url.{target.as_uri()}.insteadOf','GIT_CONFIG_VALUE_0':'https://example.test/provider',
|
||||
'GIT_CONFIG_KEY_1':'protocol.file.allow','GIT_CONFIG_VALUE_1':'always',
|
||||
'GIT_CONFIG_KEY_2':'http.https://example.test/.extraheader','GIT_CONFIG_VALUE_2':'AUTHORIZATION: token fake-private'}
|
||||
with patch.dict(os.environ,inherited):
|
||||
env=transport.environment(root)
|
||||
pairs=[(env[f'GIT_CONFIG_KEY_{i}'],env[f'GIT_CONFIG_VALUE_{i}']) for i in range(int(env['GIT_CONFIG_COUNT']))]
|
||||
self.assertIn((f'url.{target.as_uri()}.insteadOf','https://example.test/provider'),pairs)
|
||||
self.assertIn(('protocol.file.allow','always'),pairs)
|
||||
self.assertFalse(any('extraheader' in key or 'fake-private' in value for key,value in pairs))
|
||||
|
||||
def test_injected_http_server_maps_to_verified_local_mirror_without_authentication(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
mirrors = root / 'mirrors'
|
||||
mirrors.mkdir()
|
||||
target = mirrors / 'Provider.git'
|
||||
subprocess.run(['git', 'init', '--bare', '-q', str(target)], check=True)
|
||||
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
|
||||
{'from': 'https://code.example/weichen/Provider', 'to': 'ssh://git@code.example:31411/weichen/Provider.git'}]}}))
|
||||
origin = 'http://127.0.0.1:3000/weichen/Provider.git'
|
||||
inherited = {'CI_DEPENDENCY_MIRRORS': str(mirrors), 'GITHUB_SERVER_URL': 'http://127.0.0.1:3000',
|
||||
'GITHUB_REPOSITORY': 'weichen/Dev', 'GIT_CONFIG_COUNT': '3',
|
||||
'GIT_CONFIG_KEY_0': f'url.{target.as_uri()}.insteadOf', 'GIT_CONFIG_VALUE_0': origin,
|
||||
'GIT_CONFIG_KEY_1': 'protocol.file.allow', 'GIT_CONFIG_VALUE_1': 'always',
|
||||
'GIT_CONFIG_KEY_2': 'http.http://127.0.0.1:3000/.extraheader',
|
||||
'GIT_CONFIG_VALUE_2': 'AUTHORIZATION: token synthetic-private'}
|
||||
before = subprocess.run(['git', 'config', '--global', '--list'], capture_output=True).stdout
|
||||
with patch.dict(os.environ, inherited):
|
||||
env = transport.environment(root)
|
||||
pairs = [(env[f'GIT_CONFIG_KEY_{i}'], env[f'GIT_CONFIG_VALUE_{i}']) for i in range(int(env['GIT_CONFIG_COUNT']))]
|
||||
self.assertIn((f'url.{target.as_uri()}.insteadOf', origin), pairs)
|
||||
self.assertFalse(any('extraheader' in key or 'synthetic-private' in value for key, value in pairs))
|
||||
cloned = subprocess.run(['git', 'clone', '--no-checkout', origin, str(root / 'cloned')], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(cloned.returncode, 0, cloned.stdout + cloned.stderr)
|
||||
after = subprocess.run(['git', 'config', '--global', '--list'], capture_output=True).stdout
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_inherited_http_source_must_match_injected_server_owner_and_repository(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
mirrors = root / 'mirrors'
|
||||
mirrors.mkdir()
|
||||
target = mirrors / 'Provider.git'
|
||||
target.mkdir()
|
||||
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
|
||||
{'from': 'https://code.example/Provider', 'to': 'ssh://git@code.example:31411/Provider.git'}]}}))
|
||||
inherited = {'CI_DEPENDENCY_MIRRORS': str(mirrors), 'GITHUB_SERVER_URL': 'http://127.0.0.1:3000',
|
||||
'GITHUB_REPOSITORY': 'weichen/Dev', 'GIT_CONFIG_COUNT': '1',
|
||||
'GIT_CONFIG_KEY_0': f'url.{target.as_uri()}.insteadOf'}
|
||||
for source in ['http://foreign.test/weichen/Provider.git', 'http://127.0.0.1:3000/other/Provider.git',
|
||||
'http://127.0.0.1:3000/weichen/Other.git', 'http://user:password@127.0.0.1:3000/weichen/Provider.git']:
|
||||
with self.subTest(source=source), patch.dict(os.environ, inherited | {'GIT_CONFIG_VALUE_0': source}):
|
||||
with self.assertRaises(ValueError):
|
||||
transport.environment(root)
|
||||
|
||||
def test_ordinary_config_cannot_enable_http_transport(self):
|
||||
transport = self.load()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
|
||||
{'from': 'http://127.0.0.1:3000/weichen/Provider.git', 'to': 'file:///tmp/mirrors/Provider.git'}]}}))
|
||||
with self.assertRaises(ValueError):
|
||||
transport.environment(root)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,823 @@
|
||||
"""Workspace behavior against disposable real Git repositories and Go modules."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WorkspaceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="workspace-test-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.root = self.base / "project"
|
||||
self.root.mkdir()
|
||||
self.env = os.environ.copy()
|
||||
for key in list(self.env):
|
||||
if key.startswith("GIT_CONFIG_") or key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GOWORK"):
|
||||
self.env.pop(key, None)
|
||||
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "Workspace Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "Workspace Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
|
||||
"GOTOOLCHAIN": "local", "GOPROXY": "off", "GOSUMDB": "off"})
|
||||
self.git(self.root, "init", "--initial-branch=main")
|
||||
(self.root / ".gitignore").write_text(".workspace/\ngo.work\ngo.work.sum\n")
|
||||
self.git(self.root, "add", ".gitignore")
|
||||
self.git(self.root, "commit", "-m", "Create project")
|
||||
self.remote, self.author, self.first = self.component("App")
|
||||
self.manifest = {"schema": 1, "components": [self.entry("App", self.remote)]}
|
||||
self.lock = {"schema": 1, "components": {"App": {"commit": self.first}}}
|
||||
self.save_manifest()
|
||||
|
||||
def command(self, cwd, *args, check=True, env=None, separate_stderr=False):
|
||||
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE if separate_stderr else subprocess.STDOUT, timeout=60)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + (result.stderr or ""))
|
||||
return result
|
||||
|
||||
def git(self, cwd, *args, **kwargs):
|
||||
return self.command(cwd, "git", *args, **kwargs)
|
||||
|
||||
def cli(self, *args, check=True, env=None):
|
||||
return self.command(self.root, sys.executable, str(SOURCE / "scripts/workspace.py"),
|
||||
"--root", str(self.root), *args, check=check, env=env)
|
||||
|
||||
def component(self, name, files=None):
|
||||
remote = self.base / (name + ".git")
|
||||
author = self.base / (name + "-author")
|
||||
self.git(self.base, "init", "--bare", "--initial-branch=main", str(remote))
|
||||
self.git(remote, "config", "gc.auto", "0")
|
||||
self.git(remote, "config", "receive.autogc", "false")
|
||||
self.git(self.base, "init", "--initial-branch=main", str(author))
|
||||
for name, value in (files or {"content": "first\n", ".gitignore": "private/\n"}).items():
|
||||
path = author / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(value)
|
||||
self.git(author, "add", ".")
|
||||
self.git(author, "commit", "-m", "First component")
|
||||
self.git(author, "remote", "add", "origin", str(remote))
|
||||
self.git(author, "push", "-u", "origin", "main")
|
||||
return remote, author, self.git(author, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
def entry(self, name, remote, role="application", modules=None):
|
||||
return {"id": name, "repo": str(remote), "path": ".workspace/" + name,
|
||||
"legacy_path": "modules/" + name, "role": role,
|
||||
"modules": modules or [], "target": "main"}
|
||||
|
||||
def save_manifest(self):
|
||||
(self.root / "components.json").write_text(json.dumps(self.manifest))
|
||||
(self.root / "components.lock.json").write_text(json.dumps(self.lock))
|
||||
|
||||
def state(self):
|
||||
return json.loads((self.root / ".workspace/state.json").read_text())
|
||||
|
||||
def test_prepare_pins_fixed_commit_and_reuses_without_advancing(self):
|
||||
(self.author / "content").write_text("latest\n")
|
||||
self.git(self.author, "commit", "-am", "Advance remote")
|
||||
self.git(self.author, "push", "origin", "main")
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
self.assertEqual((clone / "content").read_text(), "first\n")
|
||||
self.cli("prepare")
|
||||
self.assertEqual(self.state()["components"]["App"]["baseline"], self.first)
|
||||
self.assertTrue((self.root / ".workspace/resolved.json").is_file())
|
||||
|
||||
def test_edit_registers_branch_and_prepare_preserves_work(self):
|
||||
self.cli("edit", "--name", "App", "--branch", "feature/change")
|
||||
clone = self.root / ".workspace/App"
|
||||
(clone / "content").write_text("my change\n")
|
||||
self.git(clone, "commit", "-am", "Local work")
|
||||
self.cli("prepare")
|
||||
self.assertEqual(self.git(clone, "branch", "--show-current").stdout.strip(), "feature/change")
|
||||
self.assertEqual((clone / "content").read_text(), "my change\n")
|
||||
self.cli("unlink", "--name", "App")
|
||||
self.assertTrue(clone.is_dir())
|
||||
|
||||
def test_rejects_unknown_component_and_bad_branch_without_source_creation(self):
|
||||
result = self.cli("edit", "--name", "missing", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("unknown component", result.stdout.lower())
|
||||
result = self.cli("edit", "--name", "App", "--branch", "feature/../../escape", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.root / ".workspace/App").exists())
|
||||
|
||||
def test_rejects_paths_and_symlink_escape(self):
|
||||
for path in ("../escape", "/tmp/escape", ".workspace/../escape"):
|
||||
with self.subTest(path=path):
|
||||
self.manifest["components"][0]["path"] = path
|
||||
self.save_manifest()
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.base / "escape").exists())
|
||||
self.manifest["components"][0]["path"] = ".workspace/App"
|
||||
self.save_manifest()
|
||||
(self.root / ".workspace").symlink_to(self.author, target_is_directory=True)
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.author / "App").exists())
|
||||
|
||||
def test_rejects_mismatched_origin_dirty_and_git_lock_without_changes(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.git(clone, "remote", "set-url", "origin", str(self.base / "wrong.git"))
|
||||
self.assertNotEqual(self.cli("prepare", check=False).returncode, 0)
|
||||
self.git(clone, "remote", "set-url", "origin", str(self.remote))
|
||||
(clone / "unique").write_text("unsaved\n")
|
||||
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
|
||||
self.assertEqual((clone / "unique").read_text(), "unsaved\n")
|
||||
(clone / "unique").unlink()
|
||||
lock = clone / ".git/index.lock"
|
||||
lock.write_text("owned by another operation\n")
|
||||
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
|
||||
self.assertEqual(lock.read_text(), "owned by another operation\n")
|
||||
|
||||
def test_ignored_unique_files_and_shared_worktrees_block_branch_changes(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
(clone / "private").mkdir()
|
||||
(clone / "private/key").write_text("unique\n")
|
||||
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
|
||||
shutil.rmtree(clone / "private")
|
||||
external = self.base / "other-owner"
|
||||
self.git(clone, "worktree", "add", "-b", "feature/other", str(external))
|
||||
result = self.cli("edit", "--name", "App", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(external, "branch", "--show-current").stdout.strip(), "feature/other")
|
||||
|
||||
def test_workspace_operation_lock_and_user_go_work_are_preserved(self):
|
||||
area = self.root / ".workspace"
|
||||
area.mkdir()
|
||||
lock = area / ".operation.lock"
|
||||
lock.write_text("other writer\n")
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(lock.read_text(), "other writer\n")
|
||||
lock.unlink()
|
||||
(self.root / "go.work").write_text("go 1.27\n// manual\n")
|
||||
result = self.cli("edit", "--name", "App", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.root / "go.work").read_text(), "go 1.27\n// manual\n")
|
||||
|
||||
def test_partial_initialization_is_retained_and_retry_is_safe(self):
|
||||
self.lock["components"]["App"]["commit"] = "a" * 40
|
||||
self.save_manifest()
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
clone = self.root / ".workspace/App"
|
||||
self.assertTrue((clone / ".git").exists(), result.stdout)
|
||||
self.assertFalse((self.root / ".workspace/.operation.lock").exists())
|
||||
self.lock["components"]["App"]["commit"] = self.first
|
||||
self.save_manifest()
|
||||
self.cli("prepare")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
|
||||
def legacy(self):
|
||||
self.git(self.root, "submodule", "add", str(self.remote), "modules/App")
|
||||
self.git(self.root, "commit", "-am", "Pin legacy component")
|
||||
return self.root / "modules/App"
|
||||
|
||||
def fingerprint(self, path):
|
||||
files = {}
|
||||
for file in path.rglob("*"):
|
||||
if ".git" not in file.relative_to(path).parts and file.is_file():
|
||||
files[str(file.relative_to(path))] = hashlib.sha256(file.read_bytes()).hexdigest()
|
||||
return (self.git(path, "show-ref").stdout, self.git(path, "status", "--porcelain=v1").stdout, files)
|
||||
|
||||
def test_transition_report_is_readonly_apply_preserves_unique_commits_refs_and_files(self):
|
||||
legacy = self.legacy()
|
||||
self.git(legacy, "checkout", "-b", "feature/legacy")
|
||||
(legacy / "content").write_text("unpublished commit\n")
|
||||
self.git(legacy, "commit", "-am", "Unpublished")
|
||||
unique_head = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(legacy, "branch", "feature/other-ref")
|
||||
(legacy / "content").write_text("uncommitted\n")
|
||||
(legacy / "unique").write_text("untracked\n")
|
||||
(legacy / "private").mkdir()
|
||||
(legacy / "private/data").write_text("ignored\n")
|
||||
before = self.fingerprint(legacy)
|
||||
report = json.loads(self.cli("transition", "--name", "App").stdout)
|
||||
self.assertEqual(report["components"]["App"]["legacy"]["head"], unique_head)
|
||||
self.assertFalse((self.root / ".workspace").exists())
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), unique_head)
|
||||
self.assertEqual(self.git(clone, "remote", "get-url", "origin").stdout.strip(), str(self.remote))
|
||||
self.assertEqual((clone / "content").read_text(), "uncommitted\n")
|
||||
self.assertEqual((clone / "unique").read_text(), "untracked\n")
|
||||
self.assertEqual((clone / "private/data").read_text(), "ignored\n")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "refs/migration/heads/feature/other-ref").stdout.strip(), unique_head)
|
||||
self.assertEqual(self.fingerprint(legacy), before)
|
||||
self.assertFalse((clone / ".git/objects/info/alternates").exists())
|
||||
|
||||
def test_transition_rejects_external_symlinks_preserving_both_locations(self):
|
||||
legacy = self.legacy()
|
||||
outside = self.base / "outside"
|
||||
outside.write_text("owner data\n")
|
||||
(legacy / "escape").symlink_to(outside)
|
||||
before = self.fingerprint(legacy)
|
||||
result = self.cli("transition", "--name", "App", "--apply", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(outside.read_text(), "owner data\n")
|
||||
self.assertEqual(self.fingerprint(legacy), before)
|
||||
self.assertFalse((self.root / ".workspace/App").exists())
|
||||
|
||||
def test_library_legacy_baseline_uses_gitlink_not_current_head(self):
|
||||
legacy = self.legacy()
|
||||
(legacy / "content").write_text("local newer commit\n")
|
||||
self.git(legacy, "commit", "-am", "Local advance")
|
||||
self.manifest["components"][0]["role"] = "go-library"
|
||||
self.lock["components"] = {}
|
||||
self.save_manifest()
|
||||
self.cli("edit", "--name", "App")
|
||||
self.assertEqual(self.git(self.root / ".workspace/App", "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
self.assertEqual((legacy / "content").read_text(), "local newer commit\n")
|
||||
|
||||
def test_status_reports_dirty_and_verify_rejects_it(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
(clone / "content").write_text("unsaved\n")
|
||||
result = json.loads(self.cli("status").stdout)
|
||||
self.assertTrue(result["components"]["App"]["actual"]["dirty"])
|
||||
self.assertNotEqual(self.cli("verify", check=False).returncode, 0)
|
||||
self.assertEqual((clone / "content").read_text(), "unsaved\n")
|
||||
|
||||
def test_transition_preserves_staged_blob_internal_symlink_and_reflog_commit(self):
|
||||
legacy = self.legacy()
|
||||
(legacy / "content").write_text("orphan commit\n")
|
||||
self.git(legacy, "commit", "-am", "Reflog-only work")
|
||||
orphan = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(legacy, "checkout", "--detach", self.first)
|
||||
(legacy / "content").write_text("staged value\n")
|
||||
self.git(legacy, "add", "content")
|
||||
(legacy / "content").write_text("working value\n")
|
||||
(legacy / "link").symlink_to("content")
|
||||
before = self.fingerprint(legacy)
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.assertEqual(self.git(clone, "show", ":content").stdout, "staged value\n")
|
||||
self.assertEqual((clone / "content").read_text(), "working value\n")
|
||||
self.assertTrue((clone / "link").is_symlink())
|
||||
self.assertEqual(os.readlink(clone / "link"), "content")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "refs/migration/reflog/" + orphan).stdout.strip(), orphan)
|
||||
self.assertEqual(self.fingerprint(legacy), before)
|
||||
|
||||
def test_transition_refuses_initialized_nested_repository(self):
|
||||
legacy = self.legacy()
|
||||
nested = legacy / "child"
|
||||
nested.mkdir()
|
||||
self.git(nested, "init", "--initial-branch=main")
|
||||
(nested / "unique").write_text("owned by nested repository\n")
|
||||
result = self.cli("transition", "--name", "App", "--apply", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("nested repository", result.stdout)
|
||||
self.assertFalse((self.root / ".workspace/App").exists())
|
||||
self.assertEqual((nested / "unique").read_text(), "owned by nested repository\n")
|
||||
|
||||
def nested_legacy(self):
|
||||
legacy = self.legacy()
|
||||
errors_remote, _, _ = self.component("Errors", {
|
||||
"go.mod": "module example.invalid/errors\n\ngo 1.27\n",
|
||||
"value.go": 'package errors\nfunc Value() string { return "errors" }\n',
|
||||
".gitignore": "private/\n"})
|
||||
logging_remote, _, _ = self.component("Logging", {
|
||||
"go.mod": "module example.invalid/logging\n\ngo 1.27\n",
|
||||
"value.go": 'package logging\nfunc Value() string { return "logging" }\n'})
|
||||
self.git(legacy, "submodule", "add", str(errors_remote), "deps/errors")
|
||||
self.git(legacy, "submodule", "add", str(logging_remote), "deps/logging")
|
||||
(legacy / "go.mod").write_text("module example.invalid/native\n\ngo 1.27\n\nrequire (\n example.invalid/errors v0.0.0\n example.invalid/logging v0.0.0\n)\nreplace example.invalid/errors => ./deps/errors\nreplace example.invalid/logging => ./deps/logging\n")
|
||||
(legacy / "main.go").write_text('package main\nimport ("fmt"; errors "example.invalid/errors"; logging "example.invalid/logging")\nfunc main() { fmt.Print(errors.Value()+":"+logging.Value()) }\n')
|
||||
self.git(legacy, "add", ".")
|
||||
self.git(legacy, "commit", "-m", "Add registered nested dependencies")
|
||||
self.manifest["components"][0]["modules"] = [{"path": "example.invalid/native", "directory": "."}]
|
||||
self.manifest["components"] += [
|
||||
self.entry("Errors", errors_remote, "go-library", [{"path": "example.invalid/errors", "directory": "."}]),
|
||||
self.entry("Logging", logging_remote, "go-library", [{"path": "example.invalid/logging", "directory": "."}])]
|
||||
self.save_manifest()
|
||||
return legacy, legacy / "deps/errors", legacy / "deps/logging"
|
||||
|
||||
def test_transition_known_nested_repositories_preserves_external_worktree_and_runs_go(self):
|
||||
legacy, errors, logging = self.nested_legacy()
|
||||
self.git(errors, "checkout", "-b", "feature/nested")
|
||||
(errors / "value.go").write_text('package errors\nfunc Value() string { return "committed" }\n')
|
||||
self.git(errors, "commit", "-am", "Unpushed nested commit")
|
||||
errors_head = self.git(errors, "rev-parse", "HEAD").stdout.strip()
|
||||
(errors / "value.go").write_text('package errors\nfunc Value() string { return "staged" }\n')
|
||||
self.git(errors, "add", "value.go")
|
||||
(errors / "value.go").write_text('package errors\nfunc Value() string { return "working" }\n')
|
||||
(errors / "private").mkdir()
|
||||
(errors / "private/unique").write_text("nested ignored data\n")
|
||||
external = self.base / "nested-dependent-worktree"
|
||||
self.git(errors, "worktree", "add", "-b", "feature/external", str(external))
|
||||
before = {str(path): self.fingerprint(path) for path in (legacy, errors, logging, external)}
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/Errors"
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), errors_head)
|
||||
self.assertIn('return "staged"', self.git(clone, "show", ":value.go").stdout)
|
||||
self.assertEqual((clone / "private/unique").read_text(), "nested ignored data\n")
|
||||
self.assertTrue((clone / ".git").is_dir())
|
||||
self.assertFalse((clone / ".git/objects/info/alternates").exists())
|
||||
self.assertFalse((self.root / ".workspace/App/deps/errors/.git").exists())
|
||||
self.assertEqual(self.command(self.root / ".workspace/App", "go", "run", ".").stdout, "working:logging")
|
||||
for path in (legacy, errors, logging, external):
|
||||
self.assertEqual(self.fingerprint(path), before[str(path)])
|
||||
self.assertTrue(Path(self.git(external, "rev-parse", "--absolute-git-dir").stdout.strip()).exists())
|
||||
mapping = self.state()["components"]["App"]["source"]["nested_components"]
|
||||
self.assertEqual({item["id"] for item in mapping}, {"Errors", "Logging"})
|
||||
|
||||
def test_conflicting_duplicate_nested_component_stops_before_cloning(self):
|
||||
legacy, errors, logging = self.nested_legacy()
|
||||
remote = next(item["repo"] for item in self.manifest["components"] if item["id"] == "Errors")
|
||||
self.git(legacy, "submodule", "add", remote, "deps/duplicate-errors")
|
||||
self.git(legacy, "commit", "-am", "Add duplicate logical dependency")
|
||||
duplicate = legacy / "deps/duplicate-errors"
|
||||
(duplicate / "value.go").write_text("conflicting unsaved source\n")
|
||||
before = self.fingerprint(legacy)
|
||||
result = self.cli("transition", "--name", "App", "--apply", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("conflicting duplicate", result.stdout)
|
||||
self.assertIn("Errors", result.stdout)
|
||||
self.assertFalse((self.root / ".workspace/App").exists())
|
||||
self.assertFalse((self.root / ".workspace/Errors").exists())
|
||||
self.assertEqual(self.fingerprint(legacy), before)
|
||||
|
||||
def test_partial_nested_transition_keeps_originals_and_owner_recovery_state(self):
|
||||
legacy, errors, logging = self.nested_legacy()
|
||||
self.git(errors, "checkout", "-b", "feature/partial-source")
|
||||
(errors / "value.go").write_text('package errors\nfunc Value() string { return "staged" }\n')
|
||||
self.git(errors, "add", "value.go")
|
||||
(errors / "value.go").write_text('package errors\nfunc Value() string { return "working" }\n')
|
||||
(errors / "private").mkdir()
|
||||
(errors / "private/unique").write_text("ignored owner data")
|
||||
external = self.base / "external-owner"
|
||||
self.git(errors, "worktree", "add", "--detach", str(external), "HEAD")
|
||||
originals = {str(path): self.fingerprint(path) for path in (legacy, errors, logging, external)}
|
||||
metadata = {str(path): self.git_metadata(path) for path in (legacy, errors, logging)}
|
||||
sys.path.insert(0, str(SOURCE / "scripts"))
|
||||
self.addCleanup(sys.path.remove, str(SOURCE / "scripts"))
|
||||
from workspace.cli import main
|
||||
copy = shutil.copy2
|
||||
copied = []
|
||||
def interrupt(source, destination, *args, **kwargs):
|
||||
result = copy(source, destination, *args, **kwargs)
|
||||
if Path(source).is_relative_to(logging):
|
||||
copied.append(str(source))
|
||||
raise OSError("injected partial nested copy failure")
|
||||
return result
|
||||
with patch.dict(os.environ, self.env, clear=True), patch("workspace.transition.shutil.copy2", side_effect=interrupt):
|
||||
result = main(["--root", str(self.root), "transition", "--name", "App", "--apply"])
|
||||
self.assertNotEqual(result, 0)
|
||||
self.assertTrue(copied)
|
||||
state = self.state()["components"]
|
||||
self.assertEqual(state["App"]["mode"], "editable")
|
||||
# Errors has not been copied when Logging fails; its complete original
|
||||
# index/worktree/ignored data is still the owner's recovery source.
|
||||
self.assertIn("staged", self.git(errors, "show", ":value.go").stdout)
|
||||
self.assertIn("working", (errors / "value.go").read_text())
|
||||
self.assertEqual((errors / "private/unique").read_text(), "ignored owner data")
|
||||
partial = self.root / ".workspace/Logging"
|
||||
self.assertTrue((partial / ".git").is_dir())
|
||||
self.assertIn("partial nested copy", state["Logging"]["error"])
|
||||
self.assertNotEqual(self.cli("transition", "--name", "App", "--apply", check=False).returncode, 0)
|
||||
self.assertTrue(partial.exists(), "owner recovery data must not be removed on retry")
|
||||
for path in (legacy, errors, logging, external):
|
||||
self.assertEqual(self.fingerprint(path), originals[str(path)])
|
||||
for path in (legacy, errors, logging):
|
||||
self.assertEqual(self.git_metadata(path), metadata[str(path)])
|
||||
|
||||
def test_equivalent_recursive_nested_sources_share_an_independent_component(self):
|
||||
legacy, errors, logging = self.nested_legacy()
|
||||
remote = next(item["repo"] for item in self.manifest["components"] if item["id"] == "Errors")
|
||||
self.git(logging, "submodule", "add", remote, "deps/errors")
|
||||
(logging / "go.mod").write_text("module example.invalid/logging\n\ngo 1.27\n\nrequire example.invalid/errors v0.0.0\nreplace example.invalid/errors => ./deps/errors\n")
|
||||
(logging / "value.go").write_text('package logging\nimport errors "example.invalid/errors"\nfunc Value() string { return "logging:"+errors.Value() }\n')
|
||||
self.git(logging, "add", ".")
|
||||
self.git(logging, "commit", "-m", "Add recursive registered dependency")
|
||||
self.git(legacy, "add", "deps/logging")
|
||||
self.git(legacy, "commit", "-m", "Pin recursive logging candidate")
|
||||
duplicate = logging / "deps/errors"
|
||||
before = {str(path): self.fingerprint(path) for path in (legacy, errors, logging, duplicate)}
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
self.assertEqual(len(self.state()["components"]["Errors"]["legacy_snapshots"]), 2)
|
||||
self.assertEqual(self.command(self.root / ".workspace/App", "go", "run", ".").stdout, "errors:logging:errors")
|
||||
for path in (legacy, errors, logging, duplicate):
|
||||
self.assertEqual(self.fingerprint(path), before[str(path)])
|
||||
|
||||
def git_metadata(self, path):
|
||||
common = Path(self.git(path, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.strip())
|
||||
return {str(file.relative_to(common)): hashlib.sha256(file.read_bytes()).hexdigest()
|
||||
for file in common.rglob("*") if file.is_file()}
|
||||
|
||||
def preserved_source_refs(self, path):
|
||||
output = self.git(path, "for-each-ref", "--format=%(refname) %(objectname)", "refs/migration/sources/").stdout
|
||||
return dict(row.split() for row in output.splitlines())
|
||||
|
||||
def test_repeated_transition_keeps_alias_tag_versions_and_snapshot_history(self):
|
||||
legacy, errors, logging = self.nested_legacy()
|
||||
remote = next(item["repo"] for item in self.manifest["components"] if item["id"] == "Errors")
|
||||
self.git(logging, "submodule", "add", remote, "deps/errors")
|
||||
self.git(logging, "commit", "-am", "Add equivalent dependency alias")
|
||||
self.git(legacy, "add", "deps/logging")
|
||||
self.git(legacy, "commit", "-m", "Pin alias source")
|
||||
alias = logging / "deps/errors"
|
||||
self.git(alias, "tag", "-a", "alias-note", "-m", "First unique alias annotation")
|
||||
first_tag = self.git(alias, "rev-parse", "refs/tags/alias-note").stdout.strip()
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/Errors"
|
||||
saved_refs = self.preserved_source_refs(clone)
|
||||
self.assertIn(first_tag, saved_refs.values())
|
||||
saved_snapshots = self.state()["components"]["Errors"]["legacy_snapshots"]
|
||||
self.git(alias, "tag", "-f", "-a", "alias-note", "-m", "Second unique alias annotation")
|
||||
second_tag = self.git(alias, "rev-parse", "refs/tags/alias-note").stdout.strip()
|
||||
self.assertNotEqual(first_tag, second_tag)
|
||||
original = {str(path): self.git_metadata(path) for path in (legacy, errors, logging, alias)}
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
current_refs = self.preserved_source_refs(clone)
|
||||
self.assertEqual({name: current_refs.get(name) for name in saved_refs}, saved_refs,
|
||||
"a new source snapshot must not overwrite any previous preserved reference")
|
||||
self.assertIn(second_tag, current_refs.values())
|
||||
history = self.state()["components"]["Errors"]["legacy_snapshots"]
|
||||
for snapshot in saved_snapshots:
|
||||
self.assertIn(snapshot, history, "prior captured metadata must remain available")
|
||||
# This object was present only through the secondary alias's annotated tag.
|
||||
# Disposable GC proves it remains reachable after the source tag moves.
|
||||
self.git(clone, "reflog", "expire", "--expire=now", "--all")
|
||||
self.git(clone, "gc", "--prune=now")
|
||||
self.assertEqual(self.git(clone, "cat-file", "-t", first_tag).stdout.strip(), "tag")
|
||||
self.assertEqual(self.git(clone, "cat-file", "-t", second_tag).stdout.strip(), "tag")
|
||||
for path in (legacy, errors, logging, alias):
|
||||
self.assertEqual(self.git_metadata(path), original[str(path)])
|
||||
|
||||
def test_repeated_transition_preserves_dirty_index_ignored_and_external_git_metadata(self):
|
||||
legacy = self.legacy()
|
||||
self.git(legacy, "checkout", "-b", "feature/legacy")
|
||||
(legacy / "content").write_text("staged source\n")
|
||||
self.git(legacy, "add", "content")
|
||||
(legacy / "content").write_text("working source\n")
|
||||
(legacy / "private").mkdir()
|
||||
(legacy / "private/unique").write_text("ignored source\n")
|
||||
external = self.base / "external-owner"
|
||||
self.git(legacy, "worktree", "add", "-b", "feature/external", str(external))
|
||||
(external / "content").write_text("external owner work\n")
|
||||
metadata = self.git_metadata(legacy)
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/App"
|
||||
refs = self.preserved_source_refs(clone)
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
self.assertEqual(self.git(clone, "show", ":content").stdout, "staged source\n")
|
||||
self.assertEqual((clone / "content").read_text(), "working source\n")
|
||||
self.assertEqual((clone / "private/unique").read_text(), "ignored source\n")
|
||||
self.assertEqual((external / "content").read_text(), "external owner work\n")
|
||||
self.assertEqual(self.git_metadata(legacy), metadata)
|
||||
self.assertEqual(self.preserved_source_refs(clone), refs)
|
||||
# A unique destination file belongs to its current owner and blocks reuse.
|
||||
(clone / "private/new-owner-file").write_text("new workspace work\n")
|
||||
state_before = (self.root / ".workspace/state.json").read_bytes()
|
||||
result = self.cli("transition", "--name", "App", "--apply", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("conflicting duplicate destination", result.stdout)
|
||||
self.assertEqual((clone / "private/new-owner-file").read_text(), "new workspace work\n")
|
||||
self.assertEqual((self.root / ".workspace/state.json").read_bytes(), state_before)
|
||||
self.assertEqual(self.git_metadata(legacy), metadata)
|
||||
|
||||
def test_repeated_transition_rejects_a_new_source_head_without_switching_destination(self):
|
||||
legacy = self.legacy()
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/App"
|
||||
refs = self.preserved_source_refs(clone)
|
||||
state_before = (self.root / ".workspace/state.json").read_bytes()
|
||||
(legacy / "content").write_text("new legacy head\n")
|
||||
self.git(legacy, "commit", "-am", "Advance original owner branch")
|
||||
new_head = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
|
||||
original_metadata = self.git_metadata(legacy)
|
||||
result = self.cli("transition", "--name", "App", "--apply", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("conflicting duplicate destination", result.stdout)
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
self.assertEqual(self.git(legacy, "rev-parse", "HEAD").stdout.strip(), new_head)
|
||||
self.assertEqual(self.preserved_source_refs(clone), refs)
|
||||
self.assertEqual((self.root / ".workspace/state.json").read_bytes(), state_before)
|
||||
self.assertEqual(self.git_metadata(legacy), original_metadata)
|
||||
|
||||
def npm_fixture(self):
|
||||
remote, author, head = self.component("UI", {"package.json": '{"name":"@agent-system/ui","version":"0.0.0"}\n'})
|
||||
(author / "package.json").write_text('{"name":"@agent-system/ui","version":"0.0.1"}\n')
|
||||
self.git(author, "commit", "-am", "Advance UI remote beyond consumer lock")
|
||||
self.git(author, "push", "origin", "main")
|
||||
(self.author / "package-lock.json").write_text(json.dumps({"name": "frontend", "lockfileVersion": 3,
|
||||
"packages": {"": {"dependencies": {"@agent-system/ui": "git+file://" + str(remote) + "#" + head}},
|
||||
"node_modules/@agent-system/ui": {"version": "0.0.0", "resolved": "git+file://" + str(remote) + "#" + head}}}))
|
||||
self.git(self.author, "add", "package-lock.json")
|
||||
self.git(self.author, "commit", "-m", "Lock UI package")
|
||||
self.git(self.author, "push", "origin", "main")
|
||||
self.lock["components"]["App"]["commit"] = self.git(self.author, "rev-parse", "HEAD").stdout.strip()
|
||||
entry = self.entry("UI", remote, "npm-library")
|
||||
entry["package_name"] = "@agent-system/ui"
|
||||
self.manifest["components"].append(entry)
|
||||
self.save_manifest()
|
||||
return remote, author, head
|
||||
|
||||
def test_npm_library_edit_uses_consumer_lock_git_sha_not_remote_main(self):
|
||||
_, _, head = self.npm_fixture()
|
||||
self.cli("edit", "--name", "UI")
|
||||
clone = self.root / ".workspace/UI"
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(json.loads((clone / "package.json").read_text())["version"], "0.0.0")
|
||||
self.assertEqual(self.state()["components"]["UI"]["source"]["kind"], "npm-lock")
|
||||
self.assertFalse((self.root / "go.work").exists())
|
||||
|
||||
def test_npm_library_rejects_wrong_lock_origin_and_competing_manual_lock(self):
|
||||
remote, _, head = self.npm_fixture()
|
||||
self.lock["components"]["UI"] = {"commit": head}
|
||||
self.save_manifest()
|
||||
result = self.cli("edit", "--name", "UI", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("competing", result.stdout)
|
||||
self.lock["components"].pop("UI")
|
||||
self.save_manifest()
|
||||
self.cli("prepare")
|
||||
lock_path = self.root / ".workspace/App/package-lock.json"
|
||||
document = json.loads(lock_path.read_text())
|
||||
document["packages"]["node_modules/@agent-system/ui"]["resolved"] = "git+file://" + str(self.base / "unapproved.git") + "#" + head
|
||||
lock_path.write_text(json.dumps(document))
|
||||
result = self.cli("edit", "--name", "UI", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("origin", result.stdout)
|
||||
self.assertFalse((self.root / ".workspace/UI").exists())
|
||||
|
||||
def test_unlink_refuses_manual_overlay_changes_without_changing_registration(self):
|
||||
self.go_fixture()
|
||||
self.cli("prepare")
|
||||
consumer = self.root / ".workspace/Consumer"
|
||||
self.command(consumer, "go", "mod", "download", "all", env=self.env | {"GOWORK": "off"})
|
||||
self.cli("edit", "--name", "Native")
|
||||
overlay = self.root / "go.work"
|
||||
overlay.write_text(overlay.read_text() + "// manual edit\n")
|
||||
result = self.cli("unlink", "--name", "Native", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(self.state()["components"]["Native"]["linked"])
|
||||
self.assertTrue(overlay.read_text().endswith("// manual edit\n"))
|
||||
|
||||
def test_missing_fixed_source_never_falls_back_to_remote_main(self):
|
||||
self.lock["components"] = {}
|
||||
self.save_manifest()
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.root / ".workspace/App").exists())
|
||||
|
||||
def test_existing_fixed_checkout_is_not_switched_after_lock_change(self):
|
||||
self.cli("prepare")
|
||||
(self.author / "content").write_text("advanced version\n")
|
||||
self.git(self.author, "commit", "-am", "Advance")
|
||||
self.git(self.author, "push", "origin", "main")
|
||||
self.lock["components"]["App"]["commit"] = self.git(self.author, "rev-parse", "HEAD").stdout.strip()
|
||||
self.save_manifest()
|
||||
result = self.cli("prepare", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(self.root / ".workspace/App", "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
|
||||
def advance_app_lock(self):
|
||||
(self.author / "content").write_text("updated locked version\n")
|
||||
counter = self.author / "revision-counter"
|
||||
counter.write_text((counter.read_text() if counter.exists() else "") + "next\n")
|
||||
self.git(self.author, "add", "revision-counter")
|
||||
self.git(self.author, "commit", "-am", "Advance fixed source")
|
||||
self.git(self.author, "push", "origin", "main")
|
||||
head = self.git(self.author, "rev-parse", "HEAD").stdout.strip()
|
||||
self.lock["components"]["App"]["commit"] = head
|
||||
self.save_manifest()
|
||||
return head
|
||||
|
||||
def test_explicit_fixed_refresh_preserves_old_commit_refs_and_reflog(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
refs_before = self.git(clone, "for-each-ref", "--format=%(refname) %(objectname)").stdout.splitlines()
|
||||
reflog_before = self.git(clone, "reflog", "--format=%H", "HEAD").stdout.splitlines()
|
||||
head = self.advance_app_lock()
|
||||
self.cli("prepare", "--name", "App", "--refresh-fixed")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(self.git(clone, "branch", "--show-current").stdout, "")
|
||||
self.assertEqual((clone / "content").read_text(), "updated locked version\n")
|
||||
self.assertEqual(self.state()["components"]["App"]["baseline"], head)
|
||||
for row in refs_before:
|
||||
self.assertIn(row, self.git(clone, "for-each-ref", "--format=%(refname) %(objectname)").stdout.splitlines())
|
||||
for row in reflog_before:
|
||||
self.assertIn(row, self.git(clone, "reflog", "--format=%H", "HEAD").stdout.splitlines())
|
||||
history = self.state()["components"]["App"]["previous_sources"]
|
||||
self.assertEqual(history[0]["commit"], self.first)
|
||||
self.assertEqual(self.git(clone, "rev-parse", history[0]["ref"]).stdout.strip(), self.first)
|
||||
self.cli("prepare", "--name", "App", "--refresh-fixed")
|
||||
self.assertEqual(len(self.state()["components"]["App"]["previous_sources"]), 1)
|
||||
|
||||
def test_explicit_fixed_refresh_refuses_dirty_ignored_edited_and_locked_sources(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.advance_app_lock()
|
||||
state_before = (self.root / ".workspace/state.json").read_bytes()
|
||||
(clone / "content").write_text("unsaved owner change\n")
|
||||
self.assertNotEqual(self.cli("prepare", "--refresh-fixed", check=False).returncode, 0)
|
||||
self.assertEqual((clone / "content").read_text(), "unsaved owner change\n")
|
||||
(clone / "content").write_text("first\n")
|
||||
(clone / "private").mkdir()
|
||||
(clone / "private/unique").write_text("ignored owner data\n")
|
||||
self.assertNotEqual(self.cli("prepare", "--refresh-fixed", check=False).returncode, 0)
|
||||
self.assertEqual((clone / "private/unique").read_text(), "ignored owner data\n")
|
||||
(clone / "private/unique").unlink()
|
||||
(clone / "private").rmdir()
|
||||
lock = clone / ".git/index.lock"
|
||||
lock.write_text("another Git owner\n")
|
||||
self.assertNotEqual(self.cli("prepare", "--refresh-fixed", check=False).returncode, 0)
|
||||
self.assertEqual(lock.read_text(), "another Git owner\n")
|
||||
lock.unlink()
|
||||
self.assertEqual((self.root / ".workspace/state.json").read_bytes(), state_before)
|
||||
self.lock["components"]["App"]["commit"] = self.first
|
||||
self.save_manifest()
|
||||
self.cli("edit", "--name", "App", "--branch", "feature/owner")
|
||||
self.advance_app_lock()
|
||||
result = self.cli("prepare", "--refresh-fixed", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(clone, "branch", "--show-current").stdout.strip(), "feature/owner")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
|
||||
def test_explicit_fixed_refresh_refuses_shared_or_unregistered_ownership(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.advance_app_lock()
|
||||
external = self.base / "fixed-external-owner"
|
||||
self.git(clone, "worktree", "add", "-b", "feature/external", str(external))
|
||||
(external / "unique").write_text("external owner data\n")
|
||||
result = self.cli("prepare", "--refresh-fixed", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
self.assertEqual((external / "unique").read_text(), "external owner data\n")
|
||||
(external / "unique").unlink()
|
||||
self.git(clone, "worktree", "remove", str(external))
|
||||
data = self.state()
|
||||
data["components"].pop("App")
|
||||
(self.root / ".workspace/state.json").write_text(json.dumps(data))
|
||||
result = self.cli("prepare", "--refresh-fixed", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
|
||||
def test_failed_explicit_fixed_refresh_keeps_old_binding_and_can_retry(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.lock["components"]["App"]["commit"] = "a" * 40
|
||||
self.save_manifest()
|
||||
result = self.cli("prepare", "--refresh-fixed", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.state()["components"]["App"]["baseline"], self.first)
|
||||
self.assertEqual(self.state()["components"]["App"]["mode"], "versioned")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
head = self.advance_app_lock()
|
||||
self.cli("prepare", "--refresh-fixed")
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
|
||||
def test_explicit_fixed_refresh_refuses_index_flags_hiding_owner_changes(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.git(clone, "update-index", "--skip-worktree", "content")
|
||||
(clone / "content").write_text("hidden owner change\n")
|
||||
self.advance_app_lock()
|
||||
result = self.cli("prepare", "--refresh-fixed", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
|
||||
self.assertEqual((clone / "content").read_text(), "hidden owner change\n")
|
||||
|
||||
def test_private_git_metadata_symlink_is_not_adopted(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
external = self.base / "other-owner-metadata"
|
||||
(clone / ".git").rename(external)
|
||||
(clone / ".git").symlink_to(external, target_is_directory=True)
|
||||
result = self.cli("edit", "--name", "App", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((external / "refs/heads/feature").exists())
|
||||
|
||||
def test_git_operation_is_reported_and_not_removed(self):
|
||||
self.cli("prepare")
|
||||
operation = self.root / ".workspace/App/.git/MERGE_HEAD"
|
||||
operation.write_text(self.first + "\n")
|
||||
result = self.cli("edit", "--name", "App", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(operation.read_text(), self.first + "\n")
|
||||
|
||||
def test_status_does_not_expose_credentials_from_an_unmatched_actual_origin(self):
|
||||
self.cli("prepare")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.git(clone, "remote", "set-url", "origin", "https://owner:workspace-fake-secret@example.invalid/repo.git")
|
||||
result = self.cli("status")
|
||||
self.assertNotIn("workspace-fake-secret", result.stdout)
|
||||
self.assertIn("redacted", result.stdout)
|
||||
|
||||
def test_transition_preserves_committed_symlink_deletion_and_file_type_change(self):
|
||||
legacy = self.legacy()
|
||||
(legacy / "old-link").symlink_to("content")
|
||||
(legacy / "regular").write_text("committed file\n")
|
||||
self.git(legacy, "add", "old-link", "regular")
|
||||
self.git(legacy, "commit", "-m", "Add two file types")
|
||||
(legacy / "old-link").unlink()
|
||||
(legacy / "regular").unlink()
|
||||
(legacy / "regular").symlink_to("content")
|
||||
before = self.fingerprint(legacy)
|
||||
self.cli("transition", "--name", "App", "--apply")
|
||||
clone = self.root / ".workspace/App"
|
||||
self.assertFalse((clone / "old-link").is_symlink())
|
||||
self.assertTrue((clone / "regular").is_symlink())
|
||||
self.assertEqual(self.git(clone, "diff", "--raw").stdout, self.git(legacy, "diff", "--raw").stdout)
|
||||
self.assertEqual(self.fingerprint(legacy), before)
|
||||
|
||||
def go_fixture(self):
|
||||
module = "example.invalid/Native"
|
||||
library_remote, author, head = self.component("Native", {
|
||||
"go.mod": "module example.invalid/Native\n\ngo 1.27\n",
|
||||
"value.go": 'package native\nfunc Value() string { return "versioned" }\n'})
|
||||
version = "v0.0.0-20261013000000-" + head[:12]
|
||||
proxy = self.base / "proxy/example.invalid/!native/@v"
|
||||
proxy.mkdir(parents=True)
|
||||
(proxy / (version + ".mod")).write_text((author / "go.mod").read_text())
|
||||
# Go's real proxy protocol supplies Origin.Hash, avoiding a fake Go executable.
|
||||
(proxy / (version + ".info")).write_text(json.dumps({"Version": version,
|
||||
"Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(library_remote), "Hash": head}}))
|
||||
with zipfile.ZipFile(proxy / (version + ".zip"), "w") as archive:
|
||||
for filename in ("go.mod", "value.go"):
|
||||
archive.write(author / filename, module + "@" + version + "/" + filename)
|
||||
app_remote, _, app_head = self.component("Consumer", {
|
||||
"go.mod": "module example.invalid/consumer\n\ngo 1.27\n\nrequire " + module + " " + version + "\n",
|
||||
"main.go": 'package main\nimport ("fmt"; native "example.invalid/Native")\nfunc main() { fmt.Print(native.Value()) }\n'})
|
||||
self.manifest["components"] = [self.entry("Consumer", app_remote, modules=[{"path": "example.invalid/consumer", "directory": "."}]),
|
||||
self.entry("Native", library_remote, "go-library", [{"path": module, "legacy": "native", "directory": "."}])]
|
||||
self.lock["components"] = {"Consumer": {"commit": app_head}}
|
||||
self.save_manifest()
|
||||
self.env.update({"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "modcache"),
|
||||
"GOPATH": str(self.base / "gopath")})
|
||||
return head
|
||||
|
||||
def test_actual_go_version_baseline_overlay_and_unlink_restore_version_consumption(self):
|
||||
head = self.go_fixture()
|
||||
self.cli("prepare")
|
||||
consumer = self.root / ".workspace/Consumer"
|
||||
native = self.root / ".workspace/Native"
|
||||
self.command(consumer, "go", "mod", "download", "all", env=self.env | {"GOWORK": "off"})
|
||||
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".", env=self.env | {"GOWORK": "off"}).stdout, "versioned")
|
||||
self.cli("edit", "--name", "Native", "--branch", "feature/value")
|
||||
self.assertEqual(self.git(native, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
(native / "value.go").write_text('package native\nfunc Value() string { return "edited" }\n')
|
||||
self.assertEqual(self.command(consumer, "go", "run", ".").stdout, "edited")
|
||||
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".", env=self.env | {"GOWORK": "off"}).stdout, "versioned")
|
||||
self.cli("unlink", "--name", "Native")
|
||||
self.assertFalse((self.root / "go.work").exists())
|
||||
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".").stdout, "versioned")
|
||||
self.assertEqual((native / "value.go").read_text(), 'package native\nfunc Value() string { return "edited" }\n')
|
||||
|
||||
def test_fresh_library_edit_resolves_fixed_consumer_and_preserves_consumer_manifest(self):
|
||||
head = self.go_fixture()
|
||||
self.cli("edit", "--name", "Native")
|
||||
native = self.root / ".workspace/Native"
|
||||
consumer = self.root / ".workspace/Consumer"
|
||||
self.assertEqual(self.git(native, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertFalse((consumer / "go.sum").exists(), "source resolution must not write a consumer's go.sum")
|
||||
self.assertEqual(self.git(consumer, "status", "--porcelain=v1").stdout, "")
|
||||
|
||||
def test_library_baseline_resolves_transitive_selected_module(self):
|
||||
head = self.go_fixture()
|
||||
native_version = "v0.0.0-20261013000000-" + head[:12]
|
||||
bridge_remote, bridge_author, bridge_head = self.component("Bridge", {
|
||||
"go.mod": "module example.invalid/Bridge\n\ngo 1.27\n\nrequire example.invalid/Native " + native_version + "\n",
|
||||
"value.go": 'package bridge\nimport native "example.invalid/Native"\nfunc Value() string { return native.Value() }\n'})
|
||||
bridge_version = "v0.0.0-20261013000000-" + bridge_head[:12]
|
||||
proxy = self.base / "proxy/example.invalid/!bridge/@v"
|
||||
proxy.mkdir(parents=True)
|
||||
(proxy / (bridge_version + ".mod")).write_text((bridge_author / "go.mod").read_text())
|
||||
(proxy / (bridge_version + ".info")).write_text(json.dumps({"Version": bridge_version,
|
||||
"Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(bridge_remote), "Hash": bridge_head}}))
|
||||
with zipfile.ZipFile(proxy / (bridge_version + ".zip"), "w") as archive:
|
||||
for filename in ("go.mod", "value.go"):
|
||||
archive.write(bridge_author / filename, "example.invalid/Bridge@" + bridge_version + "/" + filename)
|
||||
author = self.base / "Consumer-author"
|
||||
(author / "go.mod").write_text("module example.invalid/consumer\n\ngo 1.27\n\nrequire example.invalid/Bridge " + bridge_version + "\n")
|
||||
(author / "main.go").write_text('package main\nimport ("fmt"; bridge "example.invalid/Bridge")\nfunc main() { fmt.Print(bridge.Value()) }\n')
|
||||
self.git(author, "commit", "-am", "Consume Native transitively")
|
||||
self.git(author, "push", "origin", "main")
|
||||
self.lock["components"]["Consumer"]["commit"] = self.git(author, "rev-parse", "HEAD").stdout.strip()
|
||||
self.save_manifest()
|
||||
self.cli("edit", "--name", "Native")
|
||||
self.assertEqual(self.git(self.root / ".workspace/Native", "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertFalse((self.root / ".workspace/Consumer/go.sum").exists())
|
||||
(self.root / ".workspace/Native/value.go").write_text('package native\nfunc Value() string { return "transitive edit" }\n')
|
||||
self.assertEqual(self.command(self.root / ".workspace/Consumer", "go", "run", ".", separate_stderr=True).stdout, "transitive edit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Task cutover protections with real repositories, gitlinks and worktrees."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WorkspaceCutoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="workspace-cutover-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.repo = self.base / "repo"
|
||||
self.env = os.environ.copy()
|
||||
for key in list(self.env):
|
||||
if key.startswith("GIT_CONFIG_") or key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GOWORK", "PRECHECK_RECEIPT", "RECEIPT", "CMD", "IMPORT_RECEIPT", "COMPOSITION_SOURCE_MAP", "CI_DEPENDENCY_MIRRORS"):
|
||||
self.env.pop(key, None)
|
||||
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "Cutover Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "Cutover Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
|
||||
"XDG_CACHE_HOME": str(self.base / "cache")})
|
||||
self.call(self.base, "git", "init", "--initial-branch=main", str(self.repo))
|
||||
shutil.copytree(SOURCE / "scripts", self.repo / "scripts")
|
||||
(self.repo / "Makefile").write_text("precheck:\n\t@bash scripts/task.sh precheck\n")
|
||||
(self.repo / ".gitignore").write_text(".workspace/\ngo.work\ngo.work.sum\nmodules/\n")
|
||||
(self.repo / "tracked").write_text("root\n")
|
||||
self.git(self.repo, "add", ".")
|
||||
self.git(self.repo, "commit", "-m", "Old project")
|
||||
self.remote = self.base / "project.git"
|
||||
self.call(self.base, "git", "init", "--bare", "--initial-branch=main", str(self.remote))
|
||||
self.git(self.remote, "config", "gc.auto", "0")
|
||||
self.git(self.remote, "config", "receive.autogc", "false")
|
||||
self.git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
self.git(self.repo, "push", "-u", "origin", "main")
|
||||
self.app, self.app_sha = self.component("App", {"content": "app v1\n", ".gitignore": "private/\n"})
|
||||
self.rules, self.rules_sha = self.component("Rules", {"core/baseline.md": "# Baseline\n", "workflows/delivery.md": "# Delivery\n"})
|
||||
|
||||
def call(self, cwd, *args, check=True, env=None):
|
||||
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def git(self, cwd, *args, **kwargs):
|
||||
return self.call(cwd, "git", *args, **kwargs)
|
||||
|
||||
def task(self, cwd, command, *args, **kwargs):
|
||||
return self.call(cwd, "bash", str(cwd / "scripts/task.sh"), command, *args, **kwargs)
|
||||
|
||||
def component(self, name, files):
|
||||
path = self.base / name
|
||||
self.call(self.base, "git", "init", "--initial-branch=main", str(path))
|
||||
for relative, content in files.items():
|
||||
target = path / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
self.git(path, "add", ".")
|
||||
self.git(path, "commit", "-m", name + " v1")
|
||||
return path, self.git(path, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
def adopt(self):
|
||||
records = [{"id": name, "repo": str(path), "path": ".workspace/" + name,
|
||||
"legacy_path": "modules/" + name, "role": role, "modules": [], "target": "main"}
|
||||
for name, path, role in (("App", self.app, "application"), ("Rules", self.rules, "guidelines"))]
|
||||
(self.repo / "components.json").write_text(json.dumps({"schema": 1, "components": records}))
|
||||
(self.repo / "components.lock.json").write_text(json.dumps({"schema": 1, "components": {"App": {"commit": self.app_sha}, "Rules": {"commit": self.rules_sha}}}))
|
||||
self.git(self.repo, "add", "components.json", "components.lock.json")
|
||||
self.git(self.repo, "commit", "-m", "Adopt source manifests")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
|
||||
def worktree(self, name="feature/old"):
|
||||
path = self.base / "task"
|
||||
self.git(self.repo, "worktree", "add", "-b", name, str(path), "origin/main")
|
||||
return path
|
||||
|
||||
def workspace(self, root, *args):
|
||||
return self.call(root, "python3", "scripts/workspace.py", "--root", str(root), *args)
|
||||
|
||||
def test_precheck_prepares_fixed_rules_outside_project_before_ready(self):
|
||||
self.adopt()
|
||||
result = self.task(self.repo, "precheck")
|
||||
self.assertIn("[verdict] READY", result.stdout)
|
||||
self.assertFalse((self.repo / ".workspace").exists())
|
||||
self.assertIn("[rules]", result.stdout)
|
||||
reading = next(line for line in result.stdout.splitlines() if line.startswith("[rules]"))
|
||||
self.assertIn(self.rules_sha, reading)
|
||||
self.assertTrue(list((self.base / "cache").rglob("core/baseline.md")))
|
||||
self.assertTrue(list((self.base / "cache").rglob("workflows/delivery.md")))
|
||||
|
||||
def test_precheck_never_claims_ready_for_unavailable_fixed_rules(self):
|
||||
self.adopt()
|
||||
(self.rules / "core/baseline.md").unlink()
|
||||
self.git(self.rules, "commit", "-am", "Rules missing baseline")
|
||||
document = json.loads((self.repo / "components.lock.json").read_text())
|
||||
document["components"]["Rules"]["commit"] = self.git(self.rules, "rev-parse", "HEAD").stdout.strip()
|
||||
(self.repo / "components.lock.json").write_text(json.dumps(document))
|
||||
self.git(self.repo, "commit", "-am", "Pin invalid rules")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(self.repo, "precheck", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[verdict] READY", result.stdout)
|
||||
self.assertFalse((self.repo / ".workspace").exists())
|
||||
|
||||
def test_start_prepares_fixed_sources_without_recursive_submodule_init(self):
|
||||
nested, _ = self.component("Nested", {"value": "nested\n"})
|
||||
self.git(self.app, "submodule", "add", str(nested), "deps/Nested")
|
||||
self.git(self.app, "commit", "-am", "Pin nested source")
|
||||
self.app_sha = self.git(self.app, "rev-parse", "HEAD").stdout.strip()
|
||||
self.adopt()
|
||||
receipt = self.base / "precheck.json"
|
||||
self.call(self.repo, "bash", "scripts/evidence.sh", env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"})
|
||||
self.task(self.repo, "start", "feature/new", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
wt = self.base / "repo-wt-feature-new"
|
||||
self.assertTrue((wt / ".workspace/App/.git").is_dir(), "fixed App checkout is missing")
|
||||
self.assertEqual(self.git(wt / ".workspace/App", "rev-parse", "HEAD").stdout.strip(), self.app_sha)
|
||||
self.assertEqual(self.git(wt / ".workspace/Rules", "rev-parse", "HEAD").stdout.strip(), self.rules_sha)
|
||||
self.assertFalse((wt / ".workspace/App/deps/Nested/.git").exists())
|
||||
self.assertTrue((wt / ".task/feature-new.md").exists())
|
||||
|
||||
def test_old_task_sync_adopts_new_sources_and_reports_explicit_transition(self):
|
||||
self.git(self.repo, "-c", "core.excludesfile=/dev/null", "submodule", "add", "--force", str(self.app), "modules/App")
|
||||
self.git(self.repo, "commit", "-m", "Pin old source")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init")
|
||||
legacy = wt / "modules/App"
|
||||
self.git(legacy, "switch", "-c", "feature/owned")
|
||||
before = self.git(legacy, "show-ref").stdout
|
||||
self.git(self.repo, "rm", "--cached", "modules/App")
|
||||
self.git(self.repo, "rm", ".gitmodules")
|
||||
self.git(self.repo, "commit", "-m", "Retire gitlink without removing checkout")
|
||||
self.adopt()
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[transition]", result.stdout)
|
||||
self.assertEqual(self.git(legacy, "show-ref").stdout, before)
|
||||
self.assertEqual(self.git(legacy, "branch", "--show-current").stdout.strip(), "feature/owned")
|
||||
self.assertTrue((wt / ".workspace/App/.git").is_dir())
|
||||
self.assertEqual((legacy / "content").read_text(), "app v1\n")
|
||||
|
||||
def test_sync_preserves_edit_branch_and_refuses_changed_declared_source(self):
|
||||
self.adopt()
|
||||
wt = self.worktree()
|
||||
self.workspace(wt, "edit", "--name", "App", "--branch", "feature/owned")
|
||||
edited = wt / ".workspace/App"
|
||||
(edited / "content").write_text("local unsaved\n")
|
||||
self.task(wt, "sync")
|
||||
(self.app / "content").write_text("app v2\n")
|
||||
self.git(self.app, "commit", "-am", "App v2")
|
||||
lock = json.loads((self.repo / "components.lock.json").read_text())
|
||||
lock["components"]["App"]["commit"] = self.git(self.app, "rev-parse", "HEAD").stdout.strip()
|
||||
(self.repo / "components.lock.json").write_text(json.dumps(lock))
|
||||
self.git(self.repo, "commit", "-am", "Update application lock")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertEqual((edited / "content").read_text(), "local unsaved\n")
|
||||
self.assertEqual(self.git(edited, "branch", "--show-current").stdout.strip(), "feature/owned")
|
||||
|
||||
def test_finish_preserves_external_worktree_of_retired_legacy_checkout(self):
|
||||
self.adopt()
|
||||
wt = self.worktree()
|
||||
legacy = wt / "modules/App"
|
||||
self.git(wt, "clone", str(self.app), str(legacy))
|
||||
external = self.base / "external"
|
||||
self.git(legacy, "worktree", "add", "-b", "feature/external", str(external))
|
||||
(external / "content").write_text("external unique\n")
|
||||
self.git(external, "add", "content")
|
||||
before = self.git(external, "status", "--porcelain").stdout
|
||||
result = self.task(wt, "finish", check=False, env=self.env | {"TASK_WRITERS_STOPPED": "1"})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("registered component worktree", result.stdout)
|
||||
self.assertEqual(self.git(external, "status", "--porcelain").stdout, before)
|
||||
self.assertEqual((external / "content").read_text(), "external unique\n")
|
||||
self.assertTrue(wt.exists())
|
||||
|
||||
def test_verify_detects_active_git_operation_in_nested_retired_root(self):
|
||||
self.adopt()
|
||||
wt = self.worktree()
|
||||
legacy = wt / "modules/App"
|
||||
self.git(wt, "clone", str(self.app), str(legacy))
|
||||
nested = legacy / "retired/nested"
|
||||
self.git(wt, "clone", str(self.rules), str(nested))
|
||||
lock = nested / ".git/index.lock"
|
||||
lock.write_text("another writer\n")
|
||||
result = self.call(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("active Git operation/lock", result.stdout)
|
||||
self.assertEqual(lock.read_text(), "another writer\n")
|
||||
|
||||
def test_make_workspace_commands_prepare_edit_unlink_and_report(self):
|
||||
self.adopt()
|
||||
shutil.copyfile(SOURCE / "Makefile", self.repo / "Makefile")
|
||||
self.call(self.repo, "make", "workspace-prepare", "NAME=App")
|
||||
source = self.repo / ".workspace/App"
|
||||
self.assertEqual((source / "content").read_text(), "app v1\n")
|
||||
self.call(self.repo, "make", "component-edit", "NAME=App", "BRANCH=feature/from-make")
|
||||
self.assertEqual(self.git(source, "branch", "--show-current").stdout.strip(), "feature/from-make")
|
||||
self.call(self.repo, "make", "component-unlink", "NAME=App")
|
||||
self.assertTrue(source.is_dir())
|
||||
status = self.call(self.repo, "make", "workspace-status", "NAME=App")
|
||||
self.assertIn('"feature/from-make"', status.stdout)
|
||||
transition = self.call(self.repo, "make", "workspace-transition", "NAME=App")
|
||||
self.assertIn('"legacy"', transition.stdout)
|
||||
self.assertFalse((self.repo / "modules/App").exists())
|
||||
|
||||
def test_finish_preserves_absorbed_metadata_after_legacy_checkout_disappears(self):
|
||||
self.git(self.repo, "submodule", "add", "--force", str(self.app), "modules/App")
|
||||
self.git(self.repo, "commit", "-m", "Old gitlink")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init")
|
||||
legacy = wt / "modules/App"
|
||||
external = self.base / "external"
|
||||
self.git(legacy, "worktree", "add", "-b", "feature/external", str(external))
|
||||
(external / "content").write_text("independent owner\n")
|
||||
self.git(external, "add", "content")
|
||||
before = self.git(external, "status", "--porcelain").stdout
|
||||
self.git(self.repo, "rm", "--cached", "modules/App")
|
||||
self.git(self.repo, "rm", ".gitmodules")
|
||||
self.git(self.repo, "commit", "-m", "Remove legacy index entries")
|
||||
self.adopt()
|
||||
self.task(wt, "sync")
|
||||
shutil.rmtree(legacy) # Simulate an absent checkout while its Git metadata remains.
|
||||
result = self.task(wt, "finish", check=False, env=self.env | {"TASK_WRITERS_STOPPED": "1"})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("retained metadata", result.stdout)
|
||||
self.assertEqual(self.git(external, "status", "--porcelain").stdout, before)
|
||||
self.assertEqual((external / "content").read_text(), "independent owner\n")
|
||||
|
||||
def test_finish_preserves_nonbranch_legacy_refs_without_remote_backup(self):
|
||||
self.adopt()
|
||||
wt = self.worktree()
|
||||
legacy = wt / "modules/App"
|
||||
self.git(wt, "clone", str(self.app), str(legacy))
|
||||
(legacy / "content").write_text("unpublished\n")
|
||||
self.git(legacy, "commit", "-am", "Unique migration object")
|
||||
unique = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(legacy, "update-ref", "refs/migration/unique", unique)
|
||||
self.git(legacy, "reset", "--hard", "origin/main")
|
||||
self.git(legacy, "reflog", "expire", "--expire=now", "--all")
|
||||
result = self.task(wt, "finish", check=False, env=self.env | {"TASK_WRITERS_STOPPED": "1"})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("unpreserved local refs", result.stdout)
|
||||
self.assertEqual(self.git(legacy, "rev-parse", "refs/migration/unique").stdout.strip(), unique)
|
||||
|
||||
@unittest.skipUnless(shutil.which("go"), "Go is required for architecture gate")
|
||||
def test_architecture_gate_preserves_edges_under_workspace_paths_and_new_module_identity(self):
|
||||
self.adopt()
|
||||
policy = self.repo / "policy"
|
||||
policy.mkdir(exist_ok=True)
|
||||
(policy / "architecture.yaml").write_text("backend:\n root: .workspace/Agent-System-Backend/internal\n import_prefix: git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/\nfrontend:\n root: .workspace/Agent-System-Frontend/src\n groups: components\n")
|
||||
(policy / "architecture-baseline.json").write_text('["backend/app backend/store"]')
|
||||
backend = self.repo / ".workspace/Agent-System-Backend"
|
||||
(backend / "internal/app").mkdir(parents=True)
|
||||
(backend / "internal/store").mkdir()
|
||||
(backend / "go.mod").write_text("module git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git\n\ngo 1.23\n")
|
||||
source = backend / "internal/app/app.go"
|
||||
source.write_text('package app\nimport _ "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"\n')
|
||||
(self.repo / ".workspace/Agent-System-Frontend/src").mkdir(parents=True)
|
||||
self.call(self.repo, "bash", "scripts/lint-architecture.sh")
|
||||
(backend / "internal/server").mkdir()
|
||||
source.write_text(source.read_text() + 'import _ "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/server"\n')
|
||||
result = self.call(self.repo, "bash", "scripts/lint-architecture.sh", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("backend/app backend/server", result.stdout)
|
||||
self.assertEqual(json.loads((policy / "architecture-baseline.json").read_text()), ["backend/app backend/store"])
|
||||
|
||||
@unittest.skipUnless(shutil.which("go"), "Go is required for boundary gates")
|
||||
def test_boundary_gate_uses_isolated_map_and_rejects_new_backend_internal_import(self):
|
||||
self.adopt()
|
||||
names = ("Agent-System-Backend", "Agent-System-Frontend", "Agent-Intent", "Agent-Native", "Agent-Squad")
|
||||
modules = {name: "git-ssh.u1680832.nyat.app/weichen/" + name + ".git" for name in names}
|
||||
entries = [{"id": name, "repo": str(self.app), "path": ".workspace/" + name,
|
||||
"role": "application" if name in names[:2] else "go-library", "modules": [], "target": "main"} for name in names]
|
||||
(self.repo / "components.json").write_text(json.dumps({"schema": 1, "components": entries}))
|
||||
(self.repo / "components.lock.json").write_text(json.dumps({"schema": 1, "components": {name: {"commit": self.app_sha} for name in names[:2]}}))
|
||||
policy = self.repo / "policy"
|
||||
policy.mkdir()
|
||||
(policy / "architecture.yaml").write_text("backend: .workspace/Agent-System-Backend/internal\nfrontend: .workspace/Agent-System-Frontend/src\ncomponents: {}\n")
|
||||
(policy / "architecture-baseline.json").write_text("[]")
|
||||
(policy / "lib-deps-allowlist.txt").write_text("# No sibling-library imports\n")
|
||||
mapping = {"schema": 1, "root_commit": self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), "components": {}}
|
||||
for name in names:
|
||||
path = self.base / "isolated" / name
|
||||
path.mkdir(parents=True)
|
||||
if name != "Agent-System-Frontend":
|
||||
(path / "go.mod").write_text("module " + modules[name] + "\n\ngo 1.23\n")
|
||||
(path / "source.go").write_text("package fixture\n")
|
||||
mapping["components"][name] = {"path": str(path), "commit": self.app_sha, "repo": str(self.app)}
|
||||
backend = self.base / "isolated/Agent-System-Backend"
|
||||
(backend / "internal/store").mkdir(parents=True)
|
||||
(backend / "internal/store/store.go").write_text("package store\n")
|
||||
sdk = backend / "pkg/extension"
|
||||
sdk.mkdir(parents=True)
|
||||
source = sdk / "sdk.go"
|
||||
source.write_text("package extension\n")
|
||||
(self.base / "isolated/Agent-System-Frontend/src").mkdir()
|
||||
source_map = self.base / "source-map.json"
|
||||
source_map.write_text(json.dumps(mapping))
|
||||
env = self.env | {"COMPOSITION_SOURCE_MAP": str(source_map)}
|
||||
self.call(self.repo, "bash", "scripts/lint-boundaries.sh", env=env)
|
||||
source.write_text('package extension\nimport _ "' + modules["Agent-System-Backend"] + '/internal/store"\n')
|
||||
result = self.call(self.repo, "bash", "scripts/lint-boundaries.sh", env=env, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("boundary: pkg/extension imports", result.stdout)
|
||||
# A forged source identity cannot substitute for the locked application.
|
||||
source.write_text("package extension\n")
|
||||
mapping["components"]["Agent-System-Backend"]["commit"] = "0" * 40
|
||||
source_map.write_text(json.dumps(mapping))
|
||||
result = self.call(self.repo, "bash", "scripts/lint-boundaries.sh", env=env, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("locked source", result.stdout)
|
||||
|
||||
def test_verify_detects_nested_git_operation_inside_new_workspace(self):
|
||||
self.adopt()
|
||||
wt = self.worktree()
|
||||
self.workspace(wt, "prepare")
|
||||
nested = wt / ".workspace/App/private/Rules"
|
||||
self.git(wt, "clone", str(self.rules), str(nested))
|
||||
lock = nested / ".git/index.lock"
|
||||
lock.write_text("active nested writer\n")
|
||||
result = self.call(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("active Git operation/lock", result.stdout)
|
||||
self.assertEqual(lock.read_text(), "active nested writer\n")
|
||||
|
||||
def test_old_task_sync_preserves_unique_legacy_commit_staging_dirty_and_ignored_files(self):
|
||||
self.git(self.repo, "submodule", "add", "--force", str(self.app), "modules/App")
|
||||
self.git(self.repo, "commit", "-m", "Old gitlink")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init")
|
||||
legacy = wt / "modules/App"
|
||||
self.git(legacy, "switch", "-c", "feature/unique")
|
||||
(legacy / "content").write_text("unpublished commit\n")
|
||||
self.git(legacy, "commit", "-am", "Unique component history")
|
||||
(legacy / "content").write_text("staged component change\n")
|
||||
self.git(legacy, "add", "content")
|
||||
(legacy / "content").write_text("unstaged component change\n")
|
||||
(legacy / "private").mkdir()
|
||||
(legacy / "private/unique").write_text("ignored unique data\n")
|
||||
before = (self.git(legacy, "show-ref").stdout, self.git(legacy, "ls-files", "--stage").stdout,
|
||||
self.git(wt, "rev-parse", "HEAD").stdout)
|
||||
self.git(self.repo, "rm", "--cached", "modules/App")
|
||||
self.git(self.repo, "rm", ".gitmodules")
|
||||
self.git(self.repo, "commit", "-m", "Retire old gitlink")
|
||||
self.adopt()
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertEqual((self.git(legacy, "show-ref").stdout, self.git(legacy, "ls-files", "--stage").stdout,
|
||||
self.git(wt, "rev-parse", "HEAD").stdout), before)
|
||||
self.assertEqual((legacy / "content").read_text(), "unstaged component change\n")
|
||||
self.assertEqual((legacy / "private/unique").read_text(), "ignored unique data\n")
|
||||
|
||||
def test_old_task_text_conflict_keeps_legacy_source_and_reports_no_success(self):
|
||||
self.git(self.repo, "submodule", "add", "--force", str(self.app), "modules/App")
|
||||
self.git(self.repo, "commit", "-m", "Old gitlink")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init")
|
||||
(wt / "tracked").write_text("task branch choice\n")
|
||||
self.git(wt, "commit", "-am", "Task text change")
|
||||
legacy = wt / "modules/App"
|
||||
before = self.git(legacy, "show-ref").stdout
|
||||
(self.repo / "tracked").write_text("main branch choice\n")
|
||||
self.git(self.repo, "commit", "-am", "Main text change")
|
||||
self.git(self.repo, "rm", "--cached", "modules/App")
|
||||
self.git(self.repo, "rm", ".gitmodules")
|
||||
self.git(self.repo, "commit", "-m", "Retire legacy index")
|
||||
self.adopt()
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertTrue(self.git(wt, "ls-files", "-u").stdout.strip())
|
||||
self.assertEqual(self.git(legacy, "show-ref").stdout, before)
|
||||
self.assertEqual((legacy / "content").read_text(), "app v1\n")
|
||||
|
||||
def test_precheck_rejects_borrowed_rules_cache_symlink(self):
|
||||
self.adopt()
|
||||
self.task(self.repo, "precheck")
|
||||
source = next(path for path in (self.base / "cache/agent-system/rules").iterdir() if path.is_dir())
|
||||
borrowed = self.base / "borrowed-rules"
|
||||
source.rename(borrowed)
|
||||
source.symlink_to(borrowed, target_is_directory=True)
|
||||
before = self.git(borrowed, "show-ref").stdout
|
||||
result = self.task(self.repo, "precheck", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[verdict] READY", result.stdout)
|
||||
self.assertEqual(self.git(borrowed, "show-ref").stdout, before)
|
||||
self.assertEqual((borrowed / "core/baseline.md").read_text(), "# Baseline\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user