diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ee47b14..774495a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2698,9 +2698,11 @@ jobs: ;; compatibility) 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 \ - --stable-ref "$PREVIOUS_STABLE" + --stable-ref "$PREVIOUS_STABLE" \ + --candidate-ref HEAD ;; e2e) bash scripts/dev/test-multi-profile-e2e.sh diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 60c5b684..81477cd5 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。 `replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即 `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 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、 值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create` diff --git a/docs/releasing.md b/docs/releasing.md index 428cdbcc..c8d6c702 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 被再次触发。 -为缩短封板前后的关键路径,`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 证明。 @@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 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//` - 只接受 `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` 历史中。 -- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 +- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 - GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。 - 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、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`,不会覆盖稳定入口。 diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 45441cfd..f7a5b5b3 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json" 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" HOME="$TMP_ROOT/base-home" DWS_LANG=zh \ "$BASE_BIN" schema --all --format json >"$BASE_RAW" @@ -468,23 +473,22 @@ check_with_migrations() { historical_kind="$1" historical_ref="$2" 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 - check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + set -- "$@" \ --approved-command-migrations "$APPROVED_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" + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" 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" diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index ada5dcc7..5c6f32fb 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -233,6 +233,53 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin 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) product = extractionWrongSource.Products["chat"] 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 { id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} 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}}} } +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 { return []interfacesnapshot.CommandMigration{ { diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 7496d4f3..9aa151cc 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string var approvedFlagMigrationsPath, candidateFlagMigrationsPath string var approvedCommandMigrationsPath, candidateCommandMigrationsPath string + var migrationBaseSchemaPath string var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) 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(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved 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(&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") @@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check") 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 != "" { currentPath = normalizePath @@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } + var flagMigrations []interfacesnapshot.FlagMigration if flagMigrationPair { - migrations, err := authorizeSchemaFlagMigrations( + flagMigrations, err = authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, migrationCurrentSnapshotPath, @@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err) 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 { - migrations, err := authorizeSchemaCommandMigrations( + commandMigrations, err := authorizeSchemaCommandMigrations( approvedCommandMigrationsPath, candidateCommandMigrationsPath, migrationCurrentSnapshotPath, @@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) 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 { fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err) 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) if len(failures) > 0 { @@ -1367,6 +1394,328 @@ func normalizeSchemaFlagMigrations( 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 // are coupled to an already-authorized CLI command migration. It rewrites a // 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) - newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil) - if oldOK && newOK && oldConstraints == newConstraints { - normalizedTool.Constraints = newSource.Constraints - } + oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + if !oldOK { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema constraints are not canonicalizable", + 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 @@ -1938,6 +2305,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str 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) { trimmed := strings.TrimSpace(raw) if trimmed == "" { diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index be8857f4..9346e1c1 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -173,8 +173,9 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") - _ = flag.String("approved-command-migrations", "", "base command ledger") - _ = flag.String("candidate-command-migrations", "", "candidate command ledger") + migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract") + approvedCommand := flag.String("approved-command-migrations", "", "base command ledger") + candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -214,6 +215,29 @@ func main() { 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) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi 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", - checkerMode: "stable-schema-guard", + checkerMode: "flag-only-schema-guard", authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED", }, { @@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { switch test.baseGovernance { case "complete": checkerSource := governedSchemaCheckerSource - if test.checkerMode == "stable-schema-guard" { + if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" { checkerSource = stableSchemaGuardCheckerSource } 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 { 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( "sh", diff --git a/scripts/release/check-release-compatibility.sh b/scripts/release/check-release-compatibility.sh new file mode 100755 index 00000000..290826bb --- /dev/null +++ b/scripts/release/check-release-compatibility.sh @@ -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 --base-ref --stable-ref [--candidate-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 diff --git a/scripts/release/release.sh b/scripts/release/release.sh index bb7dea1f..eb44b5a5 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -19,7 +19,7 @@ usage() { cat >&2 <<'EOF' usage: release.sh [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. Options: @@ -359,10 +359,12 @@ else make policy if [ -n "$previous_stable" ]; then - printf '==> Comparing command tree with %s\n' "$previous_stable" - "$ROOT/scripts/policy/check-command-compatibility.sh" \ + printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable" + "$SCRIPT_DIR/check-release-compatibility.sh" \ + --repo-root "$ROOT" \ --base-ref HEAD \ - --stable-ref "$previous_stable" + --stable-ref "$previous_stable" \ + --candidate-ref HEAD fi 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 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" - "$ROOT/scripts/policy/check-command-compatibility.sh" \ + "$SCRIPT_DIR/check-release-compatibility.sh" \ + --repo-root "$ROOT" \ --base-ref HEAD \ - --stable-ref "$previous_stable" + --stable-ref "$previous_stable" \ + --candidate-ref HEAD fi if [ "$PUBLISH" -eq 1 ]; then diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 46155da0..73798dcd 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -1303,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication( "- e2e", "verify-github-tag-authority.sh", "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", } { if !strings.Contains(validation, 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") { 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") 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 || verifiedTag > deleteLocalTag || deleteLocalTag > compatibility { t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered") diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 6ad03b77..7c8e98cc 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -75,6 +75,7 @@ type releaseTestRepo struct { prepare string render string releaseCmd string + compat string lib string verify string } @@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo { prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"), render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.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"), 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) { sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) 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) { r := newReleaseTestRepo(t) 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" 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) r.commitAndPush(t, "install advancing release fixture") @@ -2646,20 +2865,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) { if err != nil { 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") || - !strings.Contains(output, "--stable-ref v1.0.1") { - t.Fatalf("advanced stable command tree was not rechecked:\n%s", 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.1^{commit}")) + 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) { 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) } 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", "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) }