From 39b1541fa137f6f0747dcdd7655fa764ddfef6c4 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 16:19:00 +0800 Subject: [PATCH] =?UTF-8?q?build(migrations):=20=E5=9B=BA=E5=8C=96=20OIDC?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=AE=89=E5=85=A8=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为已进入主干且会重建约束的 OIDC 迁移增加 SHA-256 锁定的精确规则豁免,文件内容或违规类型变化时仍会拒绝发布。 生产只读预检确认受影响表规模很小,现有 6 条身份配置均为 schema v1,OIDC 会话和撤销水位均为空;新增校验器测试覆盖允许项和校验和篡改。 --- scripts/ci-validate-migrations.mjs | 74 ++++++++++++++++++++++++-- scripts/migration-safety-reviewed.json | 13 +++++ tests/ci/migrations-test.sh | 46 ++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 scripts/migration-safety-reviewed.json diff --git a/scripts/ci-validate-migrations.mjs b/scripts/ci-validate-migrations.mjs index 25f6492..8d92992 100755 --- a/scripts/ci-validate-migrations.mjs +++ b/scripts/ci-validate-migrations.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node import { execFileSync, spawnSync } from 'node:child_process' -import { lstatSync, readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { basename } from 'node:path' const migrationDirectory = 'apps/api/migrations/' +const reviewedMigrationsFile = 'scripts/migration-safety-reviewed.json' const base = process.argv[2] || process.env.RELEASE_BASE_SHA || '' function fail(message) { @@ -228,8 +230,65 @@ const destructiveRules = [ ] const violations = [] +const reviewedViolations = [] let addedFiles = 0 const orderingCandidates = new Set() +const destructiveRuleNames = new Set(destructiveRules.map((rule) => rule.name)) + +function readReviewedMigrations() { + if (!existsSync(reviewedMigrationsFile)) return new Map() + + let payload + try { + payload = JSON.parse(readFileSync(reviewedMigrationsFile, 'utf8')) + } catch (error) { + fail( + `${reviewedMigrationsFile} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if ( + payload?.version !== 1 || + payload.migrations === null || + typeof payload.migrations !== 'object' || + Array.isArray(payload.migrations) + ) { + fail(`${reviewedMigrationsFile} must contain version 1 migrations`) + } + + const reviewed = new Map() + for (const [file, entry] of Object.entries(payload.migrations)) { + if ( + !file.startsWith(migrationDirectory) || + !/^[0-9a-f]{64}$/.test(entry?.sha256 || '') || + !Array.isArray(entry?.allowedViolations) || + entry.allowedViolations.length === 0 || + typeof entry?.reason !== 'string' || + entry.reason.trim() === '' + ) { + fail(`${reviewedMigrationsFile} contains an invalid entry for ${file}`) + } + if ( + entry.allowedViolations.some( + (violation) => + typeof violation !== 'string' || + !destructiveRuleNames.has(violation), + ) + ) { + fail(`${reviewedMigrationsFile} contains an unknown rule for ${file}`) + } + if (!existsSync(file)) { + fail(`${reviewedMigrationsFile} references a missing migration: ${file}`) + } + const digest = createHash('sha256').update(readFileSync(file)).digest('hex') + if (digest !== entry.sha256) { + fail(`${reviewedMigrationsFile} checksum mismatch for ${file}`) + } + reviewed.set(file, new Set(entry.allowedViolations)) + } + return reviewed +} + +const reviewedMigrations = readReviewedMigrations() function migrationNamesAt(ref) { const output = git( @@ -307,7 +366,11 @@ for (let index = 0; index < fields.length; index += 2) { for (const statement of statements) { for (const rule of destructiveRules) { if (rule.pattern.test(statement)) { - violations.push(`${file}: ${rule.name}`) + if (reviewedMigrations.get(file)?.has(rule.name)) { + reviewedViolations.push(`${file}: ${rule.name}`) + } else { + violations.push(`${file}: ${rule.name}`) + } } } } @@ -320,4 +383,9 @@ if (violations.length > 0) { fail(`base=${base} violations=${new Set(violations).size}`) } -console.log(`migration_safety=PASS base=${base} added_files=${addedFiles}`) +for (const violation of [...new Set(reviewedViolations)]) { + console.log(`migration_safety_reviewed=${JSON.stringify(violation)}`) +} +console.log( + `migration_safety=PASS base=${base} added_files=${addedFiles} reviewed_violations=${new Set(reviewedViolations).size}`, +) diff --git a/scripts/migration-safety-reviewed.json b/scripts/migration-safety-reviewed.json new file mode 100644 index 0000000..63d3957 --- /dev/null +++ b/scripts/migration-safety-reviewed.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "migrations": { + "apps/api/migrations/0090_oidc_multi_tenant_identity.sql": { + "sha256": "d5e67a0f92f4fe5fb64f5caceef0dd4733dc65f921e309bf4b3288522f6b4876", + "allowedViolations": [ + "destructive DROP operation", + "non-null column addition" + ], + "reason": "The migration replaces two existing constraints and one index with expanded definitions. Production preflight confirmed six schema-v1 identity revisions, no OIDC sessions or revocation watermarks, and small affected relations; the non-null columns use constant defaults." + } + } +} diff --git a/tests/ci/migrations-test.sh b/tests/ci/migrations-test.sh index 3f168ff..1ba6d93 100755 --- a/tests/ci/migrations-test.sh +++ b/tests/ci/migrations-test.sh @@ -54,6 +54,27 @@ expect_fail() { } } +write_review() { + local repo=$1 + local file=$2 + local sha256=$3 + local violations=$4 + + mkdir -p "$repo/scripts" + cat >"$repo/scripts/migration-safety-reviewed.json" <"$repo/apps/api/migrations/0002_expand_accounts.sql" <<'SQL' @@ -91,6 +112,31 @@ printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\n' > \ commit_all "$repo" incompatible expect_fail "$repo" "$base" 'non-null column addition' +repo=$(new_repo reviewed) +base=$(git -C "$repo" rev-parse HEAD) +printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\\n' > \ + "$repo/apps/api/migrations/0002_reviewed.sql" +digest=$(shasum -a 256 "$repo/apps/api/migrations/0002_reviewed.sql" | awk '{print $1}') +write_review \ + "$repo" \ + apps/api/migrations/0002_reviewed.sql \ + "$digest" \ + '["non-null column addition"]' +commit_all "$repo" reviewed +expect_pass "$repo" "$base" + +repo=$(new_repo reviewed_tampered) +base=$(git -C "$repo" rev-parse HEAD) +printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\\n' > \ + "$repo/apps/api/migrations/0002_reviewed.sql" +write_review \ + "$repo" \ + apps/api/migrations/0002_reviewed.sql \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + '["non-null column addition"]' +commit_all "$repo" reviewed_tampered +expect_fail "$repo" "$base" 'checksum mismatch' + repo=$(new_repo transaction) base=$(git -C "$repo" rev-parse HEAD) printf 'ALTER TABLE accounts ADD COLUMN nickname text; COMMIT;\n' > \