Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
3922970bfc |
@@ -2698,9 +2698,11 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
compatibility)
|
compatibility)
|
||||||
test -n "$PREVIOUS_STABLE"
|
test -n "$PREVIOUS_STABLE"
|
||||||
./scripts/policy/check-command-compatibility.sh \
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||||
|
--repo-root "$GITHUB_WORKSPACE" \
|
||||||
--base-ref HEAD \
|
--base-ref HEAD \
|
||||||
--stable-ref "$PREVIOUS_STABLE"
|
--stable-ref "$PREVIOUS_STABLE" \
|
||||||
|
--candidate-ref HEAD
|
||||||
;;
|
;;
|
||||||
e2e)
|
e2e)
|
||||||
bash scripts/dev/test-multi-profile-e2e.sh
|
bash scripts/dev/test-multi-profile-e2e.sh
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
|||||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||||
|
|
||||||
|
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||||
|
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||||
|
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||||
|
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||||
|
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||||
|
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||||
|
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||||
|
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||||
|
并按 lifecycle 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||||
|
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||||
|
|
||||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||||
|
|||||||
+3
-3
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||||
|
|
||||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||||
|
|
||||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
|||||||
dws-release v1.2.3-beta.1
|
dws-release v1.2.3-beta.1
|
||||||
```
|
```
|
||||||
|
|
||||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||||
|
|
||||||
## 正式发布
|
## 正式发布
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`
|
|||||||
|
|
||||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||||
|
|||||||
@@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
|
|||||||
go build -o "$CANDIDATE_BIN" ./cmd
|
go build -o "$CANDIDATE_BIN" ./cmd
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
|
||||||
|
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
|
||||||
|
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
|
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
|
||||||
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
||||||
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
|
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
|
||||||
@@ -468,23 +473,22 @@ check_with_migrations() {
|
|||||||
historical_kind="$1"
|
historical_kind="$1"
|
||||||
historical_ref="$2"
|
historical_ref="$2"
|
||||||
historical_baseline="$3"
|
historical_baseline="$3"
|
||||||
|
set -- \
|
||||||
|
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||||
|
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||||
|
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||||
|
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||||
|
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
set -- "$@" \
|
||||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
|
||||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
|
||||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
|
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
|
||||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
|
||||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
|
||||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
|
||||||
else
|
|
||||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
|
||||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
|
||||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
|
||||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
|
||||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
|
||||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
|
||||||
fi
|
fi
|
||||||
|
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
|
||||||
|
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
|
||||||
|
set -- "$@" --migration-base-schema "$BASELINE"
|
||||||
|
fi
|
||||||
|
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
|
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
|
||||||
|
|||||||
@@ -233,6 +233,53 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin
|
|||||||
t.Fatalf("constraint drift was hidden: %s", failures)
|
t.Fatalf("constraint drift was hidden: %s", failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unchangedLegacyConstraints := cloneContract(current)
|
||||||
|
product = unchangedLegacyConstraints.Products["chat"]
|
||||||
|
tool = product.Tools["chat.move"]
|
||||||
|
tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
|
||||||
|
product.Tools["chat.move"] = tool
|
||||||
|
unchangedLegacyConstraints.Products["chat"] = product
|
||||||
|
if _, err := normalizeSchemaCommandMigrations(baseline, unchangedLegacyConstraints, migrations); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
|
||||||
|
t.Fatalf("unchanged legacy constraints error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixedLegacyConstraints := cloneContract(current)
|
||||||
|
product = mixedLegacyConstraints.Products["chat"]
|
||||||
|
tool = product.Tools["chat.move"]
|
||||||
|
tool.Constraints = `{"require_together":[["keep","new-id","old-id"]]}`
|
||||||
|
product.Tools["chat.move"] = tool
|
||||||
|
mixedLegacyConstraints.Products["chat"] = product
|
||||||
|
if _, err := normalizeSchemaCommandMigrations(baseline, mixedLegacyConstraints, migrations); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
|
||||||
|
t.Fatalf("mixed legacy constraints error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malformedHistoricalConstraints := cloneContract(baseline)
|
||||||
|
product = malformedHistoricalConstraints.Products["chat"]
|
||||||
|
tool = product.Tools["chat.move"]
|
||||||
|
tool.Constraints = "{"
|
||||||
|
product.Tools["chat.move"] = tool
|
||||||
|
malformedHistoricalConstraints.Products["chat"] = product
|
||||||
|
if _, err := normalizeSchemaCommandMigrations(malformedHistoricalConstraints, current, migrations); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "historical Schema constraints are not canonicalizable") {
|
||||||
|
t.Fatalf("malformed historical constraints error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malformedCurrentConstraints := cloneContract(current)
|
||||||
|
product = malformedCurrentConstraints.Products["chat"]
|
||||||
|
tool = product.Tools["chat.move"]
|
||||||
|
tool.Constraints = "{"
|
||||||
|
product.Tools["chat.move"] = tool
|
||||||
|
malformedCurrentConstraints.Products["chat"] = product
|
||||||
|
if _, err := normalizeSchemaCommandMigrations(baseline, malformedCurrentConstraints, migrations); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "current Schema constraints are not canonicalizable") {
|
||||||
|
t.Fatalf("malformed current constraints error=%v", err)
|
||||||
|
}
|
||||||
|
if source, found := migratedConstraintSourceParameter("{", map[string]string{"legacy": "canonical"}); found || source != "" {
|
||||||
|
t.Fatalf("malformed migrated constraint source = %q, %v", source, found)
|
||||||
|
}
|
||||||
|
|
||||||
extractionWrongSource := cloneContract(current)
|
extractionWrongSource := cloneContract(current)
|
||||||
product = extractionWrongSource.Products["chat"]
|
product = extractionWrongSource.Products["chat"]
|
||||||
tool = product.Tools["chat.create_group"]
|
tool = product.Tools["chat.create_group"]
|
||||||
@@ -657,6 +704,508 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineage(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
stableContract, baseContract, currentContract := schemaCommandLineageContracts()
|
||||||
|
baselinePath := filepath.Join(directory, "stable-schema.json")
|
||||||
|
baseSchemaPath := filepath.Join(directory, "base-schema.json")
|
||||||
|
currentPath := filepath.Join(directory, "current-schema.json")
|
||||||
|
approvedFlagPath := filepath.Join(directory, "approved-flags.json")
|
||||||
|
candidateFlagPath := filepath.Join(directory, "candidate-flags.json")
|
||||||
|
approvedCommandPath := filepath.Join(directory, "approved-commands.json")
|
||||||
|
candidateCommandPath := filepath.Join(directory, "candidate-commands.json")
|
||||||
|
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
|
||||||
|
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
|
||||||
|
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
|
||||||
|
|
||||||
|
writeSchemaContractFile(t, baselinePath, stableContract)
|
||||||
|
writeSchemaContractFile(t, baseSchemaPath, baseContract)
|
||||||
|
writeRawSchemaContractFile(t, currentPath, currentContract)
|
||||||
|
writeFlagMigrationManifestFile(t, approvedFlagPath, schemaCommandLineageFlagManifest())
|
||||||
|
writeFlagMigrationManifestFile(t, candidateFlagPath, schemaCommandLineageFlagManifest())
|
||||||
|
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending))
|
||||||
|
writeCommandMigrationManifestFile(t, candidateCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||||
|
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandLineageSnapshot(true, true))
|
||||||
|
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, false))
|
||||||
|
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandLineageSnapshot(false, false))
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--check", baselinePath,
|
||||||
|
"--current", currentPath,
|
||||||
|
"--migration-base-schema", baseSchemaPath,
|
||||||
|
"--approved-flag-migrations", approvedFlagPath,
|
||||||
|
"--candidate-flag-migrations", candidateFlagPath,
|
||||||
|
"--approved-command-migrations", approvedCommandPath,
|
||||||
|
"--candidate-command-migrations", candidateCommandPath,
|
||||||
|
"--migration-current-snapshot", currentSnapshotPath,
|
||||||
|
"--migration-base-snapshot", baseSnapshotPath,
|
||||||
|
"--migration-stable-snapshot", stableSnapshotPath,
|
||||||
|
}
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
if code := run(args, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("pending command lineage code=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutBaseSchema := append([]string(nil), args[:4]...)
|
||||||
|
withoutBaseSchema = append(withoutBaseSchema, args[6:]...)
|
||||||
|
stderr.Reset()
|
||||||
|
if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 ||
|
||||||
|
!strings.Contains(stderr.String(), "requires --migration-base-schema") {
|
||||||
|
t.Fatalf("missing migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if code := run([]string{
|
||||||
|
"--check", baselinePath,
|
||||||
|
"--current", currentPath,
|
||||||
|
"--migration-base-schema", baseSchemaPath,
|
||||||
|
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") {
|
||||||
|
t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
missingBaseSchema := append([]string(nil), args...)
|
||||||
|
missingBaseSchema[5] = filepath.Join(directory, "missing-base-schema.json")
|
||||||
|
stderr.Reset()
|
||||||
|
if code := run(missingBaseSchema, &stdout, &stderr); code != 2 ||
|
||||||
|
!strings.Contains(stderr.String(), "read migration merge-base Schema contract") {
|
||||||
|
t.Fatalf("unreadable migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the command move is merged, the merge-base Schema is already at the
|
||||||
|
// final name. The two consumed receipts must keep the stable lineage durable
|
||||||
|
// until the stable release itself reaches the after state.
|
||||||
|
writeSchemaContractFile(t, baseSchemaPath, currentContract)
|
||||||
|
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||||
|
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, true))
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if code := run(args, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("consumed command lineage code=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSchemaCommandMigrationLineageFailsClosed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
commandState string
|
||||||
|
mutate func(*schemaContract, *schemaContract, *schemaContract, *[]interfacesnapshot.FlagMigration, *[]interfacesnapshot.CommandMigration)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pending merge-base missing intermediate",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
delete(tool.Parameters, "conversation-id")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "merge-base Schema lacks intermediate parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merge-base missing source tool",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
product := base.Products["chat"]
|
||||||
|
delete(product.Tools, "chat.list_topic_replies")
|
||||||
|
base.Products["chat"] = product
|
||||||
|
},
|
||||||
|
want: "merge-base Schema lacks source tool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending merge-base parameter drift",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "conversation-id", func(parameter *parameterSchema) {
|
||||||
|
parameter.Property = "differentProperty"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "changed a non-migration field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending merge-base wrong path",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.PrimaryCLIPath = "chat unrelated"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "merge-base Schema source tool has primary_cli_path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending merge-base already publishes final",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["open-topic-id"] = tool.Parameters["conversation-id"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "already publishes final parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending flag receipt",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
(*flags)[0].State = interfacesnapshot.FlagMigrationPending
|
||||||
|
},
|
||||||
|
want: "requires a consumed flag migration receipt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flag receipt belongs to another command",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
(*flags)[0].Command = "dws chat message other"
|
||||||
|
},
|
||||||
|
want: `historical Schema tool lacks parameter "conversation-id"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current retains predecessor",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(stable, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "current Schema still publishes predecessor parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending current constraints retain predecessor",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "current Schema constraints still reference predecessor parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed current constraints retain predecessor",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "current Schema constraints still reference predecessor parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current retains intermediate",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["conversation-id"] = base.Products["chat"].Tools["chat.list_topic_replies"].Parameters["conversation-id"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: `still publishes legacy Schema parameter "conversation-id"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed merge-base final drift",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "open-topic-id", func(parameter *parameterSchema) {
|
||||||
|
parameter.Required = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "changed a non-name field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed merge-base wrong path",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.PrimaryCLIPath = "chat unrelated"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "consumed command migration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed merge-base missing final",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
delete(tool.Parameters, "open-topic-id")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "merge-base Schema lacks final parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed merge-base retains intermediate",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["conversation-id"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "still publishes intermediate parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending constraints drift",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = `{"require_one_of":[["conversation-id"]]}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "changed merge-base Schema constraints",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending current keeps intermediate constraints",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = base.Products["chat"].Tools["chat.list_topic_replies"].Constraints
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "still reference legacy Schema constraint parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merge-base retains predecessor",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "merge-base Schema still publishes predecessor parameter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "historical constraints malformed",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = "not-json"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "historical Schema constraints are not canonicalizable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merge-base constraints malformed",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = "not-json"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "merge-base Schema constraints are not canonicalizable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumed constraints drift",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||||
|
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Constraints = `{"require_one_of":[["open-topic-id"]]}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "changed merge-base Schema constraints",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target collision",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["open-topic-id"] = tool.Parameters["group"]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: "target \"open-topic-id\" already exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lineage cycle",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
|
||||||
|
(*commands)[0].Schema.Parameters[0].To = "group"
|
||||||
|
},
|
||||||
|
want: "lineage cycle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported command receipt state",
|
||||||
|
commandState: "unknown",
|
||||||
|
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
},
|
||||||
|
want: "unsupported lineage state",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate historical path",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||||
|
product := stable.Products["chat"]
|
||||||
|
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
|
||||||
|
stable.Products["chat"] = product
|
||||||
|
},
|
||||||
|
want: "matches 2 historical Schema tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "source tool fork",
|
||||||
|
commandState: interfacesnapshot.CommandMigrationPending,
|
||||||
|
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
|
||||||
|
fork := (*commands)[0]
|
||||||
|
fork.Legacy.Command = "dws chat message fork"
|
||||||
|
fork.Replacement.Command = "dws chat topic fork"
|
||||||
|
*commands = append(*commands, fork)
|
||||||
|
},
|
||||||
|
want: "fork Schema source tool",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
stable, baseBefore, current := schemaCommandLineageContracts()
|
||||||
|
base := baseBefore
|
||||||
|
if test.commandState == interfacesnapshot.CommandMigrationConsumed {
|
||||||
|
base = cloneContract(current)
|
||||||
|
}
|
||||||
|
flags := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
|
||||||
|
commands := append([]interfacesnapshot.CommandMigration(nil), schemaCommandLineageManifest(test.commandState).Migrations...)
|
||||||
|
test.mutate(&stable, &base, ¤t, &flags, &commands)
|
||||||
|
if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("lineage error=%v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) {
|
||||||
|
stable, base, current := schemaCommandLineageContracts()
|
||||||
|
mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}}
|
||||||
|
})
|
||||||
|
normalized, err := normalizeSchemaCommandMigrationLineage(
|
||||||
|
stable,
|
||||||
|
base,
|
||||||
|
current,
|
||||||
|
schemaCommandLineageFlagManifest().Migrations,
|
||||||
|
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") {
|
||||||
|
t.Fatalf("lineage hid positional drift: %s", failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple historical names may converge only when every predecessor carries
|
||||||
|
// the exact same contract and every receipt is already consumed.
|
||||||
|
stable, base, current = schemaCommandLineageContracts()
|
||||||
|
mutateSchemaCommandMigrationTool(&stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["id"] = tool.Parameters["group"]
|
||||||
|
})
|
||||||
|
flags := schemaCommandLineageFlagManifest().Migrations
|
||||||
|
second := flags[0]
|
||||||
|
second.Legacy.Name = "id"
|
||||||
|
flags = append(flags, second)
|
||||||
|
normalized, err = normalizeSchemaCommandMigrationLineage(
|
||||||
|
stable,
|
||||||
|
base,
|
||||||
|
current,
|
||||||
|
flags,
|
||||||
|
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("equivalent predecessor aliases should pass: %v", err)
|
||||||
|
}
|
||||||
|
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||||
|
t.Fatalf("equivalent predecessor aliases remained incompatible: %v", failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateSchemaCommandMigrationParameter(&stable, "chat.list_topic_replies", "id", func(parameter *parameterSchema) {
|
||||||
|
parameter.Property = "differentProperty"
|
||||||
|
})
|
||||||
|
if _, err := normalizeSchemaCommandMigrationLineage(
|
||||||
|
stable,
|
||||||
|
base,
|
||||||
|
current,
|
||||||
|
flags,
|
||||||
|
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||||
|
); err == nil || !strings.Contains(err.Error(), "changed a non-migration field") {
|
||||||
|
t.Fatalf("drifted predecessor alias error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSchemaCommandMigrationLineageDefensiveEdges(t *testing.T) {
|
||||||
|
stable, base, current := schemaCommandLineageContracts()
|
||||||
|
flags := schemaCommandLineageFlagManifest().Migrations
|
||||||
|
commands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||||
|
|
||||||
|
flagExtraction := schemaCommandMigrationAuthorizations()[1]
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(stable, base, current, flags, []interfacesnapshot.CommandMigration{flagExtraction}); err != nil {
|
||||||
|
t.Fatalf("non-move command migration should be ignored: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingTool := cloneContract(stable)
|
||||||
|
product := missingTool.Products["chat"]
|
||||||
|
delete(product.Tools, "chat.list_topic_replies")
|
||||||
|
missingTool.Products["chat"] = product
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(missingTool, base, current, flags, commands); err != nil {
|
||||||
|
t.Fatalf("missing historical source should remain for the ordinary checker: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(current, current, current, flags, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed).Migrations); err != nil {
|
||||||
|
t.Fatalf("already-after historical source should be a no-op: %v", err)
|
||||||
|
}
|
||||||
|
wrongPath := cloneContract(stable)
|
||||||
|
mutateSchemaCommandMigrationTool(&wrongPath, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.PrimaryCLIPath = "chat unrelated"
|
||||||
|
})
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(wrongPath, base, current, flags, commands); err != nil {
|
||||||
|
t.Fatalf("wrong historical path should remain for the command normalizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bothNames := cloneContract(stable)
|
||||||
|
mutateSchemaCommandMigrationTool(&bothNames, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["conversation-id"] = tool.Parameters["group"]
|
||||||
|
})
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(bothNames, base, current, flags, commands); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "publishes both predecessor") {
|
||||||
|
t.Fatalf("ambiguous predecessor error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicatePath := cloneContract(stable)
|
||||||
|
product = duplicatePath.Products["chat"]
|
||||||
|
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
|
||||||
|
duplicatePath.Products["chat"] = product
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(duplicatePath, base, current, flags, commands); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "requires one exact historical Schema tool") {
|
||||||
|
t.Fatalf("duplicate primary path error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
forkStable, forkBase, forkCurrent := schemaCommandLineageContracts()
|
||||||
|
mutateSchemaCommandMigrationTool(&forkBase, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
|
||||||
|
})
|
||||||
|
mutateSchemaCommandMigrationTool(&forkCurrent, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["other-final"] = tool.Parameters["open-topic-id"]
|
||||||
|
})
|
||||||
|
forkFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
|
||||||
|
forkFlag := flags[0]
|
||||||
|
forkFlag.Canonical.Name = "other-mid"
|
||||||
|
forkFlags = append(forkFlags, forkFlag)
|
||||||
|
forkCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||||
|
forkCommands[0].Schema.Parameters = append(forkCommands[0].Schema.Parameters,
|
||||||
|
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(forkStable, forkBase, forkCurrent, forkFlags, forkCommands); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "forks Schema predecessor") {
|
||||||
|
t.Fatalf("forked predecessor error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleStable, cycleBase, _ := schemaCommandLineageContracts()
|
||||||
|
mutateSchemaCommandMigrationTool(&cycleStable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["id"] = tool.Parameters["group"]
|
||||||
|
})
|
||||||
|
mutateSchemaCommandMigrationTool(&cycleBase, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
|
||||||
|
})
|
||||||
|
cycleFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
|
||||||
|
cycleFlag := flags[0]
|
||||||
|
cycleFlag.Legacy.Name = "id"
|
||||||
|
cycleFlag.Canonical.Name = "other-mid"
|
||||||
|
cycleFlags = append(cycleFlags, cycleFlag)
|
||||||
|
cycleCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||||
|
cycleCommands[0].Schema.Parameters[0].To = "id"
|
||||||
|
cycleCommands[0].Schema.Parameters = append(cycleCommands[0].Schema.Parameters,
|
||||||
|
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
|
||||||
|
missingCurrent := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}}
|
||||||
|
if _, err := stageSchemaCommandMigrationPredecessors(cycleStable, cycleBase, missingCurrent, cycleFlags, cycleCommands); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "lineage cycle") {
|
||||||
|
t.Fatalf("cross-lineage cycle error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func schemaCommandMigrationContract(after bool) schemaContract {
|
func schemaCommandMigrationContract(after bool) schemaContract {
|
||||||
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
|
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
|
||||||
keep := parameterSchema{Type: `"string"`, Property: "keep"}
|
keep := parameterSchema{Type: `"string"`, Property: "keep"}
|
||||||
@@ -722,6 +1271,155 @@ func schemaCommandMigrationContract(after bool) schemaContract {
|
|||||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
|
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func schemaCommandLineageContracts() (schemaContract, schemaContract, schemaContract) {
|
||||||
|
conversation := parameterSchema{
|
||||||
|
Type: `"string"`,
|
||||||
|
Property: "openconversationId",
|
||||||
|
InterfaceType: "string",
|
||||||
|
Required: true,
|
||||||
|
CLIRequired: true,
|
||||||
|
}
|
||||||
|
topic := parameterSchema{
|
||||||
|
Type: `"string"`,
|
||||||
|
Property: "openConversationThreadId",
|
||||||
|
InterfaceType: "string",
|
||||||
|
Required: true,
|
||||||
|
CLIRequired: true,
|
||||||
|
}
|
||||||
|
tool := toolSchema{
|
||||||
|
PrimaryCLIPath: "chat message list-topic-replies",
|
||||||
|
InterfaceMode: "mcp",
|
||||||
|
InterfaceRef: `{"product_id":"im","rpc_name":"list_topic_replies"}`,
|
||||||
|
Availability: "available",
|
||||||
|
Parameters: map[string]parameterSchema{
|
||||||
|
"group": conversation,
|
||||||
|
"topic-id": topic,
|
||||||
|
},
|
||||||
|
Constraints: `{"require_together":[["group","topic-id"]]}`,
|
||||||
|
Effect: "read",
|
||||||
|
Risk: "low",
|
||||||
|
Confirmation: "not_required",
|
||||||
|
Idempotency: "idempotent",
|
||||||
|
}
|
||||||
|
stable := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||||||
|
"chat": {Tools: map[string]toolSchema{"chat.list_topic_replies": tool}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
base := cloneContract(stable)
|
||||||
|
mutateSchemaCommandMigrationTool(&base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
delete(tool.Parameters, "group")
|
||||||
|
tool.Parameters["conversation-id"] = conversation
|
||||||
|
tool.Constraints = `{"require_together":[["conversation-id","topic-id"]]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
current := cloneContract(base)
|
||||||
|
mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||||
|
delete(tool.Parameters, "conversation-id")
|
||||||
|
delete(tool.Parameters, "topic-id")
|
||||||
|
tool.Parameters["open-topic-id"] = conversation
|
||||||
|
tool.Parameters["open-conv-thread-id"] = topic
|
||||||
|
tool.PrimaryCLIPath = "chat topic list-replies"
|
||||||
|
tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}`
|
||||||
|
})
|
||||||
|
return stable, base, current
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest {
|
||||||
|
beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"}
|
||||||
|
afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}
|
||||||
|
return interfacesnapshot.FlagMigrationManifest{
|
||||||
|
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||||
|
Migrations: []interfacesnapshot.FlagMigration{{
|
||||||
|
Command: "dws chat message list-topic-replies",
|
||||||
|
Legacy: interfacesnapshot.FlagMigrationSide{
|
||||||
|
Name: "group",
|
||||||
|
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||||
|
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"},
|
||||||
|
},
|
||||||
|
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||||
|
Name: "conversation-id",
|
||||||
|
Before: beforeCanonical,
|
||||||
|
After: afterCanonical,
|
||||||
|
},
|
||||||
|
State: interfacesnapshot.FlagMigrationConsumed,
|
||||||
|
Reason: "preserve the reviewed group to conversation-id lineage",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest {
|
||||||
|
return interfacesnapshot.CommandMigrationManifest{
|
||||||
|
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||||
|
Migrations: []interfacesnapshot.CommandMigration{{
|
||||||
|
Kind: interfacesnapshot.CommandMigrationMove,
|
||||||
|
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||||
|
Command: "dws chat message list-topic-replies",
|
||||||
|
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||||
|
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||||
|
},
|
||||||
|
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||||
|
Command: "dws chat topic list-replies",
|
||||||
|
Before: interfacesnapshot.CommandMigrationState{},
|
||||||
|
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||||
|
},
|
||||||
|
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||||
|
ProductID: "chat",
|
||||||
|
SourceToolID: "chat.list_topic_replies",
|
||||||
|
ReplacementToolID: "chat.list_topic_replies",
|
||||||
|
Parameters: []interfacesnapshot.CommandParameterMigration{
|
||||||
|
{From: "conversation-id", To: "open-topic-id"},
|
||||||
|
{From: "topic-id", To: "open-conv-thread-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
State: state,
|
||||||
|
Reason: "move topic reply listing while retaining the legacy command",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot {
|
||||||
|
legacyFlags := []interfacesnapshot.Flag{
|
||||||
|
{Name: "conversation-id", Type: "string", Hidden: true},
|
||||||
|
{Name: "group", Type: "string", Required: true},
|
||||||
|
{Name: "topic-id", Type: "string", Required: true},
|
||||||
|
}
|
||||||
|
if flagAfter {
|
||||||
|
legacyFlags[0].Hidden = false
|
||||||
|
legacyFlags[0].Required = true
|
||||||
|
legacyFlags[1].Required = false
|
||||||
|
legacyFlags[1].Hidden = true
|
||||||
|
legacyFlags[1].AliasOf = "conversation-id"
|
||||||
|
}
|
||||||
|
commands := []interfacesnapshot.Command{
|
||||||
|
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||||
|
{
|
||||||
|
Path: "dws chat message list-topic-replies",
|
||||||
|
Runnable: true,
|
||||||
|
Aliases: []string{},
|
||||||
|
LocalFlags: legacyFlags,
|
||||||
|
InheritedFlags: []interfacesnapshot.Flag{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if commandAfter {
|
||||||
|
commands[1].Hidden = true
|
||||||
|
commands = append(commands, interfacesnapshot.Command{
|
||||||
|
Path: "dws chat topic list-replies",
|
||||||
|
Runnable: true,
|
||||||
|
Aliases: []string{},
|
||||||
|
LocalFlags: []interfacesnapshot.Flag{},
|
||||||
|
InheritedFlags: []interfacesnapshot.Flag{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return interfacesnapshot.Snapshot{
|
||||||
|
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||||
|
Rules: interfacesnapshot.Rules{
|
||||||
|
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
|
||||||
|
ExcludedFlags: []string{"help"},
|
||||||
|
},
|
||||||
|
Commands: commands,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
|
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
|
||||||
return []interfacesnapshot.CommandMigration{
|
return []interfacesnapshot.CommandMigration{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
var normalizePath, checkPath, mergePath, currentPath string
|
var normalizePath, checkPath, mergePath, currentPath string
|
||||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||||
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
||||||
|
var migrationBaseSchemaPath string
|
||||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -98,6 +99,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
||||||
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
|
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
|
||||||
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
|
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
|
||||||
|
flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
|
||||||
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
||||||
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
||||||
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
||||||
@@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
|
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" {
|
||||||
|
fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
|
||||||
|
fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
if normalizePath != "" {
|
if normalizePath != "" {
|
||||||
currentPath = normalizePath
|
currentPath = normalizePath
|
||||||
@@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
var flagMigrations []interfacesnapshot.FlagMigration
|
||||||
if flagMigrationPair {
|
if flagMigrationPair {
|
||||||
migrations, err := authorizeSchemaFlagMigrations(
|
flagMigrations, err = authorizeSchemaFlagMigrations(
|
||||||
approvedFlagMigrationsPath,
|
approvedFlagMigrationsPath,
|
||||||
candidateFlagMigrationsPath,
|
candidateFlagMigrationsPath,
|
||||||
migrationCurrentSnapshotPath,
|
migrationCurrentSnapshotPath,
|
||||||
@@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
|
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if commandMigrationPair {
|
if commandMigrationPair {
|
||||||
migrations, err := authorizeSchemaCommandMigrations(
|
commandMigrations, err := authorizeSchemaCommandMigrations(
|
||||||
approvedCommandMigrationsPath,
|
approvedCommandMigrationsPath,
|
||||||
candidateCommandMigrationsPath,
|
candidateCommandMigrationsPath,
|
||||||
migrationCurrentSnapshotPath,
|
migrationCurrentSnapshotPath,
|
||||||
@@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
|
if flagMigrationPair {
|
||||||
|
migrationBase, readErr := readContract(migrationBaseSchemaPath)
|
||||||
|
if readErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
baseline, err = normalizeSchemaCommandMigrationLineage(
|
||||||
|
baseline,
|
||||||
|
migrationBase,
|
||||||
|
current,
|
||||||
|
flagMigrations,
|
||||||
|
commandMigrations,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
|
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
} else if flagMigrationPair {
|
||||||
|
baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
failures := checkCompatibility(baseline, current)
|
failures := checkCompatibility(baseline, current)
|
||||||
if len(failures) > 0 {
|
if len(failures) > 0 {
|
||||||
@@ -1367,6 +1394,328 @@ func normalizeSchemaFlagMigrations(
|
|||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
|
||||||
|
// migration receipts without inventing a second alias authority. A consumed
|
||||||
|
// flag migration may supply the historical predecessor of a command_move
|
||||||
|
// parameter, but only after the merge-base Schema or a retained consumed
|
||||||
|
// command receipt proves the corresponding next hop.
|
||||||
|
func normalizeSchemaCommandMigrationLineage(
|
||||||
|
historical schemaContract,
|
||||||
|
mergeBase schemaContract,
|
||||||
|
current schemaContract,
|
||||||
|
flagMigrations []interfacesnapshot.FlagMigration,
|
||||||
|
commandMigrations []interfacesnapshot.CommandMigration,
|
||||||
|
) (schemaContract, error) {
|
||||||
|
normalized, err := normalizeSchemaFlagMigrations(historical, current, flagMigrations)
|
||||||
|
if err != nil {
|
||||||
|
return schemaContract{}, fmt.Errorf("normalize ordinary flag migrations: %w", err)
|
||||||
|
}
|
||||||
|
staged, err := stageSchemaCommandMigrationPredecessors(
|
||||||
|
normalized,
|
||||||
|
mergeBase,
|
||||||
|
current,
|
||||||
|
flagMigrations,
|
||||||
|
commandMigrations,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return schemaContract{}, err
|
||||||
|
}
|
||||||
|
return normalizeSchemaCommandMigrations(staged, current, commandMigrations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageSchemaCommandMigrationPredecessors replays only the name-changing edge
|
||||||
|
// recorded by a base-owned consumed flag migration. It leaves interface,
|
||||||
|
// safety, dry-run, and positional facts untouched so the ordinary checker
|
||||||
|
// remains authoritative for every non-name change.
|
||||||
|
func stageSchemaCommandMigrationPredecessors(
|
||||||
|
historical schemaContract,
|
||||||
|
mergeBase schemaContract,
|
||||||
|
current schemaContract,
|
||||||
|
flagMigrations []interfacesnapshot.FlagMigration,
|
||||||
|
commandMigrations []interfacesnapshot.CommandMigration,
|
||||||
|
) (schemaContract, error) {
|
||||||
|
staged := cloneContract(historical)
|
||||||
|
moveBySource := map[schemaToolRef]string{}
|
||||||
|
for _, migration := range commandMigrations {
|
||||||
|
if migration.Kind != interfacesnapshot.CommandMigrationMove {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
|
||||||
|
if previous, exists := moveBySource[ref]; exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migrations fork Schema source tool %q between %q and %q",
|
||||||
|
migration.Schema.SourceToolID,
|
||||||
|
previous,
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moveBySource[ref] = migration.Legacy.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range commandMigrations {
|
||||||
|
if migration.Kind != interfacesnapshot.CommandMigrationMove {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldProduct, productExists := historical.Products[migration.Schema.ProductID]
|
||||||
|
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
|
||||||
|
if !productExists || !toolExists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
|
||||||
|
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
|
||||||
|
if oldTool.PrimaryCLIPath == replacementPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if oldTool.PrimaryCLIPath != legacyPath {
|
||||||
|
// Preserve the existing command normalizer's deterministic path error.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
baseProduct, baseProductExists := mergeBase.Products[migration.Schema.ProductID]
|
||||||
|
baseTool, baseToolExists := baseProduct.Tools[migration.Schema.SourceToolID]
|
||||||
|
currentProduct, currentProductExists := current.Products[migration.Schema.ProductID]
|
||||||
|
currentTool, currentToolExists := currentProduct.Tools[migration.Schema.SourceToolID]
|
||||||
|
firstHopRenames := map[string]string{}
|
||||||
|
composedRenames := map[string]string{}
|
||||||
|
lineageApplied := false
|
||||||
|
|
||||||
|
for _, parameter := range migration.Schema.Parameters {
|
||||||
|
if _, direct := oldTool.Parameters[parameter.From]; direct {
|
||||||
|
for _, flagMigration := range flagMigrations {
|
||||||
|
if flagMigration.Command != migration.Legacy.Command ||
|
||||||
|
flagMigration.Canonical.Name != parameter.From {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, legacyAlsoPublished := oldTool.Parameters[flagMigration.Legacy.Name]; legacyAlsoPublished {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q historical Schema tool publishes both predecessor %q and intermediate %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
flagMigration.Legacy.Name,
|
||||||
|
parameter.From,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composedRenames[parameter.From] = parameter.To
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
|
||||||
|
for _, flagMigration := range flagMigrations {
|
||||||
|
if flagMigration.Command != migration.Legacy.Command ||
|
||||||
|
flagMigration.Canonical.Name != parameter.From {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flagMigration.State != interfacesnapshot.FlagMigrationConsumed {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q Schema predecessor %q -> %q requires a consumed flag migration receipt",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
flagMigration.Legacy.Name,
|
||||||
|
parameter.From,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, published := oldTool.Parameters[flagMigration.Legacy.Name]; published {
|
||||||
|
predecessors = append(predecessors, flagMigration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(predecessors) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lineageApplied = true
|
||||||
|
if !baseProductExists || !baseToolExists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q merge-base Schema lacks source tool %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
migration.Schema.SourceToolID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var stagedParameter parameterSchema
|
||||||
|
switch migration.State {
|
||||||
|
case interfacesnapshot.CommandMigrationPending:
|
||||||
|
if baseTool.PrimaryCLIPath != legacyPath {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"pending command migration %q merge-base Schema source tool has primary_cli_path %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
baseTool.PrimaryCLIPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
intermediate, exists := baseTool.Parameters[parameter.From]
|
||||||
|
if !exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"pending command migration %q merge-base Schema lacks intermediate parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
parameter.From,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := baseTool.Parameters[parameter.To]; exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"pending command migration %q merge-base Schema already publishes final parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
parameter.To,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stagedParameter = intermediate
|
||||||
|
case interfacesnapshot.CommandMigrationConsumed:
|
||||||
|
if baseTool.PrimaryCLIPath != replacementPath {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"consumed command migration %q merge-base Schema source tool has primary_cli_path %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
baseTool.PrimaryCLIPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finalParameter, exists := baseTool.Parameters[parameter.To]
|
||||||
|
if !exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"consumed command migration %q merge-base Schema lacks final parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
parameter.To,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := baseTool.Parameters[parameter.From]; exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"consumed command migration %q merge-base Schema still publishes intermediate parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
parameter.From,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stagedParameter = oldTool.Parameters[predecessors[0].Legacy.Name]
|
||||||
|
for _, predecessor := range predecessors {
|
||||||
|
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
|
||||||
|
composite := interfacesnapshot.CommandParameterMigration{From: predecessor.Legacy.Name, To: parameter.To}
|
||||||
|
if err := validateEquivalentCommandSchemaParameter(migration, composite, oldParameter, finalParameter); err != nil {
|
||||||
|
return schemaContract{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q has unsupported lineage state %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
migration.State,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stagedProduct := staged.Products[migration.Schema.ProductID]
|
||||||
|
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
|
||||||
|
for _, predecessor := range predecessors {
|
||||||
|
if predecessor.Legacy.Name == parameter.To {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q forms a Schema parameter lineage cycle through %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
parameter.To,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if existing, claimed := firstHopRenames[predecessor.Legacy.Name]; claimed && existing != parameter.From {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q forks Schema predecessor %q to both %q and %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
predecessor.Legacy.Name,
|
||||||
|
existing,
|
||||||
|
parameter.From,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
|
||||||
|
if migration.State == interfacesnapshot.CommandMigrationPending {
|
||||||
|
if err := validateRenamedSchemaParameter(predecessor, oldParameter, stagedParameter); err != nil {
|
||||||
|
return schemaContract{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := baseTool.Parameters[predecessor.Legacy.Name]; exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q merge-base Schema still publishes predecessor parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
predecessor.Legacy.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if currentProductExists && currentToolExists {
|
||||||
|
if _, exists := currentTool.Parameters[predecessor.Legacy.Name]; exists {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q current Schema still publishes predecessor parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
predecessor.Legacy.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(stagedTool.Parameters, predecessor.Legacy.Name)
|
||||||
|
firstHopRenames[predecessor.Legacy.Name] = parameter.From
|
||||||
|
composedRenames[predecessor.Legacy.Name] = parameter.To
|
||||||
|
}
|
||||||
|
stagedTool.Parameters[parameter.From] = stagedParameter
|
||||||
|
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
|
||||||
|
staged.Products[migration.Schema.ProductID] = stagedProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lineageApplied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := schemaToolsByPrimaryPath(historical, legacyPath)
|
||||||
|
wantRef := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
|
||||||
|
if len(matches) != 1 || matches[0] != wantRef {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q predecessor lineage requires one exact historical Schema tool, got %#v",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
matches,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if currentProductExists && currentToolExists {
|
||||||
|
if source, found := migratedConstraintSourceParameter(currentTool.Constraints, firstHopRenames); found {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q current Schema constraints still reference predecessor parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, finalTarget := range composedRenames {
|
||||||
|
if _, cycle := firstHopRenames[finalTarget]; cycle {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q forms a Schema parameter lineage cycle through %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
finalTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstHopConstraints, firstHopOK := canonicalizeMigratedConstraints(oldTool.Constraints, firstHopRenames)
|
||||||
|
if !firstHopOK {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q historical Schema constraints are not canonicalizable",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
baseConstraints, baseOK := canonicalizeMigratedConstraints(baseTool.Constraints, nil)
|
||||||
|
if !baseOK {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q merge-base Schema constraints are not canonicalizable",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stagedProduct := staged.Products[migration.Schema.ProductID]
|
||||||
|
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
|
||||||
|
switch migration.State {
|
||||||
|
case interfacesnapshot.CommandMigrationPending:
|
||||||
|
if firstHopConstraints != baseConstraints {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"pending command migration %q predecessor lineage changed merge-base Schema constraints",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stagedTool.Constraints = baseTool.Constraints
|
||||||
|
case interfacesnapshot.CommandMigrationConsumed:
|
||||||
|
composedConstraints, composedOK := canonicalizeMigratedConstraints(oldTool.Constraints, composedRenames)
|
||||||
|
if !composedOK || composedConstraints != baseConstraints {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"consumed command migration %q predecessor lineage changed merge-base Schema constraints",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stagedTool.Constraints = firstHopConstraints
|
||||||
|
}
|
||||||
|
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
|
||||||
|
staged.Products[migration.Schema.ProductID] = stagedProduct
|
||||||
|
}
|
||||||
|
return staged, nil
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeSchemaCommandMigrations projects only the Schema consequences that
|
// normalizeSchemaCommandMigrations projects only the Schema consequences that
|
||||||
// are coupled to an already-authorized CLI command migration. It rewrites a
|
// are coupled to an already-authorized CLI command migration. It rewrites a
|
||||||
// cloned historical contract; the ordinary checker still rejects every field
|
// cloned historical contract; the ordinary checker still rejects every field
|
||||||
@@ -1478,12 +1827,30 @@ func normalizeSchemaCommandMigrations(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if oldTool.Constraints != newSource.Constraints {
|
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
if !oldOK {
|
||||||
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
return schemaContract{}, fmt.Errorf(
|
||||||
if oldOK && newOK && oldConstraints == newConstraints {
|
"approved command migration %q historical Schema constraints are not canonicalizable",
|
||||||
normalizedTool.Constraints = newSource.Constraints
|
migration.Legacy.Command,
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
||||||
|
if !newOK {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q current Schema constraints are not canonicalizable",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if source, found := migratedConstraintSourceParameter(newSource.Constraints, renames); found {
|
||||||
|
return schemaContract{}, fmt.Errorf(
|
||||||
|
"approved command migration %q current constraints still reference legacy Schema constraint parameter %q",
|
||||||
|
migration.Legacy.Command,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
normalizedTool.Constraints = oldConstraints
|
||||||
|
if oldConstraints == newConstraints {
|
||||||
|
normalizedTool.Constraints = newSource.Constraints
|
||||||
}
|
}
|
||||||
normalizedTool.PrimaryCLIPath = replacementPath
|
normalizedTool.PrimaryCLIPath = replacementPath
|
||||||
|
|
||||||
@@ -1938,6 +2305,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str
|
|||||||
return string(encoded), err == nil
|
return string(encoded), err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migratedConstraintSourceParameter(raw string, renames map[string]string) (string, bool) {
|
||||||
|
groups, ok := parseMigrationConstraintsStrict(raw)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||||
|
for _, group := range groups[kind] {
|
||||||
|
for _, member := range group {
|
||||||
|
if _, renamed := renames[member]; renamed {
|
||||||
|
return member, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
|
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -173,8 +173,9 @@ func main() {
|
|||||||
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
||||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||||
_ = flag.String("approved-command-migrations", "", "base command ledger")
|
migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
|
||||||
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
|
approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
|
||||||
|
candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *normalize != "" {
|
if *normalize != "" {
|
||||||
@@ -214,6 +215,29 @@ func main() {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
commandPair := *approvedCommand != "" || *candidateCommand != ""
|
||||||
|
if commandPair && (*approvedCommand == "" || *candidateCommand == "") {
|
||||||
|
fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if commandPair {
|
||||||
|
data, err := os.ReadFile(*migrationBaseSchema)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "BASE_AUTHORITY") {
|
||||||
|
fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
|
||||||
|
} else {
|
||||||
|
if *migrationBaseSchema != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED")
|
||||||
|
}
|
||||||
currentData, err := os.ReadFile(*current)
|
currentData, err := os.ReadFile(*current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
@@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
|
|||||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "governed checker protects stable Schema contract",
|
name: "governed checker protects stable Schema contract",
|
||||||
|
baseGovernance: "complete",
|
||||||
|
commandGovernance: "complete",
|
||||||
|
checkerMode: "stable-schema-guard",
|
||||||
|
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lineage capable checker omits command lineage for flag-only governance",
|
||||||
baseGovernance: "complete",
|
baseGovernance: "complete",
|
||||||
checkerMode: "stable-schema-guard",
|
checkerMode: "flag-only-schema-guard",
|
||||||
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
|||||||
switch test.baseGovernance {
|
switch test.baseGovernance {
|
||||||
case "complete":
|
case "complete":
|
||||||
checkerSource := governedSchemaCheckerSource
|
checkerSource := governedSchemaCheckerSource
|
||||||
if test.checkerMode == "stable-schema-guard" {
|
if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" {
|
||||||
checkerSource = stableSchemaGuardCheckerSource
|
checkerSource = stableSchemaGuardCheckerSource
|
||||||
}
|
}
|
||||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
|
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
|
||||||
@@ -794,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
|||||||
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
|
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
|
||||||
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
|
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
|
||||||
}
|
}
|
||||||
|
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
|
||||||
|
t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
|
||||||
|
}
|
||||||
|
if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
|
||||||
|
t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
wrongStable := exec.Command(
|
wrongStable := exec.Command(
|
||||||
"sh",
|
"sh",
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Release compatibility is one decision seam over the sealed source tree.
|
||||||
|
# Trusted release tooling may orchestrate this script, but the explicitly
|
||||||
|
# selected repository remains the authority for both CLI and Schema checks.
|
||||||
|
|
||||||
|
REPO_ROOT=""
|
||||||
|
BASE_REF=""
|
||||||
|
STABLE_REF=""
|
||||||
|
CANDIDATE_REF="HEAD"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' \
|
||||||
|
"usage: $0 --repo-root <path> --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo-root)
|
||||||
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||||
|
REPO_ROOT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--base-ref)
|
||||||
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||||
|
BASE_REF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stable-ref)
|
||||||
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||||
|
STABLE_REF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--candidate-ref)
|
||||||
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||||
|
CANDIDATE_REF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'error: unknown argument: %s\n' "$1" >&2
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || {
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || {
|
||||||
|
printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
|
||||||
|
printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[ "$GIT_ROOT" = "$REPO_ROOT" ] || {
|
||||||
|
printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh"
|
||||||
|
SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh"
|
||||||
|
for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do
|
||||||
|
[ -x "$check" ] || {
|
||||||
|
printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
resolve_commit() {
|
||||||
|
label="$1"
|
||||||
|
ref="$2"
|
||||||
|
commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || {
|
||||||
|
printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
[ -n "$commit" ] || {
|
||||||
|
printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
printf '%s\n' "$commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Freeze the complete comparison tuple before either checker runs. Otherwise a
|
||||||
|
# concurrent ref update (or the first checker itself) could make Schema inspect
|
||||||
|
# different Git objects from the CLI gate even when both receive the same names.
|
||||||
|
BASE_COMMIT="$(resolve_commit base "$BASE_REF")"
|
||||||
|
STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")"
|
||||||
|
CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")"
|
||||||
|
|
||||||
|
printf '==> Checking authoritative CLI compatibility\n'
|
||||||
|
if "$CLI_CHECK" \
|
||||||
|
--base-ref "$BASE_COMMIT" \
|
||||||
|
--stable-ref "$STABLE_COMMIT" \
|
||||||
|
--candidate-ref "$CANDIDATE_COMMIT"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
printf 'error: authoritative CLI compatibility failed\n' >&2
|
||||||
|
exit "$status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '==> Checking authoritative Schema compatibility\n'
|
||||||
|
if "$SCHEMA_CHECK" \
|
||||||
|
--base-ref "$BASE_COMMIT" \
|
||||||
|
--stable-ref "$STABLE_COMMIT" \
|
||||||
|
--candidate-ref "$CANDIDATE_COMMIT"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
printf 'error: authoritative Schema compatibility failed\n' >&2
|
||||||
|
exit "$status"
|
||||||
|
fi
|
||||||
@@ -19,7 +19,7 @@ usage() {
|
|||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
usage: release.sh <prerelease|stable> <version> [options]
|
usage: release.sh <prerelease|stable> <version> [options]
|
||||||
|
|
||||||
Runs the full test, command-compatibility, package, and install preflight.
|
Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight.
|
||||||
The default is validation only. Official publication is cloud-only.
|
The default is validation only. Official publication is cloud-only.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -359,10 +359,12 @@ else
|
|||||||
make policy
|
make policy
|
||||||
|
|
||||||
if [ -n "$previous_stable" ]; then
|
if [ -n "$previous_stable" ]; then
|
||||||
printf '==> Comparing command tree with %s\n' "$previous_stable"
|
printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable"
|
||||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
"$SCRIPT_DIR/check-release-compatibility.sh" \
|
||||||
|
--repo-root "$ROOT" \
|
||||||
--base-ref HEAD \
|
--base-ref HEAD \
|
||||||
--stable-ref "$previous_stable"
|
--stable-ref "$previous_stable" \
|
||||||
|
--candidate-ref HEAD
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '==> Building local release artifacts for %s\n' "$VERSION"
|
printf '==> Building local release artifacts for %s\n' "$VERSION"
|
||||||
@@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
|
|||||||
require_delivered_previous_stable
|
require_delivered_previous_stable
|
||||||
|
|
||||||
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
|
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
|
||||||
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
|
printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \
|
||||||
"${previous_stable_before_refresh:-none}" "$previous_stable"
|
"${previous_stable_before_refresh:-none}" "$previous_stable"
|
||||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
"$SCRIPT_DIR/check-release-compatibility.sh" \
|
||||||
|
--repo-root "$ROOT" \
|
||||||
--base-ref HEAD \
|
--base-ref HEAD \
|
||||||
--stable-ref "$previous_stable"
|
--stable-ref "$previous_stable" \
|
||||||
|
--candidate-ref HEAD
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$PUBLISH" -eq 1 ]; then
|
if [ "$PUBLISH" -eq 1 ]; then
|
||||||
|
|||||||
@@ -1303,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
|||||||
"- e2e",
|
"- e2e",
|
||||||
"verify-github-tag-authority.sh",
|
"verify-github-tag-authority.sh",
|
||||||
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
||||||
"check-command-compatibility.sh",
|
`tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh`,
|
||||||
|
`--repo-root "$GITHUB_WORKSPACE"`,
|
||||||
|
`--base-ref HEAD`,
|
||||||
|
`--stable-ref "$PREVIOUS_STABLE"`,
|
||||||
|
`--candidate-ref HEAD`,
|
||||||
"test-multi-profile-e2e.sh",
|
"test-multi-profile-e2e.sh",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(validation, required) {
|
if !strings.Contains(validation, required) {
|
||||||
t.Errorf("parallel release validation is missing %q", required)
|
t.Errorf("parallel release validation is missing %q", required)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"./scripts/policy/check-command-compatibility.sh",
|
||||||
|
"./scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||||
|
} {
|
||||||
|
if strings.Contains(validation, forbidden) {
|
||||||
|
t.Errorf("parallel release validation bypasses the shared compatibility runner with %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if strings.Contains(build, "test-multi-profile-e2e.sh") {
|
if strings.Contains(build, "test-multi-profile-e2e.sh") {
|
||||||
t.Error("multi-profile E2E must not serialize GoReleaser")
|
t.Error("multi-profile E2E must not serialize GoReleaser")
|
||||||
@@ -1379,7 +1391,7 @@ func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *t
|
|||||||
|
|
||||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
compatibility := strings.Index(validation, "tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh")
|
||||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type releaseTestRepo struct {
|
|||||||
prepare string
|
prepare string
|
||||||
render string
|
render string
|
||||||
releaseCmd string
|
releaseCmd string
|
||||||
|
compat string
|
||||||
lib string
|
lib string
|
||||||
verify string
|
verify string
|
||||||
}
|
}
|
||||||
@@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
|||||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||||
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
||||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||||
|
compat: filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh"),
|
||||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedReleaseCompatibilityRefs(t *testing.T, repo string) (stable, base, candidate string) {
|
||||||
|
t.Helper()
|
||||||
|
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||||
|
mustRun(t, repo, "git", "config", "user.email", "release-compatibility-test@example.com")
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("stable\n"), 0o644)
|
||||||
|
mustRun(t, repo, "git", "add", "seed.txt")
|
||||||
|
mustRun(t, repo, "git", "commit", "-m", "stable")
|
||||||
|
mustRun(t, repo, "git", "tag", "stable-ref")
|
||||||
|
stable = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("base\n"), 0o644)
|
||||||
|
mustRun(t, repo, "git", "add", "seed.txt")
|
||||||
|
mustRun(t, repo, "git", "commit", "-m", "base")
|
||||||
|
mustRun(t, repo, "git", "branch", "base-ref")
|
||||||
|
base = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("candidate\n"), 0o644)
|
||||||
|
mustRun(t, repo, "git", "add", "seed.txt")
|
||||||
|
mustRun(t, repo, "git", "commit", "-m", "candidate")
|
||||||
|
mustRun(t, repo, "git", "branch", "candidate-ref")
|
||||||
|
candidate = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||||
|
return stable, base, candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCompatibilityCheckRunsCLIAndSchemaWithExactRefs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
|
}
|
||||||
|
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||||
|
repo := t.TempDir()
|
||||||
|
mustRun(t, repo, "git", "init", "-b", "main")
|
||||||
|
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||||
|
|
||||||
|
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||||
|
checker := func(name string) []byte {
|
||||||
|
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + ":%s\\n' \"$*\" >> \"$TRACE\"\n")
|
||||||
|
}
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), checker("cli"), 0o755)
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), checker("schema"), 0o755)
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
runner,
|
||||||
|
"--repo-root", repo,
|
||||||
|
"--base-ref", "base-ref",
|
||||||
|
"--stable-ref", "stable-ref",
|
||||||
|
"--candidate-ref", "candidate-ref",
|
||||||
|
)
|
||||||
|
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(trace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("cli:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) +
|
||||||
|
fmt.Sprintf("schema:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate)
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("release compatibility calls = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCompatibilityCheckFreezesRefsBeforeRunningEitherChecker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
|
}
|
||||||
|
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||||
|
repo := t.TempDir()
|
||||||
|
mustRun(t, repo, "git", "init", "-b", "main")
|
||||||
|
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||||
|
|
||||||
|
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||||
|
resolveAndLog := `
|
||||||
|
base="$2"
|
||||||
|
stable="$4"
|
||||||
|
candidate="$6"
|
||||||
|
printf '%s:%s:%s:%s\n' "$CHECK_NAME" \
|
||||||
|
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${base}^{commit}")" \
|
||||||
|
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${stable}^{commit}")" \
|
||||||
|
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${candidate}^{commit}")" >> "$TRACE"
|
||||||
|
`
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=cli\n"+resolveAndLog+"git -C \"$REPO_ROOT_FOR_TEST\" update-ref refs/heads/base-ref \"$MUTATE_TO\"\n"), 0o755)
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=schema\n"+resolveAndLog), 0o755)
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
runner,
|
||||||
|
"--repo-root", repo,
|
||||||
|
"--base-ref", "base-ref",
|
||||||
|
"--stable-ref", "stable-ref",
|
||||||
|
"--candidate-ref", "candidate-ref",
|
||||||
|
)
|
||||||
|
cmd.Env = append(os.Environ(), "TRACE="+trace, "REPO_ROOT_FOR_TEST="+repo, "MUTATE_TO="+candidate)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(trace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("cli:%s:%s:%s\nschema:%s:%s:%s\n", base, stable, candidate, base, stable, candidate)
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("release compatibility resolved commits = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if moved := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "base-ref^{commit}")); moved != candidate {
|
||||||
|
t.Fatalf("mutating checker did not move base-ref: got %s, want %s", moved, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCompatibilityCheckStopsBeforeSchemaWhenCLIFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
|
}
|
||||||
|
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||||
|
repo := t.TempDir()
|
||||||
|
mustRun(t, repo, "git", "init", "-b", "main")
|
||||||
|
seedReleaseCompatibilityRefs(t, repo)
|
||||||
|
|
||||||
|
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\nexit 17\n"), 0o755)
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\n"), 0o755)
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
runner,
|
||||||
|
"--repo-root", repo,
|
||||||
|
"--base-ref", "base-ref",
|
||||||
|
"--stable-ref", "stable-ref",
|
||||||
|
"--candidate-ref", "candidate-ref",
|
||||||
|
)
|
||||||
|
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
exitErr, ok := err.(*exec.ExitError)
|
||||||
|
if !ok || exitErr.ExitCode() != 17 {
|
||||||
|
t.Fatalf("release compatibility CLI failure = %v, want exit 17\noutput:\n%s", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "error: authoritative CLI compatibility failed") {
|
||||||
|
t.Fatalf("release compatibility CLI failure has no boundary message:\n%s", output)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(trace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||||
|
}
|
||||||
|
if string(got) != "cli\n" {
|
||||||
|
t.Fatalf("checks after CLI failure = %q, want only CLI", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCompatibilityCheckFailsWhenSchemaFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
|
}
|
||||||
|
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||||
|
repo := t.TempDir()
|
||||||
|
mustRun(t, repo, "git", "init", "-b", "main")
|
||||||
|
seedReleaseCompatibilityRefs(t, repo)
|
||||||
|
|
||||||
|
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\n"), 0o755)
|
||||||
|
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\nexit 19\n"), 0o755)
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
runner,
|
||||||
|
"--repo-root", repo,
|
||||||
|
"--base-ref", "base-ref",
|
||||||
|
"--stable-ref", "stable-ref",
|
||||||
|
"--candidate-ref", "candidate-ref",
|
||||||
|
)
|
||||||
|
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
exitErr, ok := err.(*exec.ExitError)
|
||||||
|
if !ok || exitErr.ExitCode() != 19 {
|
||||||
|
t.Fatalf("release compatibility Schema failure = %v, want exit 19\noutput:\n%s", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "error: authoritative Schema compatibility failed") {
|
||||||
|
t.Fatalf("release compatibility Schema failure has no boundary message:\n%s", output)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(trace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||||
|
}
|
||||||
|
if string(got) != "cli\nschema\n" {
|
||||||
|
t.Fatalf("checks before Schema failure = %q, want CLI then Schema", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReleaseVersionOrdering(t *testing.T) {
|
func TestReleaseVersionOrdering(t *testing.T) {
|
||||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2631,12 +2827,35 @@ func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseCommandRunsCLIAndSchemaCompatibilityInInitialPreflight(t *testing.T) {
|
||||||
|
r := newReleaseTestRepo(t)
|
||||||
|
installReleaseCommandFixture(t, r)
|
||||||
|
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||||
|
r.commitAndPush(t, "install release compatibility fixture")
|
||||||
|
|
||||||
|
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||||
|
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||||
|
}
|
||||||
|
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||||
|
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||||
|
for _, want := range []string{
|
||||||
|
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||||
|
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Errorf("initial release preflight is missing %q\noutput:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||||
r := newReleaseTestRepo(t)
|
r := newReleaseTestRepo(t)
|
||||||
installReleaseCommandFixture(t, r)
|
installReleaseCommandFixture(t, r)
|
||||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
|
||||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||||
r.commitAndPush(t, "install advancing release fixture")
|
r.commitAndPush(t, "install advancing release fixture")
|
||||||
|
|
||||||
@@ -2646,20 +2865,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
|
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||||
!strings.Contains(output, "--stable-ref v1.0.1") {
|
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.1^{commit}"))
|
||||||
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
|
for _, want := range []string{
|
||||||
|
"Stable authority advanced from v1.0.0 to v1.0.1",
|
||||||
|
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||||
|
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Errorf("advanced stable authority recheck is missing %q\noutput:\n%s", want, output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
for _, source := range []string{r.lib, r.contract, r.releaseCmd, r.compat} {
|
||||||
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
||||||
}
|
}
|
||||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'cli-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||||
|
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'schema-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user