ci: Run DB tests against the full supported Postgres range (#35960)

This commit is contained in:
Iván Ovejero
2026-08-11 09:49:07 +00:00
committed by GitHub
parent acd4fdacc9
commit 4afbd16dc7
24 changed files with 355 additions and 37 deletions
+95
View File
@@ -0,0 +1,95 @@
// Builds the job matrix for test-db-reusable.yml. Node builtins only: it runs in a
// job that installs the monorepo's dependencies, not this folder's.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json';
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
/**
* @typedef {Object} PostgresVersions
* @property {string} primary
* @property {Array<{ major: number, image: string, support?: string }>} matrix
*/
/** @returns {PostgresVersions} */
export function readPostgresVersions(repoRoot = REPO_ROOT) {
const file = path.join(repoRoot, POSTGRES_VERSIONS_PATH);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* Coverage and the schema-docs check run on the primary Postgres leg only, since
* the committed docs come from that one version.
*
* @param {PostgresVersions} versions
*/
export function buildMatrix(versions) {
const { primary, matrix } = versions;
if (!Array.isArray(matrix) || matrix.length === 0) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "matrix" must be a non-empty array`);
}
// Checked first: everything below treats the last entry as the newest.
const majors = matrix.map((entry) => entry.major);
for (let i = 1; i < majors.length; i++) {
if (majors[i] <= majors[i - 1]) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "matrix" must be sorted by ascending major, got ${majors.join(', ')}`,
);
}
}
const newest = matrix[matrix.length - 1];
if (newest.image !== primary) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "primary" (${primary}) must be the newest "matrix" entry's image (${newest.image})`,
);
}
// An exact minor keeps CI reproducible across Postgres point releases.
for (const { major, image } of matrix) {
const pinned = /^postgres:(\d+)\.\d+-alpine$/.exec(image);
if (!pinned) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "${image}" must pin an exact minor, e.g. postgres:${major}.4-alpine`,
);
}
if (Number(pinned[1]) !== major) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "${image}" does not match major ${major}`);
}
}
return [
{
name: 'SQLite Pooled',
runner: RUNNER,
'test-cmd': 'pnpm test:sqlite',
'migration-cmd': 'pnpm test:sqlite:migrations',
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
TEST_IMAGE_POSTGRES: undefined,
collectCoverage: 'false',
},
...matrix.map(({ major, image }) => ({
name: `Postgres ${major}`,
runner: RUNNER,
'test-cmd': 'pnpm test:postgres:integration:tc',
'migration-cmd': 'pnpm test:postgres:migrations:tc',
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
TEST_IMAGE_POSTGRES: image,
collectCoverage: image === primary ? 'true' : 'false',
})),
];
}
// Skipped when imported by the tests.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(buildMatrix(readPostgresVersions())));
}
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildMatrix, readPostgresVersions, POSTGRES_VERSIONS_PATH } from './db-test-matrix.mjs';
// node --test ./.github/scripts/db-test-matrix.test.mjs
const versions = () => ({
primary: 'postgres:18.4-alpine',
matrix: [
{ major: 16, support: 'compatibility', image: 'postgres:16.14-alpine' },
{ major: 17, support: 'supported', image: 'postgres:17.10-alpine' },
{ major: 18, support: 'supported', image: 'postgres:18.4-alpine' },
],
});
describe('postgres-versions.json', () => {
it('is valid, so the DB workflow can build a matrix from it', () => {
assert.doesNotThrow(() => buildMatrix(readPostgresVersions()));
});
it('covers the supported range plus one compatibility major', () => {
const { matrix } = readPostgresVersions();
const supported = matrix.filter((entry) => entry.support === 'supported');
const compatibility = matrix.filter((entry) => entry.support === 'compatibility');
assert.equal(supported.length, 2, `${POSTGRES_VERSIONS_PATH}: expected two supported majors`);
assert.equal(
compatibility.length,
1,
`${POSTGRES_VERSIONS_PATH}: expected one compatibility major`,
);
assert.ok(compatibility[0].major < Math.min(...supported.map((entry) => entry.major)));
});
});
describe('buildMatrix', () => {
it('emits one leg per database', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.map((leg) => leg.name),
['SQLite Pooled', 'Postgres 16', 'Postgres 17', 'Postgres 18'],
);
});
it('passes each Postgres leg its own pinned image', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.filter((leg) => leg.TEST_IMAGE_POSTGRES).map((leg) => leg.TEST_IMAGE_POSTGRES),
['postgres:16.14-alpine', 'postgres:17.10-alpine', 'postgres:18.4-alpine'],
);
assert.equal(legs[0].TEST_IMAGE_POSTGRES, undefined, 'SQLite leg needs no Postgres image');
});
it('runs every leg against both the integration and the migration suite', () => {
for (const leg of buildMatrix(versions())) {
assert.ok(leg['test-cmd'], `${leg.name} has no test command`);
assert.ok(leg['migration-cmd'], `${leg.name} has no migration command`);
}
});
it('collects coverage and checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
const collecting = legs.filter((leg) => leg.collectCoverage === 'true');
assert.deepEqual(
collecting.map((leg) => leg.name),
['Postgres 18'],
);
const checkingPostgresSchema = legs.filter(
(leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres',
);
assert.deepEqual(
checkingPostgresSchema.map((leg) => leg.name),
['Postgres 18'],
);
const skipping = legs.filter((leg) => leg['schema-check-cmd'] === '');
assert.deepEqual(
skipping.map((leg) => leg.name),
['Postgres 16', 'Postgres 17'],
);
});
it('still checks the SQLite schema docs', () => {
const legs = buildMatrix(versions());
assert.equal(legs[0]['schema-check-cmd'], 'pnpm --filter=@n8n/db schema:check:sqlite');
});
it('rejects a primary that is not the newest major', () => {
const stale = { ...versions(), primary: 'postgres:17.10-alpine' };
assert.throws(() => buildMatrix(stale), /"primary".*must be the newest/);
});
it('rejects majors that are not in ascending order', () => {
const unsorted = versions();
[unsorted.matrix[0], unsorted.matrix[1]] = [unsorted.matrix[1], unsorted.matrix[0]];
assert.throws(() => buildMatrix(unsorted), /ascending major, got 17, 16, 18/);
});
it('rejects a floating image tag', () => {
const floating = {
primary: 'postgres:18-alpine',
matrix: [{ major: 18, image: 'postgres:18-alpine' }],
};
assert.throws(() => buildMatrix(floating), /must pin an exact minor/);
});
it('rejects an image whose major disagrees with its entry', () => {
const mismatched = {
primary: 'postgres:17.10-alpine',
matrix: [{ major: 18, image: 'postgres:17.10-alpine' }],
};
assert.throws(() => buildMatrix(mismatched), /does not match major 18/);
});
it('rejects an empty matrix', () => {
assert.throws(() => buildMatrix({ primary: 'postgres:18.4-alpine', matrix: [] }), /non-empty/);
});
});