mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-27 17:14:30 -05:00
[AI] migrations: enforce append-only migration files in CI (#8446)
* [AI] Enforce append-only migration files in CI Shipped migrations are append-only: existing installs never re-run them, so editing one silently forks the database schema across the user base, and deleting one breaks the migration check for every existing budget file. The CI migration check now diffs blob hashes against the merge base to reject edits/deletions, keeps the existing timestamp-ordering rule, emits advisory PR annotations for migrations containing DROP/RENAME statements, and fails loudly when git commands fail instead of passing on empty output. First of a series that makes clients on different app versions sync safely against the same budget file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [AI] Derive the newest upstream migration id with an explicit max The ls-tree listing is alphabetical, so taking the last entry only worked while every migration id has the same digit count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [AI] Post advisory migration warnings as a PR comment The risky-SQL warnings were only visible as job annotations, and since the job passes there was no prompt to look at them. The migrations job now uploads its findings as an artifact, and a new workflow_run workflow (trusted base-repo context, so it also works for fork PRs whose tokens are read-only) validates the artifact against a fixed allowlist and posts/updates a PR comment — removing it again once the warnings are resolved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [AI] Align overview numbering with the inline check comments --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a862d463d7
commit
501bece3a1
@@ -139,9 +139,30 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full history so the migration check can diff against the merge
|
||||
# base with master; blobless to keep the large repo checkout fast
|
||||
# (ls-tree and merge-base only need commits and trees)
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Check migrations
|
||||
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
|
||||
- name: Save PR number for the warnings comment workflow
|
||||
if: always() && hashFiles('packages/ci-actions/migration-warnings.json') != ''
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: echo "$PR_NUMBER" > packages/ci-actions/migration-warnings-pr.txt
|
||||
# Consumed by migration-warnings-comment.yml, which posts the advisory
|
||||
# warnings as a PR comment (this job's token is read-only on fork PRs,
|
||||
# so it can't comment itself).
|
||||
- name: Upload warnings for the comment workflow
|
||||
if: always() && hashFiles('packages/ci-actions/migration-warnings.json') != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: migration-warnings
|
||||
path: |
|
||||
packages/ci-actions/migration-warnings.json
|
||||
packages/ci-actions/migration-warnings-pr.txt
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Migration warnings comment
|
||||
|
||||
# Posts the advisory migration warnings from the `migrations` job in
|
||||
# check.yml as a PR comment, so they're visible without opening the job log.
|
||||
#
|
||||
# SECURITY: This runs in the trusted base-repo context (workflow_run) so its
|
||||
# token can comment on fork PRs, whose `pull_request` runs only get a
|
||||
# read-only token. The artifact produced by the PR run is UNTRUSTED data:
|
||||
# the PR number is cross-checked against the triggering run's head SHA, and
|
||||
# the findings are validated against a fixed allowlist before being rendered
|
||||
# into the comment. Nothing from the artifact is ever executed.
|
||||
on:
|
||||
# See SECURITY note above: artifact content is validated and only ever
|
||||
# treated as data.
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers]
|
||||
workflows: ['Test']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: depot-ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download warnings artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
# `pattern` instead of `name` so a run without the artifact (PR
|
||||
# doesn't touch migrations) skips gracefully instead of failing.
|
||||
pattern: migration-warnings
|
||||
merge-multiple: true
|
||||
path: warnings
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: hashFiles('warnings/migration-warnings.json') != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
JSON=warnings/migration-warnings.json
|
||||
MARKER='<!--- migration-warnings --->'
|
||||
|
||||
PR=$(cat warnings/migration-warnings-pr.txt)
|
||||
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid PR number in artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The artifact is untrusted: make sure the PR it names is really
|
||||
# the one that produced the triggering run.
|
||||
if [ "$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha)" != "$HEAD_SHA" ]; then
|
||||
echo "Artifact PR head does not match triggering run head; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
existing=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("<!--- migration-warnings --->")) | .id')
|
||||
existing=${existing%%$'\n'*}
|
||||
|
||||
# Warnings resolved (or none) — remove a stale comment if present.
|
||||
if jq -e 'length == 0' "$JSON" > /dev/null; then
|
||||
if [ -n "$existing" ]; then
|
||||
gh api -X DELETE "repos/$REPO/issues/comments/$existing"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only render findings matching the exact shape and allowlisted
|
||||
# risk strings produced by check-migrations.ts.
|
||||
jq -e '
|
||||
type == "array" and all(.[];
|
||||
(.name | type == "string" and test("^[A-Za-z0-9._-]{1,200}$")) and
|
||||
(.risks | type == "array" and length > 0 and all(.[];
|
||||
IN("drops a table", "drops a column", "renames a table or column"))))
|
||||
' "$JSON" > /dev/null
|
||||
|
||||
body="$MARKER
|
||||
### ⚠️ This PR contains possibly breaking database migrations
|
||||
|
||||
$(jq -r '.[] | . as $m | .risks[] | "- `\($m.name)` \(.)."' "$JSON")
|
||||
|
||||
Migrations must be additive-only: removing or renaming tables or columns breaks older clients that sync the same budget file. See the [migration guidelines](https://actualbudget.org/docs/contributing/project-details/migrations).
|
||||
|
||||
<sub>This is an automated advisory check — it may be a false positive and does not block the PR.</sub>"
|
||||
|
||||
if [ -n "$existing" ]; then
|
||||
gh api -X PATCH "repos/$REPO/issues/comments/$existing" -f body="$body"
|
||||
else
|
||||
gh api "repos/$REPO/issues/$PR/comments" -f body="$body"
|
||||
fi
|
||||
@@ -1 +1,3 @@
|
||||
dist/*
|
||||
migration-warnings.json
|
||||
migration-warnings-pr.txt
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
// overview:
|
||||
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
|
||||
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
|
||||
// overview: identify the migrations in packages/loot-core/migrations/* on
|
||||
// `master`, on the merge base, and on HEAD, then:
|
||||
// 1. Make sure that any new migrations on HEAD are dated after the latest
|
||||
// migration on `master` (older dates trigger `out-of-sync-migrations` for
|
||||
// users who already applied the newer one).
|
||||
// 2. Make sure no migration that exists on the merge base was edited or
|
||||
// deleted (shipped migrations are append-only: existing installs never
|
||||
// re-run them, so edits fork the schema across the user base).
|
||||
// 3. Emit advisory warnings when a new migration contains statements that
|
||||
// look like they remove or rename schema (removing or renaming breaks
|
||||
// older clients syncing the same budget file). The warnings are also
|
||||
// written to migration-warnings.json so the migration-warnings-comment
|
||||
// workflow can surface them as a PR comment.
|
||||
//
|
||||
// See https://actualbudget.org/docs/contributing/project-details/migrations
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import {
|
||||
findAddedMigrations,
|
||||
findMisdatedMigrations,
|
||||
findMutatedMigrations,
|
||||
findRiskyStatements,
|
||||
parseMigrationTree,
|
||||
} from '../src/migrations/check';
|
||||
|
||||
const POLICY_URL =
|
||||
'https://actualbudget.org/docs/contributing/project-details/migrations';
|
||||
|
||||
const migrationsDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
@@ -16,45 +40,118 @@ const migrationsDir = path.join(
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function readMigrations(ref: string) {
|
||||
const { stdout } = spawnSync('git', [
|
||||
'ls-tree',
|
||||
'--name-only',
|
||||
ref,
|
||||
migrationsDir + '/',
|
||||
]);
|
||||
const files = stdout.toString().split('\n').filter(Boolean);
|
||||
console.log(`Found ${files.length} migrations on ${ref}.`);
|
||||
return files
|
||||
.map(file => path.basename(file))
|
||||
.filter(file => !file.startsWith('.'))
|
||||
.map(name => ({
|
||||
date: parseInt(name.split('_')[0]),
|
||||
name: name.match(/^\d+_(.+?)(\.sql)?$/)?.[1] ?? '***' + name,
|
||||
}));
|
||||
function git(args: string[]): string {
|
||||
const { status, stdout, stderr, error } = spawnSync('git', args);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (status !== 0) {
|
||||
throw new Error(
|
||||
`git ${args.join(' ')} failed (exit ${status}): ${stderr.toString()}`,
|
||||
);
|
||||
}
|
||||
return stdout.toString();
|
||||
}
|
||||
|
||||
spawnSync('git', ['fetch', 'origin', 'master']);
|
||||
function readMigrations(ref: string) {
|
||||
const migrations = parseMigrationTree(
|
||||
git(['ls-tree', ref, migrationsDir + '/']),
|
||||
);
|
||||
console.log(`Found ${migrations.length} migrations on ${ref}.`);
|
||||
return migrations;
|
||||
}
|
||||
|
||||
git(['fetch', 'origin', 'master']);
|
||||
|
||||
const mergeBase = git(['merge-base', 'origin/master', 'HEAD']).trim();
|
||||
|
||||
const masterMigrations = readMigrations('origin/master');
|
||||
const mergeBaseMigrations = readMigrations(mergeBase);
|
||||
const headMigrations = readMigrations('HEAD');
|
||||
|
||||
const latestMasterMigration =
|
||||
masterMigrations[masterMigrations.length - 1].date;
|
||||
const newMigrations = headMigrations.filter(
|
||||
migration => !masterMigrations.find(m => m.name === migration.name),
|
||||
const problems: string[] = [];
|
||||
|
||||
// 1. New migrations must be dated after the latest migration on master.
|
||||
const latestMasterMigration = Math.max(
|
||||
0,
|
||||
...masterMigrations.map(migration => migration.id),
|
||||
);
|
||||
const badMigrations = newMigrations.filter(
|
||||
migration => migration.date <= latestMasterMigration,
|
||||
const newMigrations = findAddedMigrations(masterMigrations, headMigrations);
|
||||
const misdated = findMisdatedMigrations(newMigrations, latestMasterMigration);
|
||||
|
||||
for (const migration of misdated) {
|
||||
problems.push(
|
||||
`Migration ${migration.name} is dated before the latest migration on ` +
|
||||
`master. Rename it with a newer timestamp, otherwise users who ` +
|
||||
`already applied the newer migration will fail to load their budget.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Migrations that exist on the merge base must not be edited or deleted.
|
||||
const { modified, deleted } = findMutatedMigrations(
|
||||
mergeBaseMigrations,
|
||||
headMigrations,
|
||||
);
|
||||
|
||||
if (badMigrations.length) {
|
||||
console.error(
|
||||
`The following migrations are dated before the latest migration on master:`,
|
||||
for (const name of modified) {
|
||||
problems.push(
|
||||
`Migration ${name} was modified. Shipped migrations are append-only: ` +
|
||||
`existing installs will never re-run it, so editing it forks the ` +
|
||||
`database schema across the user base. Add a new migration instead.`,
|
||||
);
|
||||
badMigrations.forEach(migration => {
|
||||
console.error(` ${migration.name}`);
|
||||
}
|
||||
for (const name of deleted) {
|
||||
problems.push(
|
||||
`Migration ${name} was deleted. Shipped migrations are append-only: ` +
|
||||
`existing installs already ran it, so deleting it breaks the ` +
|
||||
`migration check for every existing budget file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Advisory: warn on new migrations that look like they remove or rename
|
||||
// schema. Not fatal — regexes over SQL can't be authoritative, so this only
|
||||
// surfaces an early hint on the PR.
|
||||
const riskyMigrations: { name: string; risks: string[] }[] = [];
|
||||
for (const migration of newMigrations) {
|
||||
let source = '';
|
||||
try {
|
||||
source = readFileSync(path.join(migrationsDir, migration.name), 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const risks = findRiskyStatements(source);
|
||||
for (const risk of risks) {
|
||||
console.log(
|
||||
`::warning file=packages/loot-core/migrations/${migration.name},` +
|
||||
`title=Possibly breaking migration::This migration ${risk}. ` +
|
||||
`Migrations must be additive-only; removing or renaming schema ` +
|
||||
`breaks older clients syncing the same budget file. See ${POLICY_URL}`,
|
||||
);
|
||||
}
|
||||
if (risks.length) {
|
||||
riskyMigrations.push({ name: migration.name, risks });
|
||||
}
|
||||
}
|
||||
|
||||
// The warnings above don't fail the job, so they're easy to miss. Hand them
|
||||
// to the migration-warnings-comment workflow (whose token can comment on
|
||||
// fork PRs) via an artifact. Always written — an empty list tells the
|
||||
// workflow to remove a stale comment once the warnings are resolved.
|
||||
writeFileSync(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'migration-warnings.json',
|
||||
),
|
||||
JSON.stringify(riskyMigrations),
|
||||
);
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`Migration policy violations found (see ${POLICY_URL}):`);
|
||||
problems.forEach(problem => {
|
||||
console.error(` - ${problem}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`All migrations are dated after the latest migration on master.`);
|
||||
console.log('All migration checks passed.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findAddedMigrations,
|
||||
findMisdatedMigrations,
|
||||
findMutatedMigrations,
|
||||
findRiskyStatements,
|
||||
parseMigrationTree,
|
||||
} from './check';
|
||||
|
||||
const treeOutput = [
|
||||
'100644 blob aaa111\tpackages/loot-core/migrations/1000_first.sql',
|
||||
'100644 blob bbb222\tpackages/loot-core/migrations/2000_second.js',
|
||||
'100644 blob ccc333\tpackages/loot-core/migrations/.hidden',
|
||||
].join('\n');
|
||||
|
||||
describe('parseMigrationTree', () => {
|
||||
it('parses names, ids, and hashes, skipping hidden files', () => {
|
||||
expect(parseMigrationTree(treeOutput)).toEqual([
|
||||
{ name: '1000_first.sql', id: 1000, hash: 'aaa111' },
|
||||
{ name: '2000_second.js', id: 2000, hash: 'bbb222' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list for empty output', () => {
|
||||
expect(parseMigrationTree('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAddedMigrations', () => {
|
||||
it('returns migrations present on head but not on base', () => {
|
||||
const base = parseMigrationTree(treeOutput);
|
||||
const head = [
|
||||
...base,
|
||||
{ name: '3000_third.sql', id: 3000, hash: 'ddd444' },
|
||||
];
|
||||
expect(findAddedMigrations(base, head)).toEqual([
|
||||
{ name: '3000_third.sql', id: 3000, hash: 'ddd444' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMutatedMigrations', () => {
|
||||
it('detects modified and deleted migrations', () => {
|
||||
const base = parseMigrationTree(treeOutput);
|
||||
const head = [{ name: '1000_first.sql', id: 1000, hash: 'changed' }];
|
||||
expect(findMutatedMigrations(base, head)).toEqual({
|
||||
modified: ['1000_first.sql'],
|
||||
deleted: ['2000_second.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing when head matches base', () => {
|
||||
const base = parseMigrationTree(treeOutput);
|
||||
expect(findMutatedMigrations(base, base)).toEqual({
|
||||
modified: [],
|
||||
deleted: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMisdatedMigrations', () => {
|
||||
it('flags migrations dated at or before the latest existing one', () => {
|
||||
const added = [
|
||||
{ name: '1500_late.sql', id: 1500, hash: 'a' },
|
||||
{ name: '2000_same.sql', id: 2000, hash: 'b' },
|
||||
{ name: '2500_ok.sql', id: 2500, hash: 'c' },
|
||||
];
|
||||
expect(findMisdatedMigrations(added, 2000).map(m => m.name)).toEqual([
|
||||
'1500_late.sql',
|
||||
'2000_same.sql',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRiskyStatements', () => {
|
||||
it('flags DROP TABLE, DROP COLUMN, and RENAME statements', () => {
|
||||
expect(findRiskyStatements('ALTER TABLE x DROP COLUMN y;')).toEqual([
|
||||
'drops a column',
|
||||
]);
|
||||
expect(findRiskyStatements('DROP TABLE x;')).toEqual(['drops a table']);
|
||||
expect(findRiskyStatements('ALTER TABLE x RENAME TO y;')).toEqual([
|
||||
'renames a table or column',
|
||||
]);
|
||||
expect(findRiskyStatements('ALTER TABLE x RENAME COLUMN a TO b;')).toEqual([
|
||||
'renames a table or column',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores additive statements and comments', () => {
|
||||
expect(findRiskyStatements('ALTER TABLE x ADD COLUMN y TEXT;')).toEqual([]);
|
||||
expect(
|
||||
findRiskyStatements('-- do not DROP TABLE here\nCREATE TABLE y (id);'),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findRiskyStatements('/* DROP COLUMN in a block comment */ SELECT 1;'),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Pure logic for the migration checks that run in CI. Database migrations
|
||||
// are append-only: once a migration has shipped, editing or deleting it
|
||||
// forks the schema across the user base (clients that already ran the old
|
||||
// version will never re-run it), and a migration dated before an already
|
||||
// shipped one triggers the `out-of-sync-migrations` error for users who
|
||||
// applied the newer one first. This module also flags statements that look
|
||||
// like they remove or rename schema, so contributors get a hint in the PR
|
||||
// checks.
|
||||
// See https://actualbudget.org/docs/contributing/project-details/migrations
|
||||
|
||||
export type MigrationEntry = {
|
||||
name: string;
|
||||
id: number;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
// Parses `git ls-tree <ref> -- <dir>/` output, where each line looks like
|
||||
// `100644 blob <hash>\t<path>`.
|
||||
export function parseMigrationTree(lsTreeOutput: string): MigrationEntry[] {
|
||||
return lsTreeOutput
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [meta, filePath] = line.split('\t');
|
||||
const hash = meta?.split(/\s+/)[2] ?? '';
|
||||
const name = filePath?.split('/').pop() ?? '';
|
||||
return { name, id: Number.parseInt(name.split('_')[0], 10), hash };
|
||||
})
|
||||
.filter(entry => entry.name !== '' && !entry.name.startsWith('.'))
|
||||
.filter(entry => Number.isFinite(entry.id));
|
||||
}
|
||||
|
||||
export function findAddedMigrations(
|
||||
base: MigrationEntry[],
|
||||
head: MigrationEntry[],
|
||||
): MigrationEntry[] {
|
||||
const baseNames = new Set(base.map(migration => migration.name));
|
||||
return head.filter(migration => !baseNames.has(migration.name));
|
||||
}
|
||||
|
||||
export function findMutatedMigrations(
|
||||
base: MigrationEntry[],
|
||||
head: MigrationEntry[],
|
||||
): { modified: string[]; deleted: string[] } {
|
||||
const headByName = new Map(
|
||||
head.map(migration => [migration.name, migration]),
|
||||
);
|
||||
|
||||
const modified: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
for (const migration of base) {
|
||||
const headMigration = headByName.get(migration.name);
|
||||
if (!headMigration) {
|
||||
deleted.push(migration.name);
|
||||
} else if (headMigration.hash !== migration.hash) {
|
||||
modified.push(migration.name);
|
||||
}
|
||||
}
|
||||
return { modified, deleted };
|
||||
}
|
||||
|
||||
export function findMisdatedMigrations(
|
||||
added: MigrationEntry[],
|
||||
latestExistingId: number,
|
||||
): MigrationEntry[] {
|
||||
return added.filter(migration => migration.id <= latestExistingId);
|
||||
}
|
||||
|
||||
const RISKY_PATTERNS = [
|
||||
{ pattern: /\bDROP\s+TABLE\b/i, description: 'drops a table' },
|
||||
{ pattern: /\bDROP\s+COLUMN\b/i, description: 'drops a column' },
|
||||
{
|
||||
pattern: /\bRENAME\s+(COLUMN|TO)\b/i,
|
||||
description: 'renames a table or column',
|
||||
},
|
||||
];
|
||||
|
||||
// Advisory only: regexes over SQL text can't see every destructive change,
|
||||
// so this just surfaces an early hint on obviously suspicious SQL.
|
||||
export function findRiskyStatements(source: string): string[] {
|
||||
const withoutComments = source
|
||||
.replace(/--[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return RISKY_PATTERNS.filter(({ pattern }) =>
|
||||
pattern.test(withoutComments),
|
||||
).map(({ description }) => description);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Enforce in CI that shipped database migration files are never edited or deleted, and warn on migrations that look destructive
|
||||
Reference in New Issue
Block a user