Compare commits

...

21 Commits

Author SHA1 Message Date
GrayBot 4df4b25e05 chore: release 8.3.0 (#280) 2026-05-25 09:31:20 -04:00
Damien Retzinger fa8e597365 feat(check-store): use the project when computing underlying version requirements 2026-05-25 09:27:28 -04:00
Damien Retzinger 1ea5a10ef6 feat(get-magento-version): emit supported-version project name as an output 2026-05-25 09:26:33 -04:00
Damien Retzinger 863444afbd feat(get-magento-version): add support for MageOS minimal distro 2026-05-25 09:15:50 -04:00
Damien Retzinger befe0807f7 feat(supported-version): add support for MageOS Minimal edition 2026-05-25 09:11:52 -04:00
Marcel 1e63c019ed feat(supported-version): add support for MageOS 3
Co-authored-by: Damien Retzinger <damienwebdev@gmail.com>
2026-05-25 08:57:42 -04:00
GrayBot ebfdeb0b73 chore: restore internal action refs to @main (#271) 2026-05-17 19:18:30 -04:00
GrayBot 8a0f197a13 chore: release 8.2.0 (#270) 2026-05-17 19:17:32 -04:00
Damien Retzinger 0bf08ef692 feat(check-extension): allow configuraton via .github/check-extension.json (#269) 2026-05-17 19:09:18 -04:00
Damien Retzinger 35c1ace2bc feat(resolve-check-config): defined required integration test services required (#269) 2026-05-17 19:09:13 -04:00
GrayBot d07afbacd0 chore: restore internal action refs to @main (#268) 2026-05-17 18:02:52 -04:00
GrayBot b71bb8b4aa chore: release 8.1.0 (#266) 2026-05-17 17:43:18 -04:00
Damien Retzinger e39dd46f9c feat(check-store): add smoke-test action and use resolve-check-config (#255) 2026-05-17 17:40:33 -04:00
Damien Retzinger b98313e100 feat(resolve-check-config): add ability to use a config file to adjust jobs (#255) 2026-05-17 17:18:50 -04:00
Damien Retzinger 0c7d14d885 feat(configure-service-nginx): add ability to adjust nginx conf after init (#255) 2026-05-17 17:18:48 -04:00
Damien Retzinger 6d4ca8d669 feat(setup-install): add a container_id input to run setup:install against a specific container (#255) 2026-05-17 17:18:46 -04:00
Damien Retzinger b790da1859 feat(smoke-test): add simple smoke test action (#255) 2026-05-17 17:18:43 -04:00
Damien Retzinger e89f6ad2e0 feat(supported-version): add service_preferences and support for php-fpm and nginx (#255) 2026-05-17 17:18:40 -04:00
Damien Retzinger 8e82fcc893 fix(check-extension): only run coding-standard on most recent version of Magento (#265) 2026-05-16 12:44:56 -04:00
Damien Retzinger 83ef32c838 ci: remove awkward commit requirements for graduating releases 2026-05-14 17:25:52 -04:00
GrayBot de7c47f07d chore: restore internal action refs to @main (#264) 2026-05-14 13:53:32 -04:00
92 changed files with 2799 additions and 248 deletions
+42 -14
View File
@@ -42,15 +42,28 @@ on:
description: "Your composer credentials (typically a stringified json object of the contents of your auth.json)"
jobs:
compute_resolved:
runs-on: ubuntu-latest
outputs:
resolved: ${{ steps.resolve.outputs.resolved }}
steps:
- uses: graycoreio/github-actions-magento2/resolve-check-config@v8.3.0
id: resolve
with:
kind: extension
matrix: ${{ inputs.matrix }}
unit-test-extension:
runs-on: ${{ matrix.os }}
needs: compute_resolved
if: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['unit-test-extension'].enabled != false }}
strategy:
matrix: ${{ fromJSON(inputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['unit-test-extension'].matrix }}
fail-fast: ${{ inputs.fail-fast }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -79,7 +92,7 @@ jobs:
env:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key && format('{0} | {1}', inputs.composer_cache_key, matrix.magento) || matrix.magento }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -113,13 +126,15 @@ jobs:
compile-extension:
runs-on: ${{ matrix.os }}
needs: compute_resolved
if: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['compile-extension'].enabled != false }}
strategy:
matrix: ${{ fromJSON(inputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['compile-extension'].matrix }}
fail-fast: ${{ inputs.fail-fast }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -148,7 +163,7 @@ jobs:
env:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key && format('{0} | {1}', inputs.composer_cache_key, matrix.magento) || matrix.magento }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -161,14 +176,25 @@ jobs:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
COMPOSER_MIRROR_PATH_REPOS: 1
- uses: graycoreio/github-actions-magento2/setup-di-compile@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-di-compile@v8.3.0
with:
path: ${{ steps.setup-magento.outputs.path }}
compute_latest_matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0
id: supported-version
with:
kind: latest
coding-standard:
runs-on: ${{ matrix.os }}
needs: compute_latest_matrix
strategy:
matrix: ${{ fromJSON(inputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_latest_matrix.outputs.matrix) }}
fail-fast: ${{ inputs.fail-fast }}
steps:
- uses: actions/checkout@v6
@@ -179,25 +205,27 @@ jobs:
tools: composer:v${{ matrix.composer }}
coverage: none
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key && format('{0} | {1}', inputs.composer_cache_key, matrix.magento) || matrix.magento }}
- uses: graycoreio/github-actions-magento2/coding-standard@v8.0.0
- uses: graycoreio/github-actions-magento2/coding-standard@v8.3.0
with:
path: ${{ inputs.path }}
composer_auth: ${{ secrets.composer_auth }}
integration_test:
runs-on: ${{ matrix.os }}
needs: compute_resolved
if: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['integration_test'].enabled != false }}
strategy:
matrix: ${{ fromJSON(inputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_resolved.outputs.resolved)['integration_test'].matrix }}
fail-fast: ${{ inputs.fail-fast }}
services: ${{ matrix.services }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -226,7 +254,7 @@ jobs:
env:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key && format('{0} | {1}', inputs.composer_cache_key, matrix.magento) || matrix.magento }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -239,7 +267,7 @@ jobs:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
COMPOSER_MIRROR_PATH_REPOS: 1
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0
id: magento-version
with:
working-directory: ${{ steps.setup-magento.outputs.path }}
+80 -11
View File
@@ -35,7 +35,7 @@ jobs:
compute_matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.supported-version.outputs.matrix }}
resolved: ${{ steps.resolve.outputs.resolved }}
steps:
- uses: actions/checkout@v6
if: inputs.store_artifact_name == ''
@@ -46,22 +46,30 @@ jobs:
name: ${{ inputs.store_artifact_name }}
path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0
id: get-magento-version
with:
working-directory: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0
id: supported-version
with:
project: ${{ steps.get-magento-version.outputs.supported_version_project }}
kind: custom
custom_versions: ${{ steps.get-magento-version.outputs.project }}:${{ fromJSON(steps.get-magento-version.outputs.version) }}
- uses: graycoreio/github-actions-magento2/resolve-check-config@v8.3.0
id: resolve
with:
kind: store
matrix: ${{ steps.supported-version.outputs.matrix }}
unit-test:
runs-on: ${{ matrix.os }}
needs: compute_matrix
if: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['unit-test'].enabled != false }}
strategy:
matrix: ${{ fromJSON(needs.compute_matrix.outputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['unit-test'].matrix }}
steps:
- uses: actions/checkout@v6
@@ -73,7 +81,7 @@ jobs:
name: ${{ inputs.store_artifact_name }}
path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -82,7 +90,7 @@ jobs:
working-directory: ${{ inputs.path }}
composer_auth: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -122,8 +130,9 @@ jobs:
coding-standard:
runs-on: ${{ matrix.os }}
needs: compute_matrix
if: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['coding-standard'].enabled != false }}
strategy:
matrix: ${{ fromJSON(needs.compute_matrix.outputs.matrix) }}
matrix: ${{ fromJSON(needs.compute_matrix.outputs.resolved)['coding-standard'].matrix }}
steps:
- uses: actions/checkout@v6
@@ -135,7 +144,7 @@ jobs:
name: ${{ inputs.store_artifact_name }}
path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -144,7 +153,7 @@ jobs:
working-directory: ${{ inputs.path }}
composer_auth: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -169,7 +178,67 @@ jobs:
EOF
fi
- uses: graycoreio/github-actions-magento2/coding-standard@v8.0.0
- uses: graycoreio/github-actions-magento2/coding-standard@v8.3.0
with:
path: ${{ steps.setup-magento.outputs.path }}
composer_auth: ${{ secrets.composer_auth }}
composer_auth: ${{ secrets.composer_auth }}
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:
- uses: actions/checkout@v6
if: inputs.store_artifact_name == ''
- uses: actions/download-artifact@v8
if: inputs.store_artifact_name != ''
with:
name: ${{ inputs.store_artifact_name }}
path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0
id: setup-magento
with:
php-version: ${{ matrix.php }}
tools: composer:v${{ matrix.composer }}
mode: store
working-directory: ${{ inputs.path }}
composer_auth: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0
with:
composer_cache_key: ${{ inputs.composer_cache_key }}
working-directory: ${{ steps.setup-magento.outputs.path }}
stamp: ${{ inputs.stamp }}
- name: Composer install
working-directory: ${{ steps.setup-magento.outputs.path }}
run: composer install
env:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
- uses: graycoreio/github-actions-magento2/setup-install@v8.3.0
id: setup-install
with:
services: ${{ toJSON(matrix.services) }}
path: ${{ steps.setup-magento.outputs.path }}
container_id: ${{ job.services['php-fpm'].id }}
extra_args: --magento-init-params=MAGE_MODE=developer
- uses: graycoreio/github-actions-magento2/configure-service-nginx@v8.3.0
with:
container_id: ${{ job.services.nginx.id }}
magento_path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/smoke-test@v8.3.0
with:
kind: page
- uses: graycoreio/github-actions-magento2/smoke-test@v8.3.0
with:
kind: graphql
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
COMPOSER_AUTH: ${{ secrets.composer_auth }}
name: Create Magento ${{ matrix.magento }} Project
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0
id: magento-version
with:
working-directory: ${{ inputs.magento_directory }}
+3 -67
View File
@@ -7,21 +7,20 @@ on:
workflow_dispatch:
inputs:
release-mode:
description: 'auto = follow conventional commits; rc = bump the rc suffix; stable = open a PR that graduates the current rc to a stable release.'
description: 'auto = follow conventional commits; rc = bump the rc suffix; graduate = graduate the current rc to a stable release.'
type: choice
required: false
default: auto
options:
- auto
- rc
- stable
- graduate
env:
RELEASE_BRANCH: release-please--branches--main--components--github-actions-magento2
jobs:
release-please:
if: github.event_name != 'workflow_dispatch' || inputs.release-mode != 'stable'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -32,7 +31,7 @@ jobs:
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.GRAYCORE_GITHUB_TOKEN }}
config-file: ${{ inputs.release-mode == 'rc' && 'release-please-config.rc.json' || 'release-please-config.json' }}
config-file: ${{ inputs.release-mode == 'rc' && 'release-please-config.rc.json' || (inputs.release-mode == 'graduate' && 'release-please-config.graduate.json' || 'release-please-config.json') }}
- name: Check if release branch exists
id: branch-check
@@ -85,69 +84,6 @@ jobs:
git commit -m "chore: pin internal action refs to ${{ steps.pin-refs.outputs.VERSION }}"
git push origin ${{ env.RELEASE_BRANCH }}
graduate:
if: github.event_name == 'workflow_dispatch' && inputs.release-mode == 'stable'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
with:
ref: main
token: ${{ secrets.GRAYBOT_PIN_BACK_PAT }}
- name: Compute graduate version
id: graduate
run: |
CURRENT=$(jq -r '."."' .release-please-manifest.json)
STABLE="${CURRENT%%-*}"
if [ "$CURRENT" = "$STABLE" ]; then
echo "::error::Manifest version ${CURRENT} has no prerelease suffix; nothing to graduate."
exit 1
fi
echo "CURRENT=${CURRENT}" >> $GITHUB_OUTPUT
echo "STABLE=${STABLE}" >> $GITHUB_OUTPUT
- name: Open graduate PR
env:
GRAYBOT_GPG_KEY: ${{ secrets.GRAYBOT_GPG_KEY }}
GH_TOKEN: ${{ secrets.GRAYBOT_PIN_BACK_PAT }}
CURRENT: ${{ steps.graduate.outputs.CURRENT }}
STABLE: ${{ steps.graduate.outputs.STABLE }}
run: |
echo "$GRAYBOT_GPG_KEY" | gpg --batch --import
export GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d/ -f2)
git config --global user.signingkey $GPG_KEY_ID
git config --global commit.gpgSign true
git config --global user.email "automation@graycore.io"
git config --global user.name "Beep Boop"
BRANCH="chore/graduate-v${STABLE}"
git checkout -b "$BRANCH"
git commit --allow-empty \
-m "chore: graduate ${CURRENT} to ${STABLE}" \
-m "Release-As: ${STABLE}"
git push --force origin "$BRANCH"
EXISTING=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty')
PR_BODY=$(cat <<EOF
Graduates the release line from \`${CURRENT}\` to a stable \`${STABLE}\`.
When this PR is merged to \`main\`, release-please will pick up the \`Release-As\` footer below and open a release PR for \`v${STABLE}\`.
Release-As: ${STABLE}
EOF
)
if [ -z "$EXISTING" ]; then
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore: graduate ${CURRENT} → ${STABLE}" \
--body "$PR_BODY"
else
gh pr edit "$EXISTING" --body "$PR_BODY"
echo "PR #$EXISTING already exists for $BRANCH — updated body"
fi
pinback:
needs: release-please
if: needs.release-please.outputs.releases_created == 'true'
+1 -1
View File
@@ -1 +1 @@
{".":"8.0.0"}
{".":"8.3.0"}
+36
View File
@@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [8.3.0](https://github.com/graycoreio/github-actions-magento2/compare/v8.2.0...v8.3.0) (2026-05-25)
### Features
* **check-store:** use the project when computing underlying version requirements ([fa8e597](https://github.com/graycoreio/github-actions-magento2/commit/fa8e59736563d5969f5c8ebaccd23c48f0628721))
* **get-magento-version:** add support for MageOS minimal distro ([863444a](https://github.com/graycoreio/github-actions-magento2/commit/863444afbd137d32157392b964f06503f021ee6c))
* **get-magento-version:** emit supported-version project name as an output ([1ea5a10](https://github.com/graycoreio/github-actions-magento2/commit/1ea5a10ef67d6fda8d10e078895adc9bea434477))
* **supported-version:** add support for MageOS 3 ([1e63c01](https://github.com/graycoreio/github-actions-magento2/commit/1e63c019edb63ee0bcd4576b4125b73520ca8864))
* **supported-version:** add support for MageOS Minimal edition ([befe080](https://github.com/graycoreio/github-actions-magento2/commit/befe0807f7636c125d7e650f2d08012b28554a54))
## [8.2.0](https://github.com/graycoreio/github-actions-magento2/compare/v8.1.0...v8.2.0) (2026-05-17)
### Features
* **check-extension:** allow configuraton via .github/check-extension.json ([#269](https://github.com/graycoreio/github-actions-magento2/issues/269)) ([0bf08ef](https://github.com/graycoreio/github-actions-magento2/commit/0bf08ef69291090e5fe3e3d47cb432c6c9107f30))
* **resolve-check-config:** defined required integration test services required ([#269](https://github.com/graycoreio/github-actions-magento2/issues/269)) ([35c1ace](https://github.com/graycoreio/github-actions-magento2/commit/35c1ace2bc68be1356dc6565a8a05ff02e33d75d))
## [8.1.0](https://github.com/graycoreio/github-actions-magento2/compare/v8.0.0...v8.1.0) (2026-05-17)
### Features
* **check-store:** add smoke-test action and use resolve-check-config ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([e39dd46](https://github.com/graycoreio/github-actions-magento2/commit/e39dd46f9c53a0d2625cd5d19ad1cf18565b8c5c))
* **configure-service-nginx:** add ability to adjust nginx conf after init ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([0c7d14d](https://github.com/graycoreio/github-actions-magento2/commit/0c7d14d88573d92c81654b1107ef6a9e4d918cff))
* **resolve-check-config:** add ability to use a config file to adjust jobs ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([b98313e](https://github.com/graycoreio/github-actions-magento2/commit/b98313e10044a0a6a04546d3ff8ebe3a3f284f5b))
* **setup-install:** add a container_id input to run setup:install against a specific container ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([6d4ca8d](https://github.com/graycoreio/github-actions-magento2/commit/6d4ca8d669164d840d99e8af721309abb9f204ea))
* **smoke-test:** add simple smoke test action ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([b790da1](https://github.com/graycoreio/github-actions-magento2/commit/b790da18597e58a9013cc0f7e2c923f08c82f813))
* **supported-version:** add service_preferences and support for php-fpm and nginx ([#255](https://github.com/graycoreio/github-actions-magento2/issues/255)) ([e89f6ad](https://github.com/graycoreio/github-actions-magento2/commit/e89f6ad2e08fcaa03cba92c8371e60ba67b3cf62))
### Bug Fixes
* **check-extension:** only run coding-standard on most recent version of Magento ([#265](https://github.com/graycoreio/github-actions-magento2/issues/265)) ([8e82fcc](https://github.com/graycoreio/github-actions-magento2/commit/8e82fcc89354c83523781c1f5fd4622dec19ca7b))
## [8.0.0](https://github.com/graycoreio/github-actions-magento2/compare/v8.0.0-rc.2...v8.0.0) (2026-05-14)
+4 -4
View File
@@ -33,7 +33,7 @@ The `composer.lock` hash is derived from `working-directory/composer.lock` using
### Extension (download cache only)
```yml
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0 # x-release-please-version
with:
composer_cache_key: ${{ inputs.composer_cache_key }}
```
@@ -41,13 +41,13 @@ The `composer.lock` hash is derived from `working-directory/composer.lock` using
### Extension or store (download + vendor stamp cache)
```yml
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0 # x-release-please-version
id: setup-magento
with:
mode: extension # or store
# ...
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0 # x-release-please-version
with:
composer_cache_key: ${{ inputs.composer_cache_key }}
working-directory: ${{ steps.setup-magento.outputs.path }}
@@ -69,7 +69,7 @@ As such, use `stamp: true` when `composer.lock` is stable across most runs — a
> **Dependabot / Renovate:** Each time a Dependabot or Renovate PR is merged, the remaining open PRs rebase and each produces a new `composer.lock`. This cascades into a large number of unique cache entries, inflating storage costs without delivering proportional compute savings — because automated PRs are not waiting on fast feedback. The fix is to disable stamp caching for automated dependency PRs entirely:
>
> ```yml
> - uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0 # x-release-please-version
> - uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0 # x-release-please-version
> with:
> stamp: ${{ github.actor != 'dependabot[bot]' }}
> ```
+1 -1
View File
@@ -62,7 +62,7 @@ runs:
exit 1
fi
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0
id: cache-magento-get-magento-version
with:
working-directory: ${{ inputs.working-directory }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
tools: composer:v2
coverage: none
- uses: graycoreio/github-actions-magento2/coding-standard@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/coding-standard@v8.3.0 # x-release-please-version
with:
path: app/code # Optional, defaults to .
version: 25 # Optional, will use the latest if omitted.
+2 -2
View File
@@ -52,12 +52,12 @@ runs:
fi
- name: Get Composer Version
uses: graycoreio/github-actions-magento2/get-composer-version@v8.0.0
uses: graycoreio/github-actions-magento2/get-composer-version@v8.3.0
id: get-composer-version
if: steps.check-installed.outputs.installed != 'true'
- name: Check if allow-plugins option is available for this version of composer
uses: graycoreio/github-actions-magento2/semver-compare@v8.0.0
uses: graycoreio/github-actions-magento2/semver-compare@v8.3.0
id: is-allow-plugins-available
if: steps.check-installed.outputs.installed != 'true'
with:
+71
View File
@@ -0,0 +1,71 @@
# Configure Service Nginx
A GitHub Action that pushes a Magento-aware nginx configuration into an **already-running nginx service container**, reloads it, and waits for the container's healthcheck to pass.
The action does **not** start nginx. It assumes the calling workflow declared nginx as a `services:` container (typically alongside `php-fpm`, and other mandatory Magento services).
The shipped `default.conf` is a thin outer wrapper that defines a `fastcgi_backend` upstream pointing at `php-fpm:9000`, sets `$MAGE_ROOT`, and includes Magento's own `nginx.conf.sample` from your own Magento install. All real routing rules come from Magento's bundled file.
## When to use this
Use this action when you have a workflow that:
1. Boots nginx as `services:` containers with the workspace bind-mounted at `/var/www/html`, and
2. Wants those containers to actually serve a Magento store you've already installed into the workspace (e.g. for end-to-end smoke tests, integration tests, or any HTTP-driven check).
You do **not** need this action if:
- You're not running nginx at all (unit tests, coding standards, static analysis).
- nginx is started by something other than a GitHub Actions `services:` block
- You've already configured nginx some other way and don't need a Magento-ready outer config.
## Prerequisites
- An nginx service container is running on the same Docker host as the runner, with an image matching the `image` input.
- A `php-fpm` container, the included `default.conf` will set up a fast-cgi backend to `php-fpm:9000`.
- The runner's workspace (`$GITHUB_WORKSPACE`) is bind-mounted into the nginx container at `/var/www/html`.
- A Magento install exists at `${{ inputs.magento_path }}` relative to the workspace, with `nginx.conf.sample` present (it ships with Magento by default after `composer install`).
## Inputs
| Input | Required | Default | Description |
| ------------------------ | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `container_id` | Yes | — | The ID of the running nginx service container. Pass `${{ job.services.nginx.id }}` (replace `nginx` with whatever you named the service). |
| `magento_path` | No | `.` | Path to the Magento store, relative to the GitHub workspace. Combined with the `/var/www/html` mount prefix to compute the in-container `MAGE_ROOT`. |
| `health_timeout_seconds` | No | `10` | How long to wait for nginx to report `healthy` after the config is pushed and the container restarts. |
## Usage
```yml
jobs:
smoke-test:
runs-on: ubuntu-latest
services:
## There are other service requirements for Magento, but this is just for the explanation of this service
nginx:
image: nginx:1.27-alpine
ports: ["80:80"]
volumes:
- ${{ github.workspace }}:/var/www/html
options: --health-cmd "nginx -t" --health-interval=10s --health-retries=3
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@main
id: setup-magento
with:
mode: store
- run: composer install
working-directory: ${{ steps.setup-magento.outputs.path }}
- uses: graycoreio/github-actions-magento2/setup-install@main
with:
services: ${{ toJSON(matrix.services) }}
path: ${{ steps.setup-magento.outputs.path }}
container_id: ${{ job.services['php-fpm'].id }}
- uses: graycoreio/github-actions-magento2/configure-service-nginx@main
with:
container_id: ${{ job.services.nginx.id }}
magento_path: ${{ inputs.path }}
- uses: graycoreio/github-actions-magento2/smoke-test@main
with:
kind: page
```
+67
View File
@@ -0,0 +1,67 @@
name: "Configure nginx service container"
author: "Graycore"
description: "Pushes a Magento-aware nginx config into an already-running nginx service container, restarts it, and waits for the container healthcheck to pass."
inputs:
container_id:
description: "The ID of the running nginx service container. Pass the value of `job.services.nginx.id` (replace `nginx` with whatever you named the service in `services:`)."
required: true
magento_path:
description: "Path to the Magento store, relative to the GitHub workspace. The workspace is mounted at /var/www/html in both the nginx and php-fpm service containers, so this is combined with that prefix to compute MAGE_ROOT."
required: false
default: "."
health_timeout_seconds:
description: "How long to wait for nginx to become healthy after the config is pushed and the container is restarted."
required: false
default: "10"
runs:
using: "composite"
steps:
- name: Push nginx config
shell: bash
env:
NGINX_CID: ${{ inputs.container_id }}
ACTION_PATH: ${{ github.action_path }}
MAGENTO_PATH: ${{ inputs.magento_path }}
run: |
case "$MAGENTO_PATH" in
""|".") MAGE_ROOT="/var/www/html" ;;
/*) MAGE_ROOT="$MAGENTO_PATH" ;;
*) MAGE_ROOT="/var/www/html/$MAGENTO_PATH" ;;
esac
MAGE_ROOT="${MAGE_ROOT%/}"
sed "s|__MAGE_ROOT__|$MAGE_ROOT|g" "$ACTION_PATH/conf.d/default.conf" > /tmp/default.conf
docker cp /tmp/default.conf "$NGINX_CID:/etc/nginx/conf.d/default.conf"
echo "--- default.conf in container ---"
docker exec "$NGINX_CID" cat /etc/nginx/conf.d/default.conf
docker exec "$NGINX_CID" nginx -t
- name: Restart nginx and wait for healthy
shell: bash
env:
NGINX_CID: ${{ inputs.container_id }}
TIMEOUT: ${{ inputs.health_timeout_seconds }}
run: |
docker restart "$NGINX_CID"
deadline=$(( $(date +%s) + TIMEOUT ))
last=""
while [ "$(date +%s)" -lt "$deadline" ]; do
last=$(docker inspect --format '{{.State.Health.Status}}' "$NGINX_CID" 2>/dev/null || echo "")
running=$(docker inspect --format '{{.State.Running}}' "$NGINX_CID" 2>/dev/null || echo "")
echo "running=$running health=$last"
if [ "$last" = "healthy" ]; then
exit 0
fi
sleep 2
done
echo "nginx did not reach healthy state within ${TIMEOUT}s (last=$last)"
docker logs "$NGINX_CID" 2>&1 | tail -80 || true
exit 1
branding:
icon: "code"
color: "green"
@@ -0,0 +1,10 @@
upstream fastcgi_backend {
server php-fpm:9000;
}
server {
listen 80 default_server;
server_name _;
set $MAGE_ROOT __MAGE_ROOT__;
include __MAGE_ROOT__/nginx[.]conf*;
}
+19 -2
View File
@@ -18,6 +18,23 @@ See the [check-extension.yaml](../../.github/workflows/check-extension.yaml)
The Magento matrix format outlined by the [supported versions action.](https://github.com/graycoreio/github-actions-magento2/tree/main/supported-version/supported.json)
## Configuration
Each check can be toggled on/off through an optional `.github/check-extension.json` file in the repo that calls this workflow.
You can learn more about this file here in the [`resolve-check-config` action.](../../resolve-check-config/README.md):
Reference the published JSON Schema with `$schema` to get autocompletion and inline validation in editors that support it:
```json
{
"$schema": "https://raw.githubusercontent.com/graycoreio/github-actions-magento2/main/resolve-check-config/check-extension.schema.json",
"jobs": {
"integration_test": false
}
}
```
## Usage
```yml
@@ -38,12 +55,12 @@ jobs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
id: supported-version
- run: echo ${{ steps.supported-version.outputs.matrix }}
check-extension:
needs: compute_matrix
uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v8.0.0 # x-release-please-version
uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v8.3.0 # x-release-please-version
with:
matrix: ${{ needs.compute_matrix.outputs.matrix }}
```
+20 -2
View File
@@ -24,6 +24,24 @@ See the [check-store.yaml](../../.github/workflows/check-store.yaml)
- **Unit Tests** — runs PHPUnit against custom code in `app/code`. Skipped automatically if no test files are found.
- **Coding Standard** — runs the Magento Coding Standard against `app/code`. Uses your `phpcs.xml` (or `.phpcs.xml`, `phpcs.xml.dist`, `.phpcs.xml.dist`) if one exists, otherwise a sensible default is generated.
- **Smoke Test** — boots your store against the supported-version service set (mysql, search, queue, cache, nginx, php-fpm) and runs the smoke probes against it.
## Configuration
Each check can be toggled on/off through an optional `.github/check-store.json` file in the repo that calls this workflow.
You can learn more about this file here in the [`resolve-check-config` action.](../../resolve-check-config/README.md):
Reference the published JSON Schema with `$schema` to get autocompletion and inline validation in editors that support it:
```json
{
"$schema": "https://raw.githubusercontent.com/graycoreio/github-actions-magento2/main/resolve-check-config/check-store.schema.json",
"jobs": {
"coding-standard": false
}
}
```
## Usage
@@ -40,7 +58,7 @@ on:
jobs:
check-store:
uses: graycoreio/github-actions-magento2/.github/workflows/check-store.yaml@v8.0.0 # x-release-please-version
uses: graycoreio/github-actions-magento2/.github/workflows/check-store.yaml@v8.3.0 # x-release-please-version
secrets:
composer_auth: ${{ secrets.COMPOSER_AUTH }}
```
@@ -52,7 +70,7 @@ If your pipeline builds or prepares the store in a prior job and uploads it as a
```yml
jobs:
check-store:
uses: graycoreio/github-actions-magento2/.github/workflows/check-store.yaml@v8.0.0 # x-release-please-version
uses: graycoreio/github-actions-magento2/.github/workflows/check-store.yaml@v8.3.0 # x-release-please-version
secrets:
composer_auth: ${{ secrets.COMPOSER_AUTH }}
```
+2 -2
View File
@@ -50,13 +50,13 @@ jobs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
with:
include_services: true
id: supported-version
integration-workflow:
needs: compute_matrix
uses: graycoreio/github-actions-magento2/.github/workflows/integration.yaml@v8.0.0 # x-release-please-version
uses: graycoreio/github-actions-magento2/.github/workflows/integration.yaml@v8.3.0 # x-release-please-version
with:
package_name: my-vendor/package
matrix: ${{ needs.compute_matrix.outputs.matrix }}
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/fix-magento-install@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/fix-magento-install@v8.3.0 # x-release-please-version
with:
magento_directory: path/to/magento
```
+1 -1
View File
@@ -9,7 +9,7 @@ inputs:
runs:
using: "composite"
steps:
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0
id: init-magento-get-magento-version
with:
working-directory: ${{ inputs.magento_directory }}
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: A job to compute an installed Composer version.
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/get-composer-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/get-composer-version@v8.3.0 # x-release-please-version
id: get-composer-version
- run: echo version ${{ steps.get-composer-version.outputs.version }}
shell: bash
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: A job to compute an installed Magento version.
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/get-magento-version@v8.3.0 # x-release-please-version
id: get-magento-version
- run: echo version ${{ steps.get-magento-version.outputs.version }}
shell: bash
+3
View File
@@ -15,6 +15,9 @@ outputs:
project:
description: 'The Magento project package name (e.g. magento/project-community-edition)'
value: ${{ steps.get-magento-version.outputs.project }}
supported_version_project:
description: 'The `project` value to pass to the supported-version action (e.g. `magento-open-source`, `mage-os`, `mage-os-minimal`). Empty when no Magento project is detected.'
value: ${{ steps.get-magento-version.outputs.supported_version_project }}
runs:
using: "composite"
@@ -0,0 +1,9 @@
{
"packages": [
{
"name": "mage-os/product-minimal-edition",
"version": "3.0.0"
}
],
"packages-dev": []
}
+9 -1
View File
@@ -2,7 +2,7 @@
set -uo pipefail
WORKING_DIR="${1:-.}"
PATTERN="magento/product-(community|enterprise)-edition|mage-os/product-community-edition"
PATTERN="magento/product-(community|enterprise)-edition|mage-os/product-(community|minimal)-edition"
cd "$WORKING_DIR"
@@ -16,5 +16,13 @@ PRODUCT=$(echo "${RESULT:-}" | awk '{print $1}')
VERSION=$(echo "${RESULT:-}" | awk '{print $2}' | sed 's/^v//')
PROJECT=$(echo "$PRODUCT" | sed 's/product-/project-/')
case "$PROJECT" in
magento/*) SUPPORTED_VERSION_PROJECT="magento-open-source" ;;
mage-os/project-community-edition) SUPPORTED_VERSION_PROJECT="mage-os" ;;
mage-os/project-minimal-edition) SUPPORTED_VERSION_PROJECT="mage-os-minimal" ;;
*) SUPPORTED_VERSION_PROJECT="" ;;
esac
echo "version=\"$VERSION\""
echo "project=$PROJECT"
echo "supported_version_project=$SUPPORTED_VERSION_PROJECT"
+23 -12
View File
@@ -24,31 +24,42 @@ field() {
}
OUT=$(bash "$SCRIPT" "$FIXTURES/store-lock")
assert_eq "store lock: version" '"2.4.7"' "$(field "$OUT" version)"
assert_eq "store lock: project" "magento/project-community-edition" "$(field "$OUT" project)"
assert_eq "store lock: version" '"2.4.7"' "$(field "$OUT" version)"
assert_eq "store lock: project" "magento/project-community-edition" "$(field "$OUT" project)"
assert_eq "store lock: supported_version_project" "magento-open-source" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/store-v-prefix")
assert_eq "store v-prefix: version" '"2.4.6"' "$(field "$OUT" version)"
OUT=$(bash "$SCRIPT" "$FIXTURES/enterprise")
assert_eq "enterprise: version" '"2.4.7-p1"' "$(field "$OUT" version)"
assert_eq "enterprise: project" "magento/project-enterprise-edition" "$(field "$OUT" project)"
assert_eq "enterprise: version" '"2.4.7-p1"' "$(field "$OUT" version)"
assert_eq "enterprise: project" "magento/project-enterprise-edition" "$(field "$OUT" project)"
assert_eq "enterprise: supported_version_project" "magento-open-source" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/mage-os")
assert_eq "mage-os: version" '"1.0.0"' "$(field "$OUT" version)"
assert_eq "mage-os: project" "mage-os/project-community-edition" "$(field "$OUT" project)"
assert_eq "mage-os: version" '"1.0.0"' "$(field "$OUT" version)"
assert_eq "mage-os: project" "mage-os/project-community-edition" "$(field "$OUT" project)"
assert_eq "mage-os: supported_version_project" "mage-os" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/mage-os-minimal")
assert_eq "mage-os-minimal: version" '"3.0.0"' "$(field "$OUT" version)"
assert_eq "mage-os-minimal: project" "mage-os/project-minimal-edition" "$(field "$OUT" project)"
assert_eq "mage-os-minimal: supported_version_project" "mage-os-minimal" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/store-json")
assert_eq "store json: version" '"2.4.6-p1"' "$(field "$OUT" version)"
assert_eq "store json: project" "magento/project-community-edition" "$(field "$OUT" project)"
assert_eq "store json: version" '"2.4.6-p1"' "$(field "$OUT" version)"
assert_eq "store json: project" "magento/project-community-edition" "$(field "$OUT" project)"
assert_eq "store json: supported_version_project" "magento-open-source" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/extension")
assert_eq "extension: version" '""' "$(field "$OUT" version)"
assert_eq "extension: project" "" "$(field "$OUT" project)"
assert_eq "extension: version" '""' "$(field "$OUT" version)"
assert_eq "extension: project" "" "$(field "$OUT" project)"
assert_eq "extension: supported_version_project" "" "$(field "$OUT" supported_version_project)"
OUT=$(bash "$SCRIPT" "$FIXTURES/empty")
assert_eq "empty dir: version" '""' "$(field "$OUT" version)"
assert_eq "empty dir: project" "" "$(field "$OUT" project)"
assert_eq "empty dir: version" '""' "$(field "$OUT" version)"
assert_eq "empty dir: project" "" "$(field "$OUT" project)"
assert_eq "empty dir: supported_version_project" "" "$(field "$OUT" supported_version_project)"
echo ""
echo "$PASS passed, $FAIL failed"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@graycoreio/github-actions-magento2",
"version": "8.0.0",
"version": "8.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@graycoreio/github-actions-magento2",
"version": "8.0.0",
"version": "8.3.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.11.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@graycoreio/github-actions-magento2",
"version": "8.0.0",
"version": "8.3.0",
"description": "Github Actions for Magento 2",
"scripts": {
"test": "cd supported-version && npm run test && cd - && cd setup-install && npm run test && cd -",
+19
View File
@@ -0,0 +1,19 @@
{
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft-pull-request": true,
"prerelease": false,
"include-component-in-tag": false,
"include-v-in-tag": true,
"pull-request-title-pattern": "chore: release ${version}",
"packages": {
".": {
"release-type": "node",
"versioning": "prerelease",
"extra-files": [
{ "type": "generic", "path": "*/README.md", "glob": true },
{ "type": "generic", "path": "docs/workflows/*.md", "glob": true }
]
}
}
}
+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.3.0 # x-release-please-version
id: supported-version
with:
kind: currently-supported
- uses: graycoreio/github-actions-magento2/resolve-check-config@v8.3.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,101 @@
import { EXTENSION_JOBS, KNOWN_JOBS_EXTENSION, resolveExtensionConfig } from './extension';
import { 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 } }],
};
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());
});
it('declares integration_test required tiers (no web)', () => {
expect(EXTENSION_JOBS['integration_test'].services).toEqual([]);
expect([...EXTENSION_JOBS['integration_test'].requiredServices!].sort()).toEqual([
'cache',
'db',
'queue',
'search',
]);
});
it('leaves the non-service jobs with empty defaults', () => {
for (const name of ['unit-test-extension', 'compile-extension', 'coding-standard']) {
expect(EXTENSION_JOBS[name].services).toEqual([]);
expect(EXTENSION_JOBS[name].requiredServices).toBeUndefined();
}
});
});
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 the non-service jobs', () => {
const resolved = resolveExtensionConfig({}, MATRIX);
for (const name of ['unit-test-extension', 'compile-extension', 'coding-standard']) {
expect(resolved[name].matrix.include[0].services).toEqual({});
}
});
it('integration_test includes mysql/search/queue/cache but NOT nginx/php-fpm', () => {
const resolved = resolveExtensionConfig({}, MATRIX);
expect(Object.keys(resolved['integration_test'].matrix.include[0].services!).sort()).toEqual([
'mysql',
'opensearch',
'rabbitmq',
'valkey',
]);
});
it('keeps integration_test required tiers even when caller overrides services to []', () => {
const resolved = resolveExtensionConfig(
{ jobs: { integration_test: { services: [] } } },
MATRIX,
);
expect(Object.keys(resolved['integration_test'].matrix.include[0].services!).sort()).toEqual([
'mysql',
'opensearch',
'rabbitmq',
'valkey',
]);
});
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,29 @@
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: [],
requiredServices: ['db', 'search', 'queue', 'cache'],
},
};
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"]
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/sansec-ecomscan@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/sansec-ecomscan@v8.3.0 # x-release-please-version
with:
license: ${{ secrets.SANSEC_LICENSE_KEY }}
```
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
name: A job to semantically compare two versions
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/semver-compare@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/semver-compare@v8.3.0 # x-release-please-version
with:
version: 2.1.0
compare_against: 2.2.3
+4 -4
View File
@@ -28,7 +28,7 @@ jobs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
id: supported-version
compile:
@@ -40,19 +40,19 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0 # x-release-please-version
id: setup-magento
with:
php-version: ${{ matrix.php }}
tools: composer:v${{ matrix.composer }}
- uses: graycoreio/github-actions-magento2/cache-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/cache-magento@v8.3.0 # x-release-please-version
- run: composer install
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
- uses: graycoreio/github-actions-magento2/setup-di-compile@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-di-compile@v8.3.0 # x-release-please-version
with:
path: ${{ steps.setup-magento.outputs.path }}
```
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
id: supported-version
with:
include_services: "true"
@@ -51,7 +51,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0 # x-release-please-version
id: setup-magento
with:
php-version: ${{ matrix.php }}
@@ -64,7 +64,7 @@ jobs:
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
- uses: graycoreio/github-actions-magento2/setup-install@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-install@v8.3.0 # x-release-please-version
with:
services: ${{ toJSON(matrix.services) }}
path: ${{ steps.setup-magento.outputs.path }}
+5
View File
@@ -18,6 +18,11 @@ inputs:
default: ""
description: "Additional raw flags to append to the setup:install command."
container_id:
required: false
default: ""
description: "When set, runs setup:install via `docker exec` inside the container with this `container_id` (typically the value of `job.services['php-fpm'].id`) and uses service-network aliases (mysql, redis, etc.) instead of runner-side localhost when running setup:install."
outputs:
command:
description: "The full bin/magento setup:install command that was run."
+28 -28
View File
File diff suppressed because one or more lines are too long
+96
View File
@@ -173,4 +173,100 @@ describe('buildInstallArgs', () => {
]);
});
});
describe('container mode', () => {
it('uses the mysql network alias and the internal port from ports[0]', () => {
const services: Services = {
mysql: { ...MYSQL_SERVICE, ports: ['33060:3306'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--db-host=mysql:3306',
'--db-name=magento_integration_tests',
'--db-user=user',
'--db-password=password',
]);
});
it('uses the opensearch alias and parses the internal port', () => {
const services: Services = {
opensearch: { image: 'opensearchproject/opensearch:2.19.1', ports: ['19200:9200'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--search-engine=opensearch',
'--opensearch-host=opensearch',
'--opensearch-port=9200',
]);
});
it('uses the elasticsearch alias and parses the internal port', () => {
const services: Services = {
elasticsearch: { image: 'elasticsearch:8.11.4', ports: ['19200:9200'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--search-engine=elasticsearch8',
'--elasticsearch-host=elasticsearch',
'--elasticsearch-port=9200',
]);
});
it('uses the rabbitmq alias and parses the internal port', () => {
const services: Services = {
rabbitmq: { image: 'rabbitmq:4.0-management', ports: ['15672:5672'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--amqp-host=rabbitmq',
'--amqp-port=5672',
'--amqp-user=guest',
'--amqp-password=guest',
]);
});
it('uses the valkey alias when valkey is the cache service', () => {
const services: Services = {
valkey: { image: 'valkey:8.0', ports: ['16379:6379'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--session-save=redis',
'--session-save-redis-host=valkey',
'--session-save-redis-port=6379',
'--cache-backend=redis',
'--cache-backend-redis-server=valkey',
'--cache-backend-redis-port=6379',
]);
});
it('uses the redis alias when redis is the cache service', () => {
const services: Services = {
redis: { image: 'redis:7.2', ports: ['16379:6379'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--session-save=redis',
'--session-save-redis-host=redis',
'--session-save-redis-port=6379',
'--cache-backend=redis',
'--cache-backend-redis-server=redis',
'--cache-backend-redis-port=6379',
]);
});
it('falls back to default internal ports when ports are absent', () => {
const services: Services = {
mysql: { ...MYSQL_SERVICE, ports: undefined },
opensearch: { image: 'opensearchproject/opensearch:2.19.1' },
rabbitmq: { image: 'rabbitmq:4.0-management' },
valkey: { image: 'valkey:8.0' },
};
const args = buildInstallArgs(services, true);
expect(args).toContain('--db-host=mysql:3306');
expect(args).toContain('--opensearch-port=9200');
expect(args).toContain('--amqp-port=5672');
expect(args).toContain('--session-save-redis-port=6379');
});
});
});
+32 -15
View File
@@ -3,6 +3,7 @@ export interface ServiceConfig {
env?: Record<string, string>;
ports?: string[];
options?: string;
volumes?: string[];
}
export interface Services {
@@ -12,6 +13,7 @@ export interface Services {
rabbitmq?: ServiceConfig;
redis?: ServiceConfig;
valkey?: ServiceConfig;
'php-fpm'?: ServiceConfig;
}
const BASE_ARGS = [
@@ -24,21 +26,29 @@ const BASE_ARGS = [
'--backend-frontname=admin',
];
const parsePort = (svc: ServiceConfig | undefined, index: 0 | 1, fallback: string): string => {
return svc?.ports?.[0]?.split(':')[index] ?? fallback;
};
export const buildMysqlPrepArgs = (mysql: ServiceConfig): string[] => {
const rootPassword = mysql.env?.MYSQL_ROOT_PASSWORD ?? 'rootpassword';
const port = mysql.ports?.[0]?.split(':')[0] ?? '3306';
const port = parsePort(mysql, 0, '3306');
return ['-h127.0.0.1', `--port=${port}`, '-uroot', `-p${rootPassword}`, '-e', 'SET GLOBAL log_bin_trust_function_creators = 1;'];
};
export const buildInstallArgs = (services: Services | null): string[] => {
export const buildInstallArgs = (services: Services | null, containerMode = false): string[] => {
const args = [...BASE_ARGS];
if (!services) return args;
const portIdx: 0 | 1 = containerMode ? 1 : 0;
const hostFor = (alias: string): string => containerMode ? alias : 'localhost';
if (services.mysql) {
const dbPort = services.mysql.ports?.[0]?.split(':')[0] ?? '3306';
const dbPort = parsePort(services.mysql, portIdx, '3306');
const dbHost = containerMode ? `mysql:${dbPort}` : `127.0.0.1:${dbPort}`;
args.push(
`--db-host=127.0.0.1:${dbPort}`,
`--db-host=${dbHost}`,
`--db-name=${services.mysql.env?.MYSQL_DATABASE ?? 'magento'}`,
`--db-user=${services.mysql.env?.MYSQL_USER ?? 'magento'}`,
`--db-password=${services.mysql.env?.MYSQL_PASSWORD ?? 'magento'}`,
@@ -46,39 +56,46 @@ export const buildInstallArgs = (services: Services | null): string[] => {
}
if (services.opensearch) {
const port = parsePort(services.opensearch, portIdx, '9200');
args.push(
'--search-engine=opensearch',
'--opensearch-host=localhost',
'--opensearch-port=9200',
`--opensearch-host=${hostFor('opensearch')}`,
`--opensearch-port=${port}`,
);
} else if (services.elasticsearch) {
const majorVersion = services.elasticsearch.image.split(':')[1]?.split('.')[0];
const port = parsePort(services.elasticsearch, portIdx, '9200');
args.push(
`--search-engine=elasticsearch${majorVersion}`,
'--elasticsearch-host=localhost',
'--elasticsearch-port=9200',
`--elasticsearch-host=${hostFor('elasticsearch')}`,
`--elasticsearch-port=${port}`,
);
}
if (services.rabbitmq) {
const port = parsePort(services.rabbitmq, portIdx, '5672');
args.push(
'--amqp-host=localhost',
'--amqp-port=5672',
`--amqp-host=${hostFor('rabbitmq')}`,
`--amqp-port=${port}`,
'--amqp-user=guest',
'--amqp-password=guest',
);
}
if (services.valkey || services.redis) {
const cacheKey: 'valkey' | 'redis' = services.valkey ? 'valkey' : 'redis';
const cache = services[cacheKey]!;
const port = parsePort(cache, portIdx, '6379');
const host = hostFor(cacheKey);
args.push(
'--session-save=redis',
'--session-save-redis-host=localhost',
'--session-save-redis-port=6379',
`--session-save-redis-host=${host}`,
`--session-save-redis-port=${port}`,
'--cache-backend=redis',
'--cache-backend-redis-server=localhost',
'--cache-backend-redis-port=6379',
`--cache-backend-redis-server=${host}`,
`--cache-backend-redis-port=${port}`,
);
}
return args;
};
};
+40 -5
View File
@@ -1,12 +1,25 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as nodePath from 'path';
import { buildInstallArgs, buildMysqlPrepArgs, Services } from './build-command';
const resolveContainerPath = (runnerPath: string): string => {
const workspace = process.env.GITHUB_WORKSPACE || '';
const absolute = nodePath.resolve(workspace, runnerPath);
const relative = nodePath.relative(workspace, absolute);
if (relative.startsWith('..')) {
throw new Error(`container_id: path ${runnerPath} resolves outside GITHUB_WORKSPACE (${workspace})`);
}
return relative ? `/var/www/html/${relative}` : '/var/www/html';
};
export async function run(): Promise<void> {
try {
const servicesInput = core.getInput('services');
const path = core.getInput('path') || '.';
const extraArgs = core.getInput('extra_args').trim();
const containerId = core.getInput('container_id').trim();
const containerMode = containerId !== '';
let services: Services | null = null;
if (servicesInput && servicesInput !== 'null') {
@@ -14,23 +27,45 @@ export async function run(): Promise<void> {
}
// setup:install creates MySQL triggers, which requires log_bin_trust_function_creators=1
// when binary logging is enabled.
// when binary logging is enabled. The prep always runs runner-side against the published port.
if (services?.mysql) {
await exec.exec('mysql', buildMysqlPrepArgs(services.mysql));
}
const args = buildInstallArgs(services);
const args = buildInstallArgs(services, containerMode);
if (extraArgs) {
args.push(...extraArgs.split(/\s+/));
}
core.setOutput('command', `php bin/magento setup:install ${args.join(' ')}`);
if (containerMode) {
const containerPath = resolveContainerPath(path);
await exec.exec('php', ['bin/magento', 'setup:install', ...args], { cwd: path });
const command = `docker exec -w ${containerPath} ${containerId} php bin/magento setup:install ${args.join(' ')}`;
core.setOutput('command', command);
await exec.exec('docker', [
'exec',
'-w', containerPath,
containerId,
'php', 'bin/magento', 'setup:install',
...args,
]);
// setup:install runs as root inside the container, but php-fpm workers
// serve requests as `www-data`. Hand ownership of the Magento writable
// dirs to www-data so request-time cache/log writes succeed.
await exec.exec('docker', [
'exec', containerId, 'sh', '-c',
`for d in var generated pub/static pub/media; do [ -d "${containerPath}/$d" ] && chown -R www-data:www-data "${containerPath}/$d"; done`,
]);
} else {
core.setOutput('command', `php bin/magento setup:install ${args.join(' ')}`);
await exec.exec('php', ['bin/magento', 'setup:install', ...args], { cwd: path });
}
} catch (error) {
core.setFailed((error as Error).message);
}
}
run();
run();
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0 # x-release-please-version
id: setup-magento
with:
php-version: "8.3"
@@ -89,7 +89,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: graycoreio/github-actions-magento2/setup-magento@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/setup-magento@v8.3.0 # x-release-please-version
id: setup-magento
with:
php-version: "8.3"
+1 -1
View File
@@ -111,7 +111,7 @@ runs:
fi
printf '%s\n' "$line" >> .gitattributes
- uses: graycoreio/github-actions-magento2/fix-magento-install@v8.0.0
- uses: graycoreio/github-actions-magento2/fix-magento-install@v8.3.0
name: Fix Magento Out of Box Install Issues
with:
magento_directory: ${{ steps.setup-magento-compute-directory.outputs.MAGENTO_DIRECTORY }}
+77
View File
@@ -0,0 +1,77 @@
name: "Magento Smoke Test"
author: "Graycore"
description: "Hits a running Magento instance with a basic page or GraphQL probe and asserts the response looks right."
inputs:
kind:
description: "Which probe to run: `page` (GET / with title check) or `graphql` (POST /graphql with storeConfig query)."
required: true
host:
description: "Host (and optional port) to probe. Defaults to `localhost`."
required: false
default: "localhost"
timeout:
description: "curl --max-time in seconds."
required: false
default: "60"
runs:
using: "composite"
steps:
- name: Validate kind
shell: bash
env:
KIND: ${{ inputs.kind }}
run: |
case "$KIND" in
page|graphql) ;;
*) echo "FATAL: kind must be 'page' or 'graphql' (got '$KIND')"; exit 1 ;;
esac
- name: Smoke test page
if: inputs.kind == 'page'
shell: bash
env:
HOST: ${{ inputs.host }}
TIMEOUT: ${{ inputs.timeout }}
run: |
status=$(curl -sS --max-time "$TIMEOUT" \
-o /tmp/smoke-page.html -w "%{http_code}" "http://$HOST/")
if [ "$status" != "200" ]; then
echo "Page returned HTTP $status"
head -c 4000 /tmp/smoke-page.html
exit 1
fi
if ! grep -qE '<title>[[:space:]]*[^<[:space:]][^<]*</title>' /tmp/smoke-page.html; then
echo "Page missing non-empty <title>"
head -c 4000 /tmp/smoke-page.html
exit 1
fi
- name: Smoke test GraphQL
if: inputs.kind == 'graphql'
shell: bash
env:
HOST: ${{ inputs.host }}
TIMEOUT: ${{ inputs.timeout }}
run: |
status=$(curl -sS --max-time "$TIMEOUT" \
-H "Content-Type: application/json" \
-X POST \
-d '{"query":"{ storeConfig { store_code } }"}' \
-o /tmp/smoke-graphql.json -w "%{http_code}" \
"http://$HOST/graphql")
if [ "$status" != "200" ]; then
echo "GraphQL returned HTTP $status"
cat /tmp/smoke-graphql.json
exit 1
fi
if ! jq -e '.data.storeConfig.store_code' /tmp/smoke-graphql.json > /dev/null; then
echo "GraphQL response missing data.storeConfig.store_code"
cat /tmp/smoke-graphql.json
exit 1
fi
branding:
icon: "check-circle"
color: "green"
+45 -2
View File
@@ -14,10 +14,11 @@ See the [action.yml](./action.yml)
| Input | Description | Required | Default |
|-----------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |-----------------------|
| kind | The "kind" of support you're targeting for your package. See [Kinds](#kinds). | false | 'currently-supported' |
| project | The project to return the supported versions for. Allowed values are `mage-os` and `magento-open-source` | false | 'magento-open-source' |
| project | The project to return the supported versions for. Allowed values are `mage-os`, `mage-os-minimal`, and `magento-open-source` | false | 'magento-open-source' |
| custom_versions | The versions you want to support, as a comma-separated string, i.e. 'magento/project-community-edition:2.3.7-p3, magento/project-community-edition:2.4.2-p2' | false | '' |
| recent_time_frame | The time frame (from today) used when `kind` is `recent`. Combination of years (y), months (m), and days (d), e.g. `2y 2m 2d`. | false | '2y' |
| include_services | Whether to include a `services` key in each matrix entry with GitHub Actions service container configurations for MySQL, search engine, RabbitMQ, and cache. | false | 'true' |
| service_preferences | Comma-separated list of service implementations to prefer (e.g. `elasticsearch,valkey`). See [Service preferences](#service-preferences). | false | '' |
## Kinds
- `currently-supported` - The currently supported Magento Open Source versions by Adobe.
@@ -30,8 +31,50 @@ See the [action.yml](./action.yml)
## Projects
- `mage-os`
- `mage-os-minimal`
- `magento-open-source` (default)
## Service preferences
When `include_services: true` (the default), each matrix entry is enriched with a `services` map. Some tiers of services (for example, search) have more than one valid implementation across the supported Magento versions:
- **search**: `opensearch` or `elasticsearch`
- **cache**: `valkey` or `redis`
By default the action picks `opensearch` over `elasticsearch` and `valkey` over `redis` wherever both are available for the matrix entry's Magento version. `service_preferences` lets the caller override that default pick by naming the implementation they want.
Tiers without a preference fall back to the per-version default pick. Your preferences are **selective**, not **exclusive**.
### Format
A comma-separated list of service implementation names. Whitespace around names is tolerated.
```yml
with:
service_preferences: elasticsearch,valkey
```
### Accepted names
| Name | Tier |
|------------------|--------|
| `mysql` | db |
| `elasticsearch` | search |
| `opensearch` | search |
| `rabbitmq` | queue |
| `redis` | cache |
| `valkey` | cache |
### Example
```yml
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
id: supported-version
with:
kind: currently-supported
service_preferences: opensearch,valkey
```
## Usage
```yml
@@ -51,7 +94,7 @@ jobs:
outputs:
matrix: ${{ steps.supported-version.outputs.matrix }}
steps:
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
- uses: graycoreio/github-actions-magento2/supported-version@v8.3.0 # x-release-please-version
id: supported-version
- run: echo ${{ steps.supported-version.outputs.matrix }}
```
+6 -1
View File
@@ -9,7 +9,7 @@ inputs:
default: "currently-supported"
project:
required: false
description: "The project to return the supported versions for. Allowed values are `mage-os` and `magento-open-source`"
description: "The project to return the supported versions for. Allowed values are `mage-os`, `mage-os-minimal`, and `magento-open-source`"
# The default value is what it is to keep backward compatibility
default: "magento-open-source"
custom_versions:
@@ -27,6 +27,11 @@ inputs:
default: "true"
description: "Whether to include a `services` key in each matrix entry with GitHub Actions service configurations."
service_preferences:
required: false
default: ""
description: "Comma-separated list of service implementations to prefer (e.g. `elasticsearch,valkey`). Each name overrides the per-tier default implementation pick. Errors on unknown names, tier collisions (two names sharing a tier), or implementations not supported by every matrix entry for your selected kind."
outputs:
matrix:
description: "The Github Actions matrix of software technologies required to run Magento."
+28 -24
View File
File diff suppressed because one or more lines are too long
+17 -2
View File
@@ -3,6 +3,7 @@ import { validateKind } from './kind/validate-kinds';
import { getMatrixForKind } from './matrix/get-matrix-for-kind';
import { validateProject } from "./project/validate-projects";
import { buildServicesForEntry } from "./services/build-services";
import { parseServicePreferences, validatePreferencesAgainstMatrix } from "./services/preferences";
export async function run(): Promise<void> {
@@ -12,19 +13,33 @@ export async function run(): Promise<void> {
const project = core.getInput("project");
const recent_time_frame = core.getInput("recent_time_frame");
const include_services = core.getInput("include_services") === "true";
const service_preferences_raw = core.getInput("service_preferences");
validateProject(<any>project)
validateKind(<any>kind, customVersions ? customVersions.split(',') : undefined);
const preferences = parseServicePreferences(service_preferences_raw);
const hasPreferences = Object.keys(preferences).length > 0;
if (!include_services && hasPreferences) {
throw new Error(
'service_preferences cannot be combined with include_services: false. Set include_services: true or clear service_preferences.'
);
}
let matrix = getMatrixForKind(kind, project, customVersions, recent_time_frame);
if (include_services) {
if (hasPreferences) {
validatePreferencesAgainstMatrix(preferences, matrix.include);
}
const workspace = process.env.GITHUB_WORKSPACE || '';
matrix = {
magento: matrix.magento,
include: matrix.include.map((entry) => ({
...entry,
services: buildServicesForEntry(entry)
services: buildServicesForEntry(entry, preferences, workspace)
}))
};
}
@@ -36,4 +51,4 @@ export async function run(): Promise<void> {
}
}
run()
run()
@@ -145,6 +145,12 @@ describe('getCurrentlySupportedVersions for mage-os', () => {
['2026-04-15T00:00:01Z', 'Release of 2.2.2', [
'mage-os/project-community-edition:2.2.2',
]],
['2026-05-13T00:00:01Z', 'Release of 2.3.0', [
'mage-os/project-community-edition:2.3.0',
]],
['2026-05-19T00:00:01Z', 'Release of 3.0.0', [
'mage-os/project-community-edition:3.0.0',
]],
])(
'supportedVersions for %s',
(date, description ,result) => {
@@ -1,4 +1,5 @@
{
"mage-os": ["mage-os/project-community-edition"],
"mage-os-minimal": ["mage-os/project-minimal-edition"],
"magento-open-source": ["magento/project-community-edition"]
}
@@ -1,4 +1,5 @@
{
"mage-os": ["mage-os/project-community-edition:next"],
"mage-os-minimal": ["mage-os/project-minimal-edition:next"],
"magento-open-source": ["magento/project-community-edition:next"]
}
@@ -3,6 +3,7 @@ export interface ServiceConfig {
env?: Record<string, string>;
ports?: string[];
options?: string;
volumes?: string[];
}
export interface Services {
@@ -3,6 +3,7 @@
*/
export const KNOWN_PROJECTS = {
"mage-os": true,
"mage-os-minimal": true,
"magento-open-source": true,
}
@@ -4,6 +4,7 @@ describe('validateProject', () => {
it('returns `true` if its a valid project', () => {
expect(validateProject("magento-open-source")).toBe(true);
expect(validateProject("mage-os")).toBe(true);
expect(validateProject("mage-os-minimal")).toBe(true);
});
it('throws a helpful exception if it is an invalid project', () => {
@@ -4,6 +4,7 @@ import {Project} from "../projects";
describe('isKnownProject', () => {
it('returns `true` for known projects', () => {
expect(isKnownProject("mage-os")).toBe(true)
expect(isKnownProject("mage-os-minimal")).toBe(true)
expect(isKnownProject("magento-open-source")).toBe(true)
});
@@ -223,13 +223,17 @@ describe('buildServicesForEntry', () => {
describe('complete service output', () => {
it('should build all services when all are available', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry);
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(Object.keys(services)).toHaveLength(4);
expect(Object.keys(services).sort()).toEqual(
['mysql', 'nginx', 'opensearch', 'php-fpm', 'rabbitmq', 'valkey']
);
expect(services.mysql).toBeDefined();
expect(services.opensearch).toBeDefined();
expect(services.rabbitmq).toBeDefined();
expect(services.valkey).toBeDefined();
expect(services.nginx).toBeDefined();
expect(services['php-fpm']).toBeDefined();
});
it('should handle entry with minimal services', () => {
@@ -239,11 +243,123 @@ describe('buildServicesForEntry', () => {
opensearch: '',
rabbitmq: '',
redis: '',
valkey: ''
valkey: '',
nginx: '',
php: ''
});
const services = buildServicesForEntry(entry);
expect(Object.keys(services)).toHaveLength(0);
});
});
describe('web tier (nginx + php-fpm)', () => {
it('emits both nginx and php-fpm when both data points are present', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services.nginx).toBeDefined();
expect(services['php-fpm']).toBeDefined();
});
it('uses the entry.nginx image for nginx', () => {
const entry = createTestEntry({ nginx: 'nginx:1.27-alpine' });
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services.nginx.image).toBe('nginx:1.27-alpine');
});
it('composes the php-fpm image from entry.php using mappia', () => {
const entry = createTestEntry({ php: '8.2' });
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services['php-fpm'].image).toBe('mappia/magento-php:fpm-alpine8.2');
});
it('mounts the runner workspace at /var/www/html on both services', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, {}, '/home/runner/work/foo/foo');
expect(services.nginx.volumes).toEqual(['/home/runner/work/foo/foo:/var/www/html']);
expect(services['php-fpm'].volumes).toEqual(['/home/runner/work/foo/foo:/var/www/html']);
});
it('exposes port 80 on nginx with the nginx -t healthcheck', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services.nginx.ports).toEqual(['80:80']);
expect(services.nginx.options).toContain('nginx -t');
});
it('skips both when entry.nginx is empty (they emit together or not at all)', () => {
const entry = createTestEntry({ nginx: '' });
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services.nginx).toBeUndefined();
expect(services['php-fpm']).toBeUndefined();
});
it('skips both when entry.php is empty (they emit together or not at all)', () => {
const entry = createTestEntry({ php: '' });
const services = buildServicesForEntry(entry, {}, '/runner/ws');
expect(services.nginx).toBeUndefined();
expect(services['php-fpm']).toBeUndefined();
});
});
describe('with service preferences', () => {
it('uses elasticsearch when search preference is elasticsearch, even if opensearch is available', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, { search: 'elasticsearch' });
expect(services.elasticsearch).toBeDefined();
expect(services.elasticsearch.image).toBe('elasticsearch:8.11.4');
expect(services.opensearch).toBeUndefined();
});
it('uses opensearch when search preference is opensearch (matches default)', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, { search: 'opensearch' });
expect(services.opensearch).toBeDefined();
expect(services.elasticsearch).toBeUndefined();
});
it('uses redis when cache preference is redis, even if valkey is available', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, { cache: 'redis' });
expect(services.redis).toBeDefined();
expect(services.redis.image).toBe('redis:7.2');
expect(services.valkey).toBeUndefined();
});
it('applies preferences across multiple tiers independently', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, { search: 'elasticsearch', cache: 'redis' });
expect(services.elasticsearch).toBeDefined();
expect(services.redis).toBeDefined();
expect(services.opensearch).toBeUndefined();
expect(services.valkey).toBeUndefined();
});
it('falls back to default-pick for tiers without a preference', () => {
const entry = createTestEntry();
const services = buildServicesForEntry(entry, { search: 'elasticsearch' });
expect(services.elasticsearch).toBeDefined();
expect(services.valkey).toBeDefined();
});
it('treats single-implementation-tier preferences as no-ops', () => {
const entry = createTestEntry();
const withPref = buildServicesForEntry(entry, { db: 'mysql', queue: 'rabbitmq' });
const withoutPref = buildServicesForEntry(entry);
expect(withPref).toEqual(withoutPref);
});
});
});
@@ -5,8 +5,11 @@ import {
opensearchConfig,
rabbitmqConfig,
redisConfig,
valkeyConfig
valkeyConfig,
buildNginxConfig,
buildPhpFpmConfig
} from './service-config';
import { ServicePreferences } from './preferences';
interface SearchEngineChoice {
type: 'opensearch' | 'elasticsearch';
@@ -19,10 +22,22 @@ interface CacheChoice {
}
/**
* Determines which search engine to use for a matrix entry.
* Prefers opensearch over elasticsearch.
* Picks the search engine for a matrix entry. Honors caller's `service_preferences`
* when set; otherwise prefers opensearch over elasticsearch.
*/
function getSearchEngineChoice(entry: PackageMatrixVersion): SearchEngineChoice | null {
const getSearchEngineChoice = (entry: PackageMatrixVersion, preference?: string): SearchEngineChoice | null => {
if (preference === 'opensearch') {
if (entry.opensearch && entry.opensearch.trim() !== '') {
return { type: 'opensearch', image: entry.opensearch };
}
return null;
}
if (preference === 'elasticsearch') {
if (entry.elasticsearch && entry.elasticsearch.trim() !== '') {
return { type: 'elasticsearch', image: entry.elasticsearch };
}
return null;
}
if (entry.opensearch && entry.opensearch.trim() !== '') {
return { type: 'opensearch', image: entry.opensearch };
}
@@ -33,10 +48,22 @@ function getSearchEngineChoice(entry: PackageMatrixVersion): SearchEngineChoice
}
/**
* Determines which cache to use for a matrix entry.
* Prefers valkey over redis.
* Picks the cache for a matrix entry. Honors caller's `service_preferences`
* when set; otherwise prefers valkey over redis.
*/
function getCacheChoice(entry: PackageMatrixVersion): CacheChoice | null {
const getCacheChoice = (entry: PackageMatrixVersion, preference?: string): CacheChoice | null => {
if (preference === 'valkey') {
if (entry.valkey && entry.valkey.trim() !== '') {
return { type: 'valkey', image: entry.valkey };
}
return null;
}
if (preference === 'redis') {
if (entry.redis && entry.redis.trim() !== '') {
return { type: 'redis', image: entry.redis };
}
return null;
}
if (entry.valkey && entry.valkey.trim() !== '') {
return { type: 'valkey', image: entry.valkey };
}
@@ -47,18 +74,25 @@ function getCacheChoice(entry: PackageMatrixVersion): CacheChoice | null {
}
/**
* Builds the services object for a single matrix entry.
* Builds the services object for a single matrix entry. Emits every
* tier the entry has data for: mysql, search (opensearch/elasticsearch),
* queue (rabbitmq), cache (valkey/redis), and web (nginx + php-fpm
* together). The web tier requires `workspace` so the volume mount
* has a real path; if the entry lacks either nginx or php data the
* web tier is skipped entirely (they're coupled — emit both or neither).
*/
export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
export const buildServicesForEntry = (
entry: PackageMatrixVersion,
preferences: ServicePreferences = {},
workspace: string = ''
): Services => {
const services: Services = {};
// MySQL is always included if present
if (entry.mysql && entry.mysql.trim() !== '') {
services.mysql = mysqlConfig.getConfig(entry.mysql);
}
// Search engine: prefer opensearch over elasticsearch
const searchEngine = getSearchEngineChoice(entry);
const searchEngine = getSearchEngineChoice(entry, preferences.search);
if (searchEngine) {
if (searchEngine.type === 'opensearch') {
services.opensearch = opensearchConfig.getConfig(searchEngine.image);
@@ -67,13 +101,11 @@ export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
}
}
// RabbitMQ
if (entry.rabbitmq && entry.rabbitmq.trim() !== '') {
services.rabbitmq = rabbitmqConfig.getConfig(entry.rabbitmq);
}
// Cache: prefer valkey over redis
const cache = getCacheChoice(entry);
const cache = getCacheChoice(entry, preferences.cache);
if (cache) {
if (cache.type === 'valkey') {
services.valkey = valkeyConfig.getConfig(cache.image);
@@ -82,5 +114,12 @@ export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
}
}
const nginxImage = (entry.nginx || '').trim();
const phpVersion = String(entry.php ?? '').trim();
if (nginxImage !== '' && phpVersion !== '') {
services.nginx = buildNginxConfig(nginxImage, workspace);
services['php-fpm'] = buildPhpFpmConfig(phpVersion, workspace);
}
return services;
}
}
@@ -0,0 +1,3 @@
export { Tier, ServicePreferences, KNOWN_SERVICE_NAMES, tierFor } from './tier-map';
export { parseServicePreferences } from './parse-service-preferences';
export { validatePreferencesAgainstMatrix } from './validate-preferences-against-matrix';
@@ -0,0 +1,50 @@
import { parseServicePreferences } from './parse-service-preferences';
describe('parseServicePreferences', () => {
it('returns an empty map when input is empty', () => {
expect(parseServicePreferences('')).toEqual({});
});
it('returns an empty map for whitespace-only input', () => {
expect(parseServicePreferences(' ')).toEqual({});
});
it('maps a single name to its tier', () => {
expect(parseServicePreferences('opensearch')).toEqual({ search: 'opensearch' });
});
it('maps two names in different tiers', () => {
expect(parseServicePreferences('elasticsearch,valkey')).toEqual({
search: 'elasticsearch',
cache: 'valkey',
});
});
it('accepts single-implementation-tier names as no-op-like preferences', () => {
expect(parseServicePreferences('mysql,rabbitmq')).toEqual({
db: 'mysql',
queue: 'rabbitmq',
});
});
it('tolerates whitespace around names', () => {
expect(parseServicePreferences(' opensearch , valkey ')).toEqual({
search: 'opensearch',
cache: 'valkey',
});
});
it('throws on unknown service name', () => {
expect(() => parseServicePreferences('foobar')).toThrowError(/unknown service "foobar"/);
});
it('throws on a collision in the search tier', () => {
expect(() => parseServicePreferences('elasticsearch,opensearch')).toThrowError(
/collision in tier "search"/
);
});
it('throws on a collision in the cache tier', () => {
expect(() => parseServicePreferences('redis,valkey')).toThrowError(/collision in tier "cache"/);
});
});
@@ -0,0 +1,32 @@
import { ServicePreferences, SERVICE_TIER_MAP, KNOWN_SERVICE_NAMES } from './tier-map';
/**
* Parses the comma-separated `service_preferences` input into a
* tier-to-implementation map. Throws on unknown service names or when
* two names collide in the same tier.
*/
export const parseServicePreferences = (raw: string): ServicePreferences => {
const trimmed = (raw || '').trim();
if (trimmed === '') return {};
const names = trimmed.split(',').map(s => s.trim()).filter(s => s !== '');
const preferences: ServicePreferences = {};
for (const name of names) {
const tier = SERVICE_TIER_MAP[name];
if (!tier) {
throw new Error(
`service_preferences: unknown service "${name}". Known services: ${KNOWN_SERVICE_NAMES.join(', ')}`
);
}
const existing = preferences[tier];
if (existing) {
throw new Error(
`service_preferences: collision in tier "${tier}" — both "${existing}" and "${name}" specified`
);
}
preferences[tier] = name;
}
return preferences;
}
@@ -0,0 +1,18 @@
import { tierFor } from './tier-map';
describe('tierFor', () => {
it.each([
['mysql', 'db'],
['elasticsearch', 'search'],
['opensearch', 'search'],
['rabbitmq', 'queue'],
['redis', 'cache'],
['valkey', 'cache'],
])('maps %s to %s', (name, tier) => {
expect(tierFor(name)).toBe(tier);
});
it('returns undefined for unknown names', () => {
expect(tierFor('foobar')).toBeUndefined();
});
});
@@ -0,0 +1,63 @@
/**
* A category of services that Magento can choose between. Each tier
* has one or more concrete implementations (e.g. the `search` tier
* resolves to either `opensearch` or `elasticsearch`).
*/
export type Tier = 'db' | 'search' | 'queue' | 'cache';
/**
* Caller-expressed preference of "which implementation to use for which tier."
* Produced by `parseServicePreferences` from the `service_preferences`
* action input, then threaded into `buildServicesForEntry` to override
* the per-tier default pick. Partial because callers only need to name
* the tiers they care about; unset tiers fall back to the version's
* default implementation.
*/
export type ServicePreferences = Partial<Record<Tier, string>>;
/**
* Reverse lookup: which tier does each known service implementation
* belong to. The keys of this map define the closed set of legal
* service names in `service_preferences`; anything not listed here is
* rejected as an unknown service. nginx and php-fpm are intentionally
* absent — they're complementary (the `web` tier emits both together),
* not alternatives, so picking one via preferences would be meaningless.
*/
export const SERVICE_TIER_MAP: Record<string, Tier> = {
mysql: 'db',
elasticsearch: 'search',
opensearch: 'search',
rabbitmq: 'queue',
redis: 'cache',
valkey: 'cache',
};
/**
* Forward lookup: the implementations available within each tier,
* ordered by default preference (first entry wins when no caller
* preference is supplied). Used by the validator to render
* "supported: a, b" lists in compatibility errors and by
* `buildServicesForEntry` to know what to fall back to.
*/
export const TIER_IMPLEMENTATIONS: Record<Tier, string[]> = {
db: ['mysql'],
search: ['opensearch', 'elasticsearch'],
queue: ['rabbitmq'],
cache: ['valkey', 'redis'],
};
/**
* Flat list of every legal service name in `service_preferences`.
* Surfaced in unknown-service error messages so the caller sees
* exactly what's accepted without having to read source.
*/
export const KNOWN_SERVICE_NAMES = Object.keys(SERVICE_TIER_MAP);
/**
* Returns the tier a service name belongs to, or `undefined` if the
* name isn't recognized. Callers use the undefined return as the
* signal to reject the input as unknown.
*/
export const tierFor = (serviceName: string): Tier | undefined => {
return SERVICE_TIER_MAP[serviceName];
}
@@ -0,0 +1,68 @@
import { validatePreferencesAgainstMatrix } from './validate-preferences-against-matrix';
import { PackageMatrixVersion } from '../../matrix/matrix-type';
const baseEntry = (overrides: Partial<PackageMatrixVersion> = {}): PackageMatrixVersion => ({
magento: 'magento/project-community-edition:2.4.7',
version: '2.4.7',
php: '8.3',
composer: '2.7.4',
mysql: 'mysql:8.4',
elasticsearch: 'elasticsearch:8.11.4',
opensearch: 'opensearchproject/opensearch:2.19.1',
rabbitmq: 'rabbitmq:4.0-management',
redis: 'redis:7.2',
varnish: 'varnish:7.5',
valkey: 'valkey:8.0',
nginx: 'nginx:1.26',
os: 'ubuntu-latest',
release: '2024-04-09T00:00:00+0000',
eol: '2027-04-09T00:00:00+0000',
...overrides,
});
describe('validatePreferencesAgainstMatrix', () => {
it('does not throw when all entries support the preference', () => {
const entries = [baseEntry({ version: '2.4.7' }), baseEntry({ version: '2.4.6' })];
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).not.toThrow();
});
it('throws when an entry lacks the preferenced implementation, listing the offender', () => {
const entries = [
baseEntry({ version: '2.4.7' }),
baseEntry({ version: '2.4.5', opensearch: '' }),
];
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
/not satisfied for:\n\s+- magento 2\.4\.5 \(supported: elasticsearch\)/
);
});
it('lists all offenders, not just the first', () => {
const entries = [
baseEntry({ version: '2.4.5', opensearch: '' }),
baseEntry({ version: '2.4.4', opensearch: '' }),
];
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
/magento 2\.4\.5[\s\S]*magento 2\.4\.4/
);
});
it('reports "<none>" when the entry supports nothing in the tier', () => {
const entries = [baseEntry({ version: '2.4.0', opensearch: '', elasticsearch: '' })];
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
/magento 2\.4\.0 \(supported: <none>\)/
);
});
it('reports violations across multiple tiers', () => {
const entries = [baseEntry({ version: '2.4.5', opensearch: '', valkey: '' })];
let captured: Error | null = null;
try {
validatePreferencesAgainstMatrix({ search: 'opensearch', cache: 'valkey' }, entries);
} catch (e) {
captured = e as Error;
}
expect(captured).not.toBeNull();
expect(captured!.message).toMatch(/opensearch/);
expect(captured!.message).toMatch(/valkey/);
});
});
@@ -0,0 +1,43 @@
import { PackageMatrixVersion } from '../../matrix/matrix-type';
import { ServicePreferences, Tier, TIER_IMPLEMENTATIONS } from './tier-map';
/**
* Verifies that every preferenced implementation is supported by every
* matrix entry. Collects all violations and throws a single
* consolidated error.
*/
export const validatePreferencesAgainstMatrix = (
preferences: ServicePreferences,
entries: PackageMatrixVersion[]
): void => {
const errors: string[] = [];
for (const tierKey of Object.keys(preferences) as Tier[]) {
const implementation = preferences[tierKey]!;
const tierImplementations = TIER_IMPLEMENTATIONS[tierKey];
const offenders: { version: string; supported: string[] }[] = [];
for (const entry of entries) {
const value = entry[implementation as keyof PackageMatrixVersion];
const isSupported = typeof value === 'string' && value.trim() !== '';
if (!isSupported) {
const supported = tierImplementations.filter(name => {
const v = entry[name as keyof PackageMatrixVersion];
return typeof v === 'string' && v.trim() !== '';
});
offenders.push({ version: entry.version, supported });
}
}
if (offenders.length > 0) {
const list = offenders
.map(o => ` - magento ${o.version} (supported: ${o.supported.length > 0 ? o.supported.join(', ') : '<none>'})`)
.join('\n');
errors.push(`service_preferences "${implementation}" is not satisfied for:\n${list}`);
}
}
if (errors.length > 0) {
throw new Error(errors.join('\n\n'));
}
}
@@ -80,4 +80,29 @@ export const valkeyConfig: ServiceTemplate = {
ports: ['6379:6379']
};
}
};
};
/**
* Builds the nginx service config for the web tier. Takes the image
* (sourced from the matrix entry's `nginx` field) and the runner
* workspace path so the volume mount lands the Magento install at
* `/var/www/html` inside the container. Pairs with `buildPhpFpmConfig`
* — they're emitted together as the web tier.
*/
export const buildNginxConfig = (image: string, workspace: string): ServiceConfig => ({
image,
ports: ['80:80'],
volumes: [`${workspace}:/var/www/html`],
options: '--health-cmd "nginx -t" --health-interval=10s --health-retries=3 --health-timeout=5s --health-start-period=5s'
});
/**
* Builds the php-fpm service config for the web tier. Composes the
* image from the matrix entry's `php` version (the mappia magento-php
* image stream is the only widely-used Magento-aware php-fpm image).
* Pairs with `buildNginxConfig`.
*/
export const buildPhpFpmConfig = (phpVersion: string, workspace: string): ServiceConfig => ({
image: `mappia/magento-php:fpm-alpine${phpVersion}`,
volumes: [`${workspace}:/var/www/html`]
});
@@ -5,8 +5,9 @@ describe('getIndividialVersionsForProject', () => {
it('returns individual versions matrix for magento-open-source', () => {
expect(Object.keys(getIndividualVersionsForProject("magento-open-source")).length).toBeGreaterThan(0)
expect(Object.keys(getIndividualVersionsForProject("mage-os")).length).toBeGreaterThan(0)
expect(Object.keys(getIndividualVersionsForProject("mage-os-minimal")).length).toBeGreaterThan(0)
})
it('throws error if no individual versions are specified for given project', () => {
expect(() => getIndividualVersionsForProject(<Project>"ahsoka")).toThrowError()
})
@@ -16,6 +17,7 @@ describe('getCompositeVersionsForProject', () => {
it('returns composite versions matrix for magento-open-source', () => {
expect(Object.keys(getCompositeVersionsForProject("magento-open-source")).length).toBeGreaterThan(0)
expect(Object.keys(getCompositeVersionsForProject("mage-os")).length).toBeGreaterThan(0)
expect(Object.keys(getCompositeVersionsForProject("mage-os-minimal")).length).toBeGreaterThan(0)
})
it('throws error if no composite versions are specified for given project', () => {
@@ -3,11 +3,13 @@ import { PackageMatrixVersion } from "../matrix/matrix-type";
const individual = {
'mage-os': require('./mage-os/individual.json'),
'mage-os-minimal': require('./mage-os-minimal/individual.json'),
'magento-open-source': require('./magento-open-source/individual.json')
}
const composite = {
'mage-os': require('./mage-os/composite.json'),
'mage-os-minimal': require('./mage-os-minimal/composite.json'),
'magento-open-source': require('./magento-open-source/composite.json')
}
@@ -0,0 +1,44 @@
{
"mage-os/project-minimal-edition": {
"magento": "mage-os/project-minimal-edition",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
},
"mage-os/project-minimal-edition:next": {
"magento": "mage-os/project-minimal-edition:next",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
},
"mage-os/project-minimal-edition:>=3.0 <3.1": {
"magento": "mage-os/project-minimal-edition:>=3.0 <3.1",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
}
}
@@ -0,0 +1,17 @@
{
"mage-os/project-minimal-edition:3.0.0": {
"magento": "mage-os/project-minimal-edition:3.0.0",
"upstream": "2.4.9",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
}
}
@@ -6,13 +6,12 @@
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.1-management",
"valkey": "valkey/valkey:8.0",
"redis": "redis:7.2",
"valkey": "valkey/valkey:8",
"varnish": "varnish:7.7",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-04-15T00:00:00+0000",
"eol": "2029-04-15T00:00:00+0000"
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
},
"mage-os/project-community-edition:next": {
"magento": "mage-os/project-community-edition:next",
@@ -21,13 +20,12 @@
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.1-management",
"valkey": "valkey/valkey:8.0",
"redis": "redis:7.2",
"valkey": "valkey/valkey:8",
"varnish": "varnish:7.7",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-04-15T00:00:00+0000",
"eol": "2029-04-15T00:00:00+0000"
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
},
"mage-os/project-community-edition:>=1.0 <1.1": {
"magento": "mage-os/project-community-edition:>=1.0 <1.1",
@@ -126,6 +124,34 @@
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-03-10T00:00:00+0000",
"eol": "2029-04-15T00:00:00+0000"
"eol": "2026-05-13T00:00:00+0000"
},
"mage-os/project-community-edition:>=2.3 <2.4": {
"magento": "mage-os/project-community-edition:>=2.3 <2.4",
"php": 8.4,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:2.19.1",
"rabbitmq": "rabbitmq:4.0-management",
"redis": "redis:7.2",
"varnish": "varnish:7.6",
"nginx": "nginx:1.26",
"os": "ubuntu-latest",
"release": "2026-05-13T00:00:00+0000",
"eol": "2026-05-19T00:00:00+0000"
},
"mage-os/project-community-edition:>=3.0 <3.1": {
"magento": "mage-os/project-community-edition:>=3.0 <3.1",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
}
}
@@ -253,6 +253,36 @@
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-04-15T00:00:00+0000",
"eol": "2029-04-15T00:00:00+0000"
"eol": "2026-05-13T00:00:00+0000"
},
"mage-os/project-community-edition:2.3.0": {
"magento": "mage-os/project-community-edition:2.3.0",
"upstream": "2.4.8-p5",
"php": 8.4,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:2.19.1",
"rabbitmq": "rabbitmq:4.0-management",
"redis": "redis:7.2",
"varnish": "varnish:7.6",
"nginx": "nginx:1.26",
"os": "ubuntu-latest",
"release": "2026-05-13T00:00:00+0000",
"eol": "2026-05-19T00:00:00+0000"
},
"mage-os/project-community-edition:3.0.0": {
"magento": "mage-os/project-community-edition:3.0.0",
"upstream": "2.4.9",
"php": 8.5,
"composer": "2.9.8",
"mysql": "mysql:8.4",
"opensearch": "opensearchproject/opensearch:3",
"rabbitmq": "rabbitmq:4.2-management",
"valkey": "valkey/valkey:9",
"varnish": "varnish:8",
"nginx": "nginx:1.28",
"os": "ubuntu-latest",
"release": "2026-05-19T00:00:00+0000",
"eol": "2029-05-19T00:00:00+0000"
}
}