feat(resolve-check-config): add ability to use a config file to adjust jobs (#255)

This commit is contained in:
Damien Retzinger
2026-05-17 17:15:09 -04:00
parent 0c7d14d885
commit b98313e100
26 changed files with 1324 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# "Resolve Check Config" Action
Reads `.github/check-<kind>.json` (or a path you specify), validates job names against the known list for the selected workflow kind, and emits a per-job filtered version of the `supported-version` matrix. Each job in the output carries an `enabled` flag and its own `matrix`, where every entry's `services` map has been narrowed to the tiers that job actually needs. Consumers gate each job with `fromJSON(...)['<job>'].enabled != false` and feed `fromJSON(...)['<job>'].matrix` into `strategy.matrix`.
A missing config file is fine — every known job is emitted with its default tier list.
## Inputs
| Input | Description | Required | Default |
|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------|
| `kind` | Which reusable workflow this config belongs to: `store` or `extension`. Selects the default `config_path`, the known-job list, and the per-job tier defaults. | true | |
| `matrix` | The matrix JSON emitted by the `supported-version` action. Each entry's `services` map is filtered per-job based on the resolved tier list. | true | |
| `config_path` | Path to the check-config JSON file, relative to the runner workspace. | false | `.github/check-<kind>.json` |
## Usage
```yml
jobs:
compute_matrix:
runs-on: ubuntu-latest
outputs:
resolved: ${{ steps.resolve.outputs.resolved }}
steps:
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
id: supported-version
with:
kind: currently-supported
- uses: graycoreio/github-actions-magento2/resolve-check-config@v8.0.0 # x-release-please-version
id: resolve
with:
kind: store
matrix: ${{ steps.supported-version.outputs.matrix }}
smoke-test:
runs-on: ${{ matrix.os }}
needs: compute_matrix
if: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['smoke-test'].enabled != false }}
services: ${{ matrix.services }}
strategy:
matrix: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['smoke-test'].matrix }}
steps:
- run: echo "running with ${{ toJSON(matrix.services) }}"
```
Example `.github/check-store.json` for opting out of a specific job:
```json
{
"$schema": "https://raw.githubusercontent.com/graycoreio/github-actions-magento2/main/resolve-check-config/check-store.schema.json",
"jobs": {
"coding-standard": false
}
}
```
+27
View File
@@ -0,0 +1,27 @@
name: "Resolve check config"
author: "Graycore"
description: "Reads .github/check-<kind>.json (or a path you specify), validates job names against the known list for that workflow kind, and emits a per-job filtered version of the supported-version matrix. Missing config file is treated as 'all jobs enabled with their default tier list.'"
inputs:
kind:
required: true
description: "Which reusable workflow this config belongs to: `store` or `extension`. Selects the default `config_path`, the known-job list used for validation, and the per-job default tier list."
matrix:
required: true
description: "The matrix JSON emitted by the `supported-version` action. Each entry's `services` map is filtered per-job based on the resolved tier list and embedded in the per-job `matrix` output."
config_path:
required: false
default: ""
description: "Path to the check-config JSON file, relative to the runner workspace. Defaults to `.github/check-<kind>.json`. Missing file is fine — every known job is emitted with its default tier list."
outputs:
resolved:
description: "The per-job resolved configuration as a JSON object. Each top-level key is a known job name for the selected kind; values are objects with `enabled` (boolean) and `matrix` (a copy of the supported-version matrix where every entry's `services` is filtered to the tiers the job needs). Consumers default-enable omitted jobs via `fromJSON(...)['<job>'].enabled != false` and use `fromJSON(...)['<job>'].matrix` for `strategy.matrix`."
runs:
using: "node24"
main: "dist/index.js"
branding:
icon: "check-square"
color: "green"
@@ -0,0 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/graycoreio/github-actions-magento2/main/resolve-check-config/check-extension.schema.json",
"title": "graycoreio check-extension config",
"description": "Configuration consumed by the check-extension reusable workflow. Per-job toggles and settings live under `jobs`. Top-level remains open for future global keys.",
"type": "object",
"properties": {
"jobs": {
"type": "object",
"description": "Per-job configuration. Each key is a job name declared by check-extension; unknown keys are rejected.",
"properties": {
"unit-test-extension": { "$ref": "#/$defs/jobConfig" },
"compile-extension": { "$ref": "#/$defs/jobConfig" },
"coding-standard": { "$ref": "#/$defs/jobConfig" },
"integration_test": { "$ref": "#/$defs/jobConfig" }
},
"additionalProperties": false
}
},
"additionalProperties": true,
"$defs": {
"jobConfig": {
"description": "How a single job should be configured. Boolean form is shorthand for { enabled: <bool> }; object form allows extra per-job keys.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the job should run. Defaults to true when the key is present.",
"default": true
},
"services": {
"type": "array",
"description": "Tier names this job needs as GitHub Actions service containers. mysql is always implicit.",
"items": {
"type": "string",
"enum": ["search", "queue", "cache", "web"]
},
"uniqueItems": true
}
},
"additionalProperties": true
}
]
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/graycoreio/github-actions-magento2/main/resolve-check-config/check-store.schema.json",
"title": "graycoreio check-store config",
"description": "Configuration consumed by the check-store reusable workflow. Per-job toggles and settings live under `jobs`. Top-level remains open for future global keys.",
"type": "object",
"properties": {
"jobs": {
"type": "object",
"description": "Per-job configuration. Each key is a job name declared by check-store; unknown keys are rejected.",
"properties": {
"unit-test": { "$ref": "#/$defs/jobConfig" },
"coding-standard": { "$ref": "#/$defs/jobConfig" },
"smoke-test": { "$ref": "#/$defs/jobConfig" }
},
"additionalProperties": false
}
},
"additionalProperties": true,
"$defs": {
"jobConfig": {
"description": "How a single job should be configured. Boolean form is shorthand for { enabled: <bool> }; object form allows extra per-job keys.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the job should run. Defaults to true when the key is present.",
"default": true
}
},
"additionalProperties": true
}
]
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"$schema": "../check-extension.schema.json",
"jobs": {
"unit-test-extension": true,
"compile-extension": true,
"coding-standard": true,
"integration_test": {
"services": ["search", "cache"]
}
}
}
@@ -0,0 +1,6 @@
{
"$schema": "../check-extension.schema.json",
"jobs": {
"integration_test": false
}
}
@@ -0,0 +1,14 @@
{
"$schema": "../check-store.schema.json",
"jobs": {
"unit-test": true,
"coding-standard": true,
"integration-test": {
"services": ["search", "queue", "cache"]
},
"smoke-test": {
"services": ["search", "queue", "cache", "nginx", "php-fpm"],
"probes": ["page", "graphql"]
}
}
}
@@ -0,0 +1,6 @@
{
"$schema": "../check-store.schema.json",
"jobs": {
"smoke-test": false
}
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testMatch: ['**/*.spec.ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@graycoreio/github-actions-magento2-resolve-check-config",
"version": "1.0.0",
"description": "A Github Action that reads .github/<workflow>.json, validates it against the known job list, and emits resolved per-job configuration.",
"main": "index.js",
"private": true,
"scripts": {
"build": "npx esbuild --outfile=dist/index.js --platform=node --bundle --minify src/index.ts",
"test": "jest"
},
"author": "",
"license": "MIT",
"dependencies": {
"@actions/core": "0.0.0-PLACEHOLDER"
},
"devDependencies": {
"@types/jest": "0.0.0-PLACEHOLDER",
"jest": "0.0.0-PLACEHOLDER",
"ts-jest": "0.0.0-PLACEHOLDER"
}
}
+33
View File
@@ -0,0 +1,33 @@
import * as core from '@actions/core';
import * as fs from 'fs';
import * as nodePath from 'path';
import { assertKind } from './kind';
import { parseMatrixInput, parseRawConfig } from './parse';
import { resolveConfig } from './resolve';
export const run = async (): Promise<void> => {
try {
const kind = assertKind(core.getInput('kind', { required: true }));
const matrix = parseMatrixInput(core.getInput('matrix', { required: true }));
const configPath = core.getInput('config_path') || `.github/check-${kind}.json`;
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const absolute = nodePath.resolve(workspace, configPath);
let raw = {};
if (fs.existsSync(absolute)) {
const text = fs.readFileSync(absolute, 'utf-8');
raw = parseRawConfig(text);
core.info(`resolve-check-config: read ${absolute}`);
} else {
core.info(`resolve-check-config: ${absolute} not found — emitting defaults for every known job`);
}
const resolved = resolveConfig(raw, kind, matrix);
core.setOutput('resolved', JSON.stringify(resolved));
} catch (error) {
core.setFailed((error as Error).message);
}
}
run();
+26
View File
@@ -0,0 +1,26 @@
import { assertKind, isKind } from './kind';
describe('isKind / assertKind', () => {
it('accepts "store"', () => {
expect(isKind('store')).toBe(true);
expect(assertKind('store')).toBe('store');
});
it('accepts "extension"', () => {
expect(isKind('extension')).toBe(true);
expect(assertKind('extension')).toBe('extension');
});
it('rejects other strings', () => {
expect(isKind('taco')).toBe(false);
expect(() => assertKind('taco')).toThrowError(/`kind` must be 'store' or 'extension'/);
});
it('rejects empty input', () => {
expect(() => assertKind('')).toThrowError(/`kind` must be 'store' or 'extension'/);
});
it('rejects non-string input', () => {
expect(() => assertKind(undefined)).toThrowError(/`kind` must be 'store' or 'extension'/);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { Kind } from './types';
/**
* Type guard for the `kind` input. Use this when you have an
* `unknown` value (e.g. from `core.getInput`) and want to narrow it
* without throwing.
*/
export const isKind = (value: unknown): value is Kind =>
value === 'store' || value === 'extension';
/**
* Narrows an `unknown` (typically the raw action input) to `Kind` or
* throws a user-facing error naming the accepted values. Prefer this
* at the action boundary so a bad `kind` fails fast with a clear
* message rather than later as an obscure dispatch miss.
*/
export const assertKind = (value: unknown): Kind => {
if (!isKind(value)) {
throw new Error(`check-config: \`kind\` must be 'store' or 'extension' (got ${JSON.stringify(value)})`);
}
return value;
}
@@ -0,0 +1,66 @@
import { EXTENSION_JOBS, KNOWN_JOBS_EXTENSION, resolveExtensionConfig } from './extension';
import { Matrix } from '../types';
const MATRIX: Matrix = {
include: [{
php: '8.3',
services: {
mysql: { image: 'mysql:8' },
opensearch: { image: 'opensearchproject/opensearch:2' },
},
}],
};
describe('EXTENSION_JOBS', () => {
it('declares the check-extension jobs', () => {
expect(Object.keys(EXTENSION_JOBS).sort()).toEqual([
'coding-standard',
'compile-extension',
'integration_test',
'unit-test-extension',
]);
});
it('keeps KNOWN_JOBS_EXTENSION in sync with the map keys', () => {
expect([...KNOWN_JOBS_EXTENSION].sort()).toEqual(Object.keys(EXTENSION_JOBS).sort());
});
});
describe('resolveExtensionConfig', () => {
it('emits every known job', () => {
const resolved = resolveExtensionConfig({}, MATRIX);
expect(Object.keys(resolved).sort()).toEqual([
'coding-standard',
'compile-extension',
'integration_test',
'unit-test-extension',
]);
});
it('emits services={} for every job under the current defaults', () => {
const resolved = resolveExtensionConfig({}, MATRIX);
for (const name of Object.keys(resolved)) {
expect(resolved[name].matrix.include[0].services).toEqual({});
}
});
it('still accepts a caller-supplied services override', () => {
const resolved = resolveExtensionConfig(
{ jobs: { integration_test: { services: ['search'] } } },
MATRIX,
);
expect(Object.keys(resolved['integration_test'].matrix.include[0].services!)).toEqual(['opensearch']);
});
it('throws on a typo in the job name', () => {
expect(() => resolveExtensionConfig({ jobs: { 'inteegration_test': false } }, MATRIX)).toThrowError(
/unknown job "inteegration_test" for kind "extension"/
);
});
it('throws when a store-only job name is used', () => {
expect(() => resolveExtensionConfig({ jobs: { 'smoke-test': false } }, MATRIX)).toThrowError(
/unknown job "smoke-test" for kind "extension"/
);
});
});
@@ -0,0 +1,26 @@
import { resolveJobs } from '../parse';
import { JobDefaults, Matrix, RawConfig, ResolvedConfig } from '../types';
/**
* Per-job defaults for the `check-extension.yaml` reusable workflow.
* Edit this map when a job is added, removed, or renamed in that
* workflow — keys are validated against caller config and the values
* supply the default tier list used when the caller doesn't override
* `services` themselves.
*/
export const EXTENSION_JOBS: Record<string, JobDefaults> = {
'unit-test-extension': { services: [] },
'compile-extension': { services: [] },
'coding-standard': { services: [] },
'integration_test': { services: [] },
};
export const KNOWN_JOBS_EXTENSION: readonly string[] = Object.keys(EXTENSION_JOBS);
/**
* Resolves a parsed config file + supported-version matrix against
* the check-extension job list. Thin wrapper that binds the kind and
* the per-job defaults so callers don't repeat the wiring.
*/
export const resolveExtensionConfig = (raw: RawConfig, matrix: Matrix): ResolvedConfig =>
resolveJobs(raw, 'extension', EXTENSION_JOBS, matrix);
@@ -0,0 +1,93 @@
import { KNOWN_JOBS_STORE, resolveStoreConfig, STORE_JOBS } from './store';
import { Matrix } from '../types';
const MATRIX: Matrix = {
include: [{
php: '8.3',
services: {
mysql: { image: 'mysql:8' },
opensearch: { image: 'opensearchproject/opensearch:2' },
rabbitmq: { image: 'rabbitmq:3' },
valkey: { image: 'valkey:8' },
nginx: { image: 'nginx:1.27' },
'php-fpm': { image: 'php:8.3-fpm' },
},
}],
};
describe('STORE_JOBS', () => {
it('declares the check-store jobs', () => {
expect(Object.keys(STORE_JOBS).sort()).toEqual(['coding-standard', 'smoke-test', 'unit-test']);
});
it('declares smoke-test required tiers (end-user cannot toggle)', () => {
expect(STORE_JOBS['smoke-test'].services).toEqual([]);
expect([...STORE_JOBS['smoke-test'].requiredServices!].sort()).toEqual([
'cache',
'db',
'queue',
'search',
'web',
]);
});
it('exposes empty service defaults for unit-test and coding-standard', () => {
expect(STORE_JOBS['unit-test'].services).toEqual([]);
expect(STORE_JOBS['coding-standard'].services).toEqual([]);
});
it('keeps KNOWN_JOBS_STORE in sync with the map keys', () => {
expect([...KNOWN_JOBS_STORE].sort()).toEqual(Object.keys(STORE_JOBS).sort());
});
});
describe('resolveStoreConfig', () => {
it('emits every known job with default tier expansion, always including mysql for smoke-test', () => {
const resolved = resolveStoreConfig({}, MATRIX);
expect(Object.keys(resolved).sort()).toEqual(['coding-standard', 'smoke-test', 'unit-test']);
expect(resolved['unit-test'].matrix.include[0].services).toEqual({});
expect(Object.keys(resolved['smoke-test'].matrix.include[0].services!).sort()).toEqual([
'mysql',
'nginx',
'opensearch',
'php-fpm',
'rabbitmq',
'valkey',
]);
});
it('keeps every required service even when caller overrides smoke-test services to []', () => {
const resolved = resolveStoreConfig(
{ jobs: { 'smoke-test': { services: [] } } },
MATRIX,
);
expect(Object.keys(resolved['smoke-test'].matrix.include[0].services!).sort()).toEqual([
'mysql',
'nginx',
'opensearch',
'php-fpm',
'rabbitmq',
'valkey',
]);
});
it('honors enabled=false for a job', () => {
const resolved = resolveStoreConfig(
{ jobs: { 'smoke-test': false } },
MATRIX,
);
expect(resolved['smoke-test'].enabled).toBe(false);
});
it('throws on a typo in the job name', () => {
expect(() => resolveStoreConfig({ jobs: { 'smkoe-test': false } }, MATRIX)).toThrowError(
/unknown job "smkoe-test" for kind "store"/
);
});
it('throws when an extension-only job name is used', () => {
expect(() => resolveStoreConfig({ jobs: { 'unit-test-extension': false } }, MATRIX)).toThrowError(
/unknown job "unit-test-extension" for kind "store"/
);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { resolveJobs } from '../parse';
import { JobDefaults, Matrix, RawConfig, ResolvedConfig } from '../types';
/**
* Per-job defaults for the `check-store.yaml` reusable workflow.
* Edit this map when a job is added, removed, or renamed in that
* workflow — keys are validated against caller config and the values
* supply the default tier list used when the caller doesn't override
* `services` themselves.
*/
export const STORE_JOBS: Record<string, JobDefaults> = {
'unit-test': { services: [] },
'coding-standard': { services: [] },
'smoke-test': {
services: [],
requiredServices: ['db', 'search', 'queue', 'cache', 'web'],
},
};
export const KNOWN_JOBS_STORE: readonly string[] = Object.keys(STORE_JOBS);
/**
* Resolves a parsed config file + supported-version matrix against
* the check-store job list. Thin wrapper that binds the kind and the
* per-job defaults so callers don't repeat the wiring.
*/
export const resolveStoreConfig = (raw: RawConfig, matrix: Matrix): ResolvedConfig =>
resolveJobs(raw, 'store', STORE_JOBS, matrix);
+305
View File
@@ -0,0 +1,305 @@
import {
filterEntryServices,
filterMatrixForJob,
mergeRequiredTiers,
normalizeJobEntry,
parseMatrixInput,
parseRawConfig,
resolveJobs,
} from './parse';
import { JobDefaults, Matrix } from './types';
const FULL_SERVICES = {
mysql: { image: 'mysql:8' },
opensearch: { image: 'opensearchproject/opensearch:2' },
rabbitmq: { image: 'rabbitmq:3' },
valkey: { image: 'valkey:8' },
nginx: { image: 'nginx:1.27' },
'php-fpm': { image: 'php:8.3-fpm' },
};
const MATRIX: Matrix = {
include: [{ php: '8.3', services: { ...FULL_SERVICES } }],
};
const noDefaults: JobDefaults = { services: [] };
const smokeDefaults: JobDefaults = { services: ['search', 'queue', 'cache', 'web'] };
describe('normalizeJobEntry', () => {
it('defaults enabled=true and uses the default tiers when entry is undefined', () => {
expect(normalizeJobEntry('smoke-test', undefined, smokeDefaults)).toEqual({
enabled: true,
tiers: smokeDefaults.services,
});
});
it('treats true shorthand as enabled with defaults', () => {
expect(normalizeJobEntry('smoke-test', true, smokeDefaults)).toEqual({
enabled: true,
tiers: smokeDefaults.services,
});
});
it('treats false shorthand as disabled with defaults', () => {
expect(normalizeJobEntry('smoke-test', false, smokeDefaults)).toEqual({
enabled: false,
tiers: smokeDefaults.services,
});
});
it('empty object is enabled with defaults', () => {
expect(normalizeJobEntry('smoke-test', {}, smokeDefaults)).toEqual({
enabled: true,
tiers: smokeDefaults.services,
});
});
it('preserves enabled when explicitly set', () => {
expect(normalizeJobEntry('smoke-test', { enabled: false }, smokeDefaults)).toEqual({
enabled: false,
tiers: smokeDefaults.services,
});
});
it('overrides the default tiers when services is set', () => {
expect(normalizeJobEntry('smoke-test', { services: ['cache', 'web'] }, smokeDefaults)).toEqual({
enabled: true,
tiers: ['cache', 'web'],
});
});
it('accepts an empty services array as "no services"', () => {
expect(normalizeJobEntry('smoke-test', { services: [] }, smokeDefaults)).toEqual({
enabled: true,
tiers: [],
});
});
it('throws when entry is a non-array primitive other than boolean', () => {
expect(() => normalizeJobEntry('unit-test', 'true' as never, noDefaults)).toThrowError(
/must be a boolean or an object/
);
});
it('throws when entry is an array', () => {
expect(() => normalizeJobEntry('unit-test', [] as never, noDefaults)).toThrowError(/got array/);
});
it('throws when services is not an array', () => {
expect(() => normalizeJobEntry('smoke-test', { services: 'search' } as never, smokeDefaults)).toThrowError(
/services must be an array of tier names/
);
});
it('throws when services contains an unknown tier', () => {
expect(() => normalizeJobEntry('smoke-test', { services: ['llm'] } as never, smokeDefaults)).toThrowError(
/services contains unknown tier "llm"/
);
});
});
describe('mergeRequiredTiers', () => {
it('returns the input list when required is undefined', () => {
expect(mergeRequiredTiers(['cache'], undefined)).toEqual(['cache']);
});
it('returns the input list when required is empty', () => {
expect(mergeRequiredTiers(['cache'], [])).toEqual(['cache']);
});
it('prepends required tiers ahead of the input tiers', () => {
expect(mergeRequiredTiers(['cache', 'web'], ['db'])).toEqual(['db', 'cache', 'web']);
});
it('deduplicates when a required tier already appears in the input', () => {
expect(mergeRequiredTiers(['db', 'cache'], ['db'])).toEqual(['db', 'cache']);
});
it('deduplicates within required itself', () => {
expect(mergeRequiredTiers(['cache'], ['db', 'db'])).toEqual(['db', 'cache']);
});
});
describe('filterEntryServices', () => {
it('returns services={} for an empty tier list', () => {
const out = filterEntryServices({ php: '8.3', services: FULL_SERVICES }, []);
expect(out.services).toEqual({});
expect(out.php).toBe('8.3');
});
it('keeps only services in the requested tiers', () => {
const out = filterEntryServices({ php: '8.3', services: FULL_SERVICES }, ['cache', 'web']);
expect(Object.keys(out.services!).sort()).toEqual(['nginx', 'php-fpm', 'valkey']);
});
it('drops services that the matrix doesn\'t carry (elasticsearch absent)', () => {
const out = filterEntryServices({ services: { opensearch: FULL_SERVICES.opensearch } }, ['search']);
expect(Object.keys(out.services!)).toEqual(['opensearch']);
});
it('emits services={} when the entry has no services map', () => {
const out = filterEntryServices({ php: '8.3' }, ['cache']);
expect(out.services).toEqual({});
});
});
describe('filterMatrixForJob', () => {
it('preserves matrix shape, mapping every entry through filterEntryServices', () => {
const out = filterMatrixForJob(MATRIX, ['queue']);
expect(out.include).toHaveLength(1);
expect(Object.keys(out.include[0].services!)).toEqual(['rabbitmq']);
});
it('passes through unrelated top-level matrix keys', () => {
const out = filterMatrixForJob({ ...MATRIX, magento: ['2.4.7'] } as Matrix, []);
expect((out as Matrix).magento).toEqual(['2.4.7']);
});
});
describe('resolveJobs', () => {
const jobs: Record<string, JobDefaults> = {
'unit-test': noDefaults,
'smoke-test': smokeDefaults,
};
it('emits every known job, defaulted-enabled, when raw is empty', () => {
const out = resolveJobs({}, 'store', jobs, MATRIX);
expect(Object.keys(out).sort()).toEqual(['smoke-test', 'unit-test']);
expect(out['unit-test'].enabled).toBe(true);
expect(out['smoke-test'].enabled).toBe(true);
});
it('emits services={} on entries for a no-default job', () => {
const out = resolveJobs({}, 'store', jobs, MATRIX);
expect(out['unit-test'].matrix.include[0].services).toEqual({});
});
it('expands the smoke-test default tiers across the matrix entry', () => {
const out = resolveJobs({}, 'store', jobs, MATRIX);
expect(Object.keys(out['smoke-test'].matrix.include[0].services!).sort()).toEqual([
'nginx',
'opensearch',
'php-fpm',
'rabbitmq',
'valkey',
]);
});
it('applies a caller-supplied services override', () => {
const out = resolveJobs(
{ jobs: { 'smoke-test': { services: ['cache'] } } },
'store',
jobs,
MATRIX,
);
expect(Object.keys(out['smoke-test'].matrix.include[0].services!)).toEqual(['valkey']);
});
it('always merges requiredServices into the matrix even when caller overrides services', () => {
const withRequired: Record<string, JobDefaults> = {
'smoke-test': { ...smokeDefaults, requiredServices: ['db'] },
};
const out = resolveJobs(
{ jobs: { 'smoke-test': { services: ['cache'] } } },
'store',
withRequired,
MATRIX,
);
expect(Object.keys(out['smoke-test'].matrix.include[0].services!).sort()).toEqual(['mysql', 'valkey']);
});
it('keeps requiredServices even when caller overrides services to []', () => {
const withRequired: Record<string, JobDefaults> = {
'smoke-test': { ...smokeDefaults, requiredServices: ['db'] },
};
const out = resolveJobs(
{ jobs: { 'smoke-test': { services: [] } } },
'store',
withRequired,
MATRIX,
);
expect(Object.keys(out['smoke-test'].matrix.include[0].services!)).toEqual(['mysql']);
});
it('honors caller enabled=false but still emits a filtered matrix', () => {
const out = resolveJobs(
{ jobs: { 'smoke-test': false } },
'store',
jobs,
MATRIX,
);
expect(out['smoke-test'].enabled).toBe(false);
expect(out['smoke-test'].matrix.include[0].services).toBeDefined();
});
it('throws on unknown job names with the kind in the message', () => {
expect(() => resolveJobs({ jobs: { taco: false } }, 'store', jobs, MATRIX)).toThrowError(
/unknown job "taco" for kind "store"/
);
});
it('throws when `jobs` is not an object', () => {
expect(() => resolveJobs({ jobs: 'oops' } as never, 'store', jobs, MATRIX)).toThrowError(
/`jobs` must be an object/
);
});
});
describe('parseRawConfig', () => {
it('returns an empty object for empty input', () => {
expect(parseRawConfig('')).toEqual({});
});
it('returns an empty object for whitespace input', () => {
expect(parseRawConfig(' \n ')).toEqual({});
});
it('parses a valid object', () => {
expect(parseRawConfig('{"jobs": {"unit-test": true}}')).toEqual({
jobs: { 'unit-test': true },
});
});
it('throws on syntactically invalid JSON', () => {
expect(() => parseRawConfig('{not json}')).toThrowError(/failed to parse JSON/);
});
it('throws when top level is an array', () => {
expect(() => parseRawConfig('[]')).toThrowError(/top-level value must be an object/);
});
it('throws when top level is a primitive', () => {
expect(() => parseRawConfig('"hello"')).toThrowError(/top-level value must be an object/);
});
it('throws when top level is null', () => {
expect(() => parseRawConfig('null')).toThrowError(/top-level value must be an object/);
});
});
describe('parseMatrixInput', () => {
it('parses a valid matrix', () => {
const out = parseMatrixInput('{"include": [{"php": "8.3"}]}');
expect(out.include).toEqual([{ php: '8.3' }]);
});
it('throws on empty input', () => {
expect(() => parseMatrixInput('')).toThrowError(/`matrix` input is required/);
});
it('throws on invalid JSON', () => {
expect(() => parseMatrixInput('{nope}')).toThrowError(/failed to parse `matrix` input/);
});
it('throws when top level is an array', () => {
expect(() => parseMatrixInput('[]')).toThrowError(/`matrix` must be a JSON object/);
});
it('throws when include is missing', () => {
expect(() => parseMatrixInput('{}')).toThrowError(/`matrix.include` must be an array/);
});
it('throws when include is not an array', () => {
expect(() => parseMatrixInput('{"include": "nope"}')).toThrowError(/`matrix.include` must be an array/);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { JobDefaults, Kind, Matrix, MatrixEntry, RawConfig, RawJobConfig, ResolvedConfig, ResolvedJobConfig, Services } from './types';
import { isTier, servicesForTiers, Tier } from './tier-map';
/**
* Normalizes a single raw job entry to (enabled, tiers). Accepts
* the boolean shorthand and the object form. Validates the shape
* and the `services` tier list; throws on unexpected input. The
* caller supplies the per-job default tiers, used when `services`
* is omitted from the entry.
*/
export const normalizeJobEntry = (
jobName: string,
raw: RawJobConfig | undefined,
defaults: JobDefaults,
): { enabled: boolean; tiers: readonly Tier[] } => {
if (raw === undefined) {
return { enabled: true, tiers: defaults.services };
}
if (typeof raw === 'boolean') {
return { enabled: raw, tiers: defaults.services };
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error(
`check-config: job "${jobName}" must be a boolean or an object (got ${Array.isArray(raw) ? 'array' : typeof raw})`
);
}
const { enabled, services } = raw as { enabled?: unknown; services?: unknown };
const enabledValue = enabled === undefined ? true : Boolean(enabled);
if (services === undefined) {
return { enabled: enabledValue, tiers: defaults.services };
}
if (!Array.isArray(services)) {
throw new Error(`check-config: job "${jobName}".services must be an array of tier names`);
}
const tiers: Tier[] = [];
for (const value of services) {
if (!isTier(value)) {
throw new Error(`check-config: job "${jobName}".services contains unknown tier "${String(value)}"`);
}
tiers.push(value);
}
return { enabled: enabledValue, tiers };
}
/**
* Merges a job's `requiredServices` into the resolved tier list,
* deduplicating while preserving order (required tiers first, then
* the caller/default tiers in their original order).
*/
export const mergeRequiredTiers = (
tiers: readonly Tier[],
required: readonly Tier[] | undefined,
): readonly Tier[] => {
if (!required || required.length === 0) return tiers;
const seen = new Set<Tier>();
const merged: Tier[] = [];
for (const tier of required) {
if (!seen.has(tier)) { seen.add(tier); merged.push(tier); }
}
for (const tier of tiers) {
if (!seen.has(tier)) { seen.add(tier); merged.push(tier); }
}
return merged;
}
/**
* Returns a copy of `entry` with `services` filtered to the concrete
* names produced by expanding `tiers` through the tier-map. An empty
* tier list yields `services: {}`.
*/
export const filterEntryServices = (entry: MatrixEntry, tiers: readonly Tier[]): MatrixEntry => {
const keep = servicesForTiers(tiers);
const original = entry.services ?? {};
const filtered: Services = {};
for (const [name, config] of Object.entries(original)) {
if (keep.has(name)) filtered[name] = config;
}
return { ...entry, services: filtered };
}
/**
* Per-job filter applied to the supported-version matrix: returns a
* shallow clone with every entry's `services` narrowed to the tiers
* the job needs.
*/
export const filterMatrixForJob = (matrix: Matrix, tiers: readonly Tier[]): Matrix => ({
...matrix,
include: matrix.include.map(entry => filterEntryServices(entry, tiers)),
});
/**
* Shared per-kind resolver: walks the per-kind job map and emits one
* `ResolvedJobConfig` per known job. Caller-supplied jobs override
* the defaults; jobs the caller omitted still appear, carrying the
* default `enabled: true` and the default tier list. Rejects unknown
* job names from the config so typos surface in CI.
*/
export const resolveJobs = (
raw: RawConfig,
kind: Kind,
jobs: Record<string, JobDefaults>,
matrix: Matrix,
): ResolvedConfig => {
const rawJobs = raw.jobs ?? {};
if (rawJobs === null || typeof rawJobs !== 'object' || Array.isArray(rawJobs)) {
throw new Error(`check-config: \`jobs\` must be an object`);
}
for (const name of Object.keys(rawJobs)) {
if (!(name in jobs)) {
throw new Error(
`check-config: unknown job "${name}" for kind "${kind}". Known jobs: ${Object.keys(jobs).join(', ')}`
);
}
}
const resolved: ResolvedConfig = {};
for (const [name, defaults] of Object.entries(jobs)) {
const entry = (rawJobs as Record<string, RawJobConfig>)[name];
const { enabled, tiers } = normalizeJobEntry(name, entry, defaults);
const finalTiers = mergeRequiredTiers(tiers, defaults.requiredServices);
resolved[name] = {
enabled,
matrix: filterMatrixForJob(matrix, finalTiers),
} as ResolvedJobConfig;
}
return resolved;
}
/**
* Parses a JSON string into a RawConfig with shape validation
* (must be an object, not an array or primitive). Empty/whitespace
* input yields an empty config.
*/
export const parseRawConfig = (jsonText: string): RawConfig => {
const trimmed = jsonText.trim();
if (trimmed === '') return {};
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (e) {
throw new Error(`check-config: failed to parse JSON: ${(e as Error).message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`check-config: top-level value must be an object`);
}
return parsed as RawConfig;
}
/**
* Parses the `matrix` action input. Validates the top-level shape
* (must be an object with an `include` array) so a malformed input
* fails with a clear message at the boundary.
*/
export const parseMatrixInput = (jsonText: string): Matrix => {
const trimmed = jsonText.trim();
if (trimmed === '') {
throw new Error('check-config: `matrix` input is required');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (e) {
throw new Error(`check-config: failed to parse \`matrix\` input as JSON: ${(e as Error).message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('check-config: `matrix` must be a JSON object');
}
const include = (parsed as Record<string, unknown>).include;
if (!Array.isArray(include)) {
throw new Error('check-config: `matrix.include` must be an array');
}
return parsed as Matrix;
}
+39
View File
@@ -0,0 +1,39 @@
import { resolveConfig } from './resolve';
import { Matrix } from './types';
const MATRIX: Matrix = {
include: [{
php: '8.3',
services: {
mysql: { image: 'mysql:8' },
opensearch: { image: 'opensearchproject/opensearch:2' },
rabbitmq: { image: 'rabbitmq:3' },
valkey: { image: 'valkey:8' },
nginx: { image: 'nginx:1.27' },
'php-fpm': { image: 'php:8.3-fpm' },
},
}],
};
describe('resolveConfig', () => {
it('routes kind=store to the store resolver', () => {
const resolved = resolveConfig({ jobs: { 'smoke-test': false } }, 'store', MATRIX);
expect(resolved['smoke-test'].enabled).toBe(false);
expect(resolved['unit-test'].enabled).toBe(true);
});
it('routes kind=extension to the extension resolver', () => {
const resolved = resolveConfig({ jobs: { 'compile-extension': false } }, 'extension', MATRIX);
expect(resolved['compile-extension'].enabled).toBe(false);
expect(resolved['integration_test'].enabled).toBe(true);
});
it('rejects a job name from the other kind', () => {
expect(() => resolveConfig({ jobs: { 'smoke-test': false } }, 'extension', MATRIX)).toThrowError(
/unknown job "smoke-test" for kind "extension"/
);
expect(() => resolveConfig({ jobs: { 'unit-test-extension': false } }, 'store', MATRIX)).toThrowError(
/unknown job "unit-test-extension" for kind "store"/
);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { Kind, Matrix, RawConfig, ResolvedConfig } from './types';
import { resolveStoreConfig } from './kinds/store';
import { resolveExtensionConfig } from './kinds/extension';
/**
* Dispatches to the per-kind resolver. Each kind owns its own list
* of jobs and per-job defaults; this function just routes the call
* and forwards the supported-version matrix.
*/
export const resolveConfig = (raw: RawConfig, kind: Kind, matrix: Matrix): ResolvedConfig => {
if (kind === 'store') return resolveStoreConfig(raw, matrix);
return resolveExtensionConfig(raw, matrix);
}
+46
View File
@@ -0,0 +1,46 @@
import { isTier, servicesForTiers, TIER_TO_SERVICES } from './tier-map';
describe('isTier', () => {
it('accepts every key in TIER_TO_SERVICES', () => {
for (const tier of Object.keys(TIER_TO_SERVICES)) {
expect(isTier(tier)).toBe(true);
}
});
it('rejects unknown strings', () => {
expect(isTier('llm')).toBe(false);
expect(isTier('')).toBe(false);
});
it('rejects non-strings', () => {
expect(isTier(42)).toBe(false);
expect(isTier(null)).toBe(false);
expect(isTier(undefined)).toBe(false);
});
});
describe('servicesForTiers', () => {
it('returns an empty set for an empty tier list', () => {
expect([...servicesForTiers([])]).toEqual([]);
});
it('expands a single tier to its concrete service names', () => {
expect([...servicesForTiers(['queue'])]).toEqual(['rabbitmq']);
});
it('expands the search tier to both implementations', () => {
expect([...servicesForTiers(['search'])].sort()).toEqual(['elasticsearch', 'opensearch']);
});
it('expands the web tier to nginx + php-fpm', () => {
expect([...servicesForTiers(['web'])].sort()).toEqual(['nginx', 'php-fpm']);
});
it('unions across multiple tiers', () => {
expect([...servicesForTiers(['cache', 'queue'])].sort()).toEqual(['rabbitmq', 'redis', 'valkey']);
});
it('deduplicates if the same tier appears twice', () => {
expect([...servicesForTiers(['queue', 'queue'])]).toEqual(['rabbitmq']);
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* A category of services that can be selected for a job. Mirrors the
* `Tier` concept in `supported-version` but adds the `web` tier so the
* smoke-test and integration jobs can opt the nginx + php-fpm pair in
* or out as a unit. Intentionally narrower than supported-version's
* tier set: this file only names tiers that the resolve-check-config
* schema lets callers toggle.
*/
export type Tier = 'db' | 'search' | 'queue' | 'cache' | 'web';
/**
* Expansion of each tier to the concrete service names that may
* appear in a supported-version matrix entry's `services` map.
* Filtering a matrix entry's services for a tier list means keeping
* the keys that union to the values of the selected tiers here.
*/
export const TIER_TO_SERVICES: Record<Tier, readonly string[]> = {
db: ['mysql'],
search: ['opensearch', 'elasticsearch'],
queue: ['rabbitmq'],
cache: ['valkey', 'redis'],
web: ['nginx', 'php-fpm'],
};
export const isTier = (value: unknown): value is Tier =>
typeof value === 'string' && value in TIER_TO_SERVICES;
/**
* Returns the flat set of concrete service names for a list of tiers.
* Used to filter a matrix entry's `services` map down to only the
* containers a particular job actually needs.
*/
export const servicesForTiers = (tiers: readonly Tier[]): Set<string> => {
const result = new Set<string>();
for (const tier of tiers) {
for (const name of TIER_TO_SERVICES[tier]) result.add(name);
}
return result;
}
+100
View File
@@ -0,0 +1,100 @@
import { Tier } from './tier-map';
/**
* Which reusable workflow a config belongs to. Selects the known-job
* list used for validation and the default config path.
*/
export type Kind = 'store' | 'extension';
/**
* A single service container definition from supported-version's
* matrix output. We don't model the inner shape here — we just
* preserve unknown keys when filtering.
*/
export interface ServiceConfig {
image: string;
env?: Record<string, string>;
ports?: string[];
options?: string;
volumes?: string[];
}
export interface Services {
[serviceName: string]: ServiceConfig;
}
/**
* One row of supported-version's matrix. Carries the PHP/Composer/etc
* coordinates plus the concrete `services` map this job should bring
* up. We type the known fields supported-version emits and allow
* extras to pass through untouched.
*/
export interface MatrixEntry {
services?: Services;
[key: string]: unknown;
}
/**
* The matrix shape emitted by supported-version, suitable for
* `strategy.matrix` in GitHub Actions.
*/
export interface Matrix {
include: MatrixEntry[];
[key: string]: unknown;
}
/**
* Per-job static defaults declared by each kind module.
*
* `services` is the tier list used when the caller's config does not
* override it — these tiers are user-toggleable through the schema.
*
* `requiredServices` is always merged in on top of the resolved list,
* regardless of caller overrides. Use it for tiers a job structurally
* cannot run without (e.g. mysql for a running store smoke-test) and
* which therefore should not appear in the user-facing schema enum.
*/
export interface JobDefaults {
services: readonly Tier[];
requiredServices?: readonly Tier[];
}
/**
* Resolved per-job output. `enabled` mirrors the input boolean (or
* `enabled` key); `matrix` is supported-version's matrix with each
* entry's `services` filtered to the tiers this job needs.
*/
export interface ResolvedJobConfig {
enabled: boolean;
matrix: Matrix;
[key: string]: unknown;
}
/**
* Map of job-name -> resolved config. Keys are exactly the job names
* declared by the kind module (omitted-by-caller jobs still appear,
* carrying defaults so the consumer's `if:` guard works uniformly).
*/
export interface ResolvedConfig {
[jobName: string]: ResolvedJobConfig;
}
/**
* Shape of a single per-job entry in the user's JSON config file.
* - `true` / `false`: shorthand for `{ enabled: true|false }`
* - object: explicit enabled flag plus an optional tier list under
* `services` (validated against the per-kind schema).
*/
export type RawJobConfig =
| boolean
| { enabled?: boolean; services?: string[]; [key: string]: unknown };
/**
* Top-level shape of the user's JSON config file. Job toggles live
* under `jobs`; the rest of the top level is reserved for future
* global keys.
*/
export interface RawConfig {
jobs?: { [jobName: string]: RawJobConfig };
[key: string]: unknown;
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"esModuleInterop": true,
"typeRoots": ["../node_modules/@types"],
"types": ["jest"]
}
}