Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ function shouldFailCI(results: Awaited<ReturnType<typeof analyze>>, 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.
*
Expand Down Expand Up @@ -275,6 +282,7 @@ program
.option('--min-pg-version <version>', 'Minimum PostgreSQL version to assume', '14')
.option('--max-risk <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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the Cloud hint to trace CI failures

The documented pgfence trace --ci path also blocks with exit 1 for risk, trace errors, or mismatches (src/index.ts lines 763–770), but the new option and hint are registered only for analyze. Consequently, trace CI failures never show the advertised hint, and passing --no-cloud-hint to trace is rejected as an unknown option. Register the suppression option and emit the hint at the trace CI gate as well.

Useful? React with 👍 / 👎.

.option('--no-lock-timeout', 'Disable lock_timeout requirement')
.option('--no-statement-timeout', 'Disable statement_timeout requirement')
.option('--max-lock-timeout <ms>', 'Maximum allowed lock_timeout in ms (default: 5000)')
Expand Down Expand Up @@ -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;
}
Expand Down
35 changes: 35 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions tests/entry-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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);
Expand Down