mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
ci: Run DB tests against the full supported Postgres range (#35960)
This commit is contained in:
@@ -520,6 +520,7 @@ Scripts in `.github/scripts/`:
|
||||
|-------------------------|-------------------|---------------------------|
|
||||
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
|
||||
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
|
||||
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
|
||||
|
||||
### Slack Scripts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json}
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_DB=n8n
|
||||
@@ -8,5 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=password
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Not PGDATA: Postgres 18+ moved it under /var/lib/postgresql/<major>/ and
|
||||
# refuses to start when /var/lib/postgresql/data is mounted.
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
- /var/lib/postgresql
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
||||
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
|
||||
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
||||
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
|
||||
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
|
||||
affected_packages: ${{ steps.affected-packages.outputs.list }}
|
||||
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
|
||||
@@ -144,7 +145,10 @@ jobs:
|
||||
packages/@n8n/scheduler/src/**
|
||||
packages/cli/**/__tests__/**
|
||||
packages/testing/containers/services/postgres.ts
|
||||
packages/testing/containers/postgres-versions.json
|
||||
packages/testing/containers/test-containers.ts
|
||||
.github/workflows/test-db-reusable.yml
|
||||
.github/scripts/db-test-matrix.mjs
|
||||
docs/generated/**
|
||||
.tbls.sqlite.yml
|
||||
.tbls.postgres.yml
|
||||
@@ -175,6 +179,12 @@ jobs:
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Generated here because a reusable workflow cannot resolve its own matrix.
|
||||
- name: Generate DB test matrix
|
||||
id: generate-db-test-matrix
|
||||
if: fromJSON(steps.ci-filter.outputs.results).db
|
||||
run: echo "db_test_matrix=$(node .github/scripts/db-test-matrix.mjs)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute affected packages
|
||||
id: affected-packages
|
||||
if: fromJSON(steps.ci-filter.outputs.results).unit
|
||||
@@ -322,6 +332,7 @@ jobs:
|
||||
uses: ./.github/workflows/test-db-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
|
||||
|
||||
performance:
|
||||
name: Performance
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
matrix:
|
||||
description: 'Job matrix JSON, from .github/scripts/db-test-matrix.mjs.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
@@ -19,24 +23,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: SQLite Pooled
|
||||
runner: blacksmith-4vcpu-ubuntu-2204
|
||||
test-cmd: pnpm test:sqlite
|
||||
migration-cmd: pnpm test:sqlite:migrations
|
||||
schema-check-cmd: pnpm --filter=@n8n/db schema:check:sqlite
|
||||
collectCoverage: 'false'
|
||||
- name: Postgres 16
|
||||
# 4vcpu is enough since persistent-worker + template-DB make PG16
|
||||
# complete in ~480s, which is now close to the SQLite leg (~360s)
|
||||
# — the matrix wall is bounded by the slower leg, so the larger
|
||||
# 8vcpu runner buys headroom we can't cash in anywhere.
|
||||
runner: blacksmith-4vcpu-ubuntu-2204
|
||||
test-cmd: pnpm test:postgres:integration:tc
|
||||
migration-cmd: pnpm test:postgres:migrations:tc
|
||||
schema-check-cmd: pnpm --filter=@n8n/db schema:check:postgres
|
||||
TEST_IMAGE_POSTGRES: 'postgres:16'
|
||||
collectCoverage: 'true'
|
||||
include: ${{ fromJSON(inputs.matrix) }}
|
||||
env:
|
||||
TEST_IMAGE_POSTGRES: ${{ matrix.TEST_IMAGE_POSTGRES }}
|
||||
COVERAGE_ENABLED: ${{ matrix.collectCoverage }}
|
||||
@@ -64,6 +51,7 @@ jobs:
|
||||
# postgres testcontainer) and fails if they differ from what's committed.
|
||||
# tbls runs as a Docker image in CI.
|
||||
- name: Verify schema docs are up to date
|
||||
if: matrix.schema-check-cmd != ''
|
||||
run: ${{ matrix.schema-check-cmd }}
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
|
||||
@@ -68,6 +68,12 @@ jobs:
|
||||
if: fromJSON(steps.paths-filter.outputs.results).db
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
|
||||
- name: Resolve Postgres image
|
||||
if: fromJSON(steps.paths-filter.outputs.results).db
|
||||
run: |
|
||||
IMAGE=$(node -p "require('./packages/testing/containers/postgres-versions.json').primary")
|
||||
echo "TEST_IMAGE_POSTGRES=$IMAGE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start Postgres
|
||||
if: fromJSON(steps.paths-filter.outputs.results).db
|
||||
uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
|
||||
Reference in New Issue
Block a user