From 69953f20c0c368dffb64d68ecbb46cb2519fb9c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:42:49 +0000 Subject: [PATCH] Migration readiness: name the error class and stage for non-rule failures The rule names from the last change never appeared for #4556, so its failure is a Git or API error, not one of our own refusals. The log now prints only the error class, code, exit status and a fixed stage label (git- or github-api). Messages, stderr, command arguments and provider bodies stay out of the log. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012BMmZWrkFxXJddyVkAXF4c --- scripts/migration-pr-readiness.mjs | 8 +++++--- scripts/publish-migration-readiness.mjs | 9 ++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/migration-pr-readiness.mjs b/scripts/migration-pr-readiness.mjs index d7ab27fc67..37461d1b36 100644 --- a/scripts/migration-pr-readiness.mjs +++ b/scripts/migration-pr-readiness.mjs @@ -213,8 +213,10 @@ async function main() { } const collectorArgs = { env, collectorSha256: hashBytes(readFileSync(process.argv[1])), event: JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')), - git: (args, binary = false, input) => execFileSync('git', ['--no-replace-objects', ...args], { input, encoding: binary ? undefined : 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }), - api: (path, options) => request(`https://api.github.com${path}`, env.GITHUB_TOKEN, options), + git: (args, binary = false, input) => { + try { return execFileSync('git', ['--no-replace-objects', ...args], { input, encoding: binary ? undefined : 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }) } catch (error) { throw Object.assign(error, { stage: `git-${/^[a-z-]{1,20}$/.test(args[0]) ? args[0] : 'cmd'}` }) } + }, + api: async (path, options) => { try { return await request(`https://api.github.com${path}`, env.GITHUB_TOKEN, options) } catch (error) { throw error?.safe ? error : Object.assign(error, { stage: 'github-api' }) } }, ledger: async () => { requireFact(env.SUPABASE_ACCESS_TOKEN && /^[a-z0-9]+$/.test(env.SUPABASE_PROJECT_REF ?? ''), 'live comparison credentials required') const payload = await request(`https://api.supabase.com/v1/projects/${env.SUPABASE_PROJECT_REF}/database/query`, env.SUPABASE_ACCESS_TOKEN, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: 'select version, name from supabase_migrations.schema_migrations order by version' }) }) @@ -230,7 +232,7 @@ async function main() { console.log(`Migration PR staging VERIFIED; ${result.pending.length} exact registered migration(s) PENDING APPLY. Production was NOT changed. Main remains strict.`) } if (invokedDirectly(import.meta.url)) main().catch(error => { - if (error?.safe === true) console.error(`Migration readiness rule refused: ${error.message}`) + import('./publish-migration-readiness.mjs').then(({ describeFailure }) => console.error(`Migration readiness ${describeFailure(error)}`)).catch(() => {}) // No API body, token, SQL, state or Git command arguments are copied into CI diagnostics. console.error('Migration PR readiness failed closed; registration, current base/ledger or exact-head staging proof was not satisfied.') process.exitCode = 1 diff --git a/scripts/publish-migration-readiness.mjs b/scripts/publish-migration-readiness.mjs index a81bba84cd..f6828f44f0 100644 --- a/scripts/publish-migration-readiness.mjs +++ b/scripts/publish-migration-readiness.mjs @@ -5,6 +5,13 @@ const sha = /^[a-f0-9]{40}$/, digest = /^[a-f0-9]{64}$/ const hash = value => createHash('sha256').update(value).digest('hex') const requireFact = (condition, message) => { if (!condition) throw Object.assign(Error(message), { safe: true }) } const files = ['scripts/migration-pr-readiness.mjs', 'scripts/publish-migration-readiness.mjs', '.github/workflows/migration-readiness.yml', 'scripts/migration-readiness-policy.json'] +// A log-safe label for any failure: our own rule text, or only the error class, code, exit status and the stage that +// raised it. Never the message, stderr, command arguments or any provider body. +export function describeFailure(error) { + if (error?.safe === true) return `rule refused: ${error.message}` + const part = value => ((typeof value === 'string' || typeof value === 'number') && /^[A-Za-z0-9_.:-]{1,40}$/.test(String(value)) ? String(value) : '?') + return `error ${part(error?.name)} code=${part(error?.code)} status=${part(error?.status)} stage=${part(error?.stage)}` +} export async function publishReadiness({ env, event, api, git, publisherSha256, evaluate }) { requireFact(env.GITHUB_ACTIONS === 'true' && env.GITHUB_EVENT_NAME === 'pull_request_target' && env.GITHUB_REPOSITORY === repo && env.GITHUB_WORKFLOW_REF === `${repo}/.github/workflows/migration-readiness.yml@refs/heads/main`, 'trusted workflow required') requireFact(Number.isSafeInteger(event?.number) && event.number > 0 && sha.test(event.pull_request?.head?.sha) && /^\d+$/.test(env.GITHUB_RUN_ID ?? '') && /^\d+$/.test(env.GITHUB_RUN_ATTEMPT ?? ''), 'unknown PR/run identity') @@ -45,7 +52,7 @@ export async function publishReadiness({ env, event, api, git, publisherSha256, return result } catch (error) { // Only our own fixed-text rule names are printed; provider, Git and API errors are never serialized. - if (error?.safe === true) console.error(`Migration readiness rule refused: ${error.message}`) + console.error(`Migration readiness ${describeFailure(error)}`) await finish('failure', { title: 'Trusted migration readiness failed closed', summary: 'Evaluation, identity or proof was refused, unavailable or moved. No production SQL was applied. Rerun against the current PR head and main.' }) throw Error('trusted candidate-head readiness failed') }