diff --git a/README.md b/README.md index 0c0b727..ceda11c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ --- +## Enforce it on pull requests + +The CLI stays free and works locally or in your existing CI. [pgfence Cloud](https://pgfence.com) runs the same analyzer as a required GitHub check, with hosted history and explicit, expiring exemptions, so a migration author cannot bypass the gate with a local suppression. + ## The Problem Your ORM migration just took down production for 47 seconds. @@ -313,6 +317,8 @@ pgfence analyze --output github migrations/*.sql pgfence analyze --ci --max-risk medium migrations/*.sql ``` +When CI blocks, pgfence prints one line about the hosted required check to stderr. Suppress it with `--no-cloud-hint` or `PGFENCE_CLOUD_HINT=0`. + ### Suppressing warnings Add an inline comment immediately before a statement to suppress checks for it: diff --git a/RULES.md b/RULES.md index bb1ebe8..6094f56 100644 --- a/RULES.md +++ b/RULES.md @@ -214,6 +214,7 @@ Run outside any transaction wrapper. - GitHub Action: see https://github.com/flvmnt/pgfence - VS Code extension: `flvmnt.pgfence` on the Marketplace - LSP server: `pgfence lsp` (stdio) +- Required GitHub check with hosted history and expiring exemptions: https://pgfence.com ## When in doubt diff --git a/package.json b/package.json index cf22c92..ef4c16b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ }, "pnpm": { "overrides": { - "brace-expansion": "^2.0.3", + "brace-expansion": "^2.1.4", "flatted": "^3.4.2", "minimatch": "^9.0.7", "picomatch": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfa2279..8c46e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion: ^2.0.3 + brace-expansion: ^2.1.4 flatted: ^3.4.2 minimatch: ^9.0.7 picomatch: ^4.0.4 @@ -800,8 +800,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} @@ -907,6 +907,7 @@ packages: eslint@9.39.3: resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2042,7 +2043,7 @@ snapshots: balanced-match@1.0.2: {} - brace-expansion@2.1.0: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 @@ -2391,7 +2392,7 @@ snapshots: minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.7 minipass@7.1.3: {} diff --git a/src/index.ts b/src/index.ts index 8a41efc..9edea97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,6 +120,13 @@ function shouldFailCI(results: Awaited>, config: Pgfe return config.unknownHandling === 'block' && hasUnanalyzableStatements(results); } +function cloudHintEnabled(optionEnabled: boolean, env: NodeJS.ProcessEnv = process.env): boolean { + if (!optionEnabled) return false; + + const value = env.PGFENCE_CLOUD_HINT?.trim().toLowerCase(); + return value !== '0' && value !== 'false' && value !== 'off'; +} + /** * The two telemetry calls a command handler makes. * @@ -275,6 +282,7 @@ program .option('--min-pg-version ', 'Minimum PostgreSQL version to assume', '14') .option('--max-risk ', 'Maximum allowed risk level for CI mode', 'high') .option('--ci', 'CI mode: exit 1 if max risk exceeded', false) + .option('--no-cloud-hint', 'Do not print the pgfence Cloud hint when CI blocks') .option('--no-lock-timeout', 'Disable lock_timeout requirement') .option('--no-statement-timeout', 'Disable statement_timeout requirement') .option('--max-lock-timeout ', 'Maximum allowed lock_timeout in ms (default: 5000)') @@ -480,6 +488,11 @@ program // immediately after writing the report truncates it at the 64KB pipe buffer. The // success path already terminates by returning, so this changes only the code. if (opts.ci && shouldFailCI(finalResults, config)) { + if (cloudHintEnabled(opts.cloudHint !== false)) { + process.stderr.write( + 'pgfence: Enforce this as a required check that cannot be bypassed locally: https://pgfence.com\n', + ); + } process.exitCode = 1; return; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7c84aad..82f7705 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -123,6 +123,41 @@ describe.skipIf(!hasBuiltCli())('CLI e2e (built binary)', () => { ).rejects.toMatchObject({ code: 1 }); }); + it('prints one Cloud hint when CI blocks', async () => { + const fixture = path.join(fixturesDir, 'dangerous-add-column.sql'); + + try { + await execPromise(`node "${distCliPath}" analyze --ci --max-risk medium "${fixture}"`); + throw new Error('expected analyze to fail'); + } catch (err: unknown) { + const error = err as { code?: number; stderr?: string }; + expect(error.code).toBe(1); + expect(error.stderr?.match(/https:\/\/pgfence\.com/g)).toHaveLength(1); + expect(error.stderr).toContain('required check that cannot be bypassed locally'); + } + }); + + it('suppresses the Cloud hint with a flag or environment variable', async () => { + const fixture = path.join(fixturesDir, 'dangerous-add-column.sql'); + const command = `node "${distCliPath}" analyze --ci --max-risk medium`; + + for (const invocation of [ + () => execPromise(`${command} --no-cloud-hint "${fixture}"`), + () => execPromise(`${command} "${fixture}"`, { + env: { ...process.env, PGFENCE_CLOUD_HINT: '0' }, + }), + ]) { + try { + await invocation(); + throw new Error('expected analyze to fail'); + } catch (err: unknown) { + const error = err as { code?: number; stderr?: string }; + expect(error.code).toBe(1); + expect(error.stderr).not.toContain('https://pgfence.com'); + } + } + }); + it('exits 1 when unknown handling blocks unanalyzable SQL', async () => { const fixture = path.join(fixturesDir, 'dynamic-typeorm.ts'); await expect( diff --git a/tests/entry-point.test.ts b/tests/entry-point.test.ts index 2252f08..ea45820 100644 --- a/tests/entry-point.test.ts +++ b/tests/entry-point.test.ts @@ -251,7 +251,9 @@ describe.skipIf(!hasBuiltCli || !canSymlink)('CLI entry through symlinked launch expect(run.stdout).toContain('[CRITICAL]'); expect(run.stdout).toContain('drop-table'); expect(run.stdout).toContain('Analyzed 1 SQL statement'); - expect(run.stderr).toBe(''); + expect(run.stderr).toBe( + 'pgfence: Enforce this as a required check that cannot be bypassed locally: https://pgfence.com\n', + ); expect(run.code).toBe(1); } finally { await cleanupWorkspace(work, [link]); @@ -752,7 +754,9 @@ describe.skipIf(!hasBuiltCli)('zero-analysis gate', () => { expect(run.stdout).toContain('drop-table'); expect(run.stdout).toContain('[NO STATEMENTS]'); expect(run.stdout).toContain('0 SQL statements found. Nothing in this file was checked.'); - expect(run.stderr).toBe(''); + expect(run.stderr).toBe( + 'pgfence: Enforce this as a required check that cannot be bypassed locally: https://pgfence.com\n', + ); expect(run.code).toBe(1); } finally { await cleanupWorkspace(work);