mirror of
https://github.com/graycoreio/github-actions-magento2.git
synced 2026-06-08 19:46:41 +00:00
feat(resolve-check-config): add ability to use a config file to adjust jobs (#255)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+69
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
clearMocks: true,
|
||||||
|
moduleFileExtensions: ['js', 'ts'],
|
||||||
|
testMatch: ['**/*.spec.ts'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': 'ts-jest'
|
||||||
|
},
|
||||||
|
verbose: true
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"typeRoots": ["../node_modules/@types"],
|
||||||
|
"types": ["jest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user