From e90f193a1c7a56c6cd1c1bdd01630b9498ae4806 Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Mon, 19 Oct 2026 11:21:46 +0300 Subject: [PATCH 01/16] feat: Split ci into layer-scoped workflows --- .github/actions/acceptance-lane/action.yml | 49 ++ .github/actions/acceptance-report/action.yml | 35 + .github/workflows/acceptance.yml | 176 +++++ .github/workflows/admin.yml | 117 ++++ .github/workflows/ci.yml | 691 +++---------------- .github/workflows/nightly.yml | 58 ++ .github/workflows/php.yml | 344 +++++++++ .github/workflows/store-release.yml | 48 +- .github/workflows/storefront.yml | 84 +++ CONTRIBUTING.md | 4 +- bin/ci-acceptance-report.sh | 52 ++ bin/ci-assert-gate-coverage.sh | 77 +++ docs/releasing.md | 10 +- tests/acceptance/playwright.config.ts | 2 + 14 files changed, 1124 insertions(+), 623 deletions(-) create mode 100644 .github/actions/acceptance-lane/action.yml create mode 100644 .github/actions/acceptance-report/action.yml create mode 100644 .github/workflows/acceptance.yml create mode 100644 .github/workflows/admin.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/storefront.yml create mode 100755 bin/ci-acceptance-report.sh create mode 100755 bin/ci-assert-gate-coverage.sh diff --git a/.github/actions/acceptance-lane/action.yml b/.github/actions/acceptance-lane/action.yml new file mode 100644 index 00000000..f79094e9 --- /dev/null +++ b/.github/actions/acceptance-lane/action.yml @@ -0,0 +1,49 @@ +name: Acceptance lane +description: >- + Boots a Shopware lane through bin/ci-write-compose.sh with the plugin installed and the + administration built, then installs the acceptance suite. Expects the plugin checked out at + agentic-commerce/. + +inputs: + lane: + description: 'Lane for bin/ci-write-compose.sh: 6.5.x, 6.6.x, 6.7.14.x or trunk' + required: true + shopware-ref: + description: 'shopware/shopware ref to check out' + required: true + +runs: + using: composite + steps: + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ inputs.shopware-ref }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/tests/acceptance/package-lock.json + - name: Write the lane's compose stack + shell: bash + env: + LANE: ${{ inputs.lane }} + run: agentic-commerce/bin/ci-write-compose.sh "${GITHUB_WORKSPACE}/shopware" "${LANE}" + # The Setup project logs into the administration, so the bootstrap builds it. "auto" keeps each + # lane's default build tool and leaves the 6.6 feature dump untouched after the script exits. + - name: Boot the shop with the administration built + shell: bash + env: + SHOPWARE_REF: ${{ inputs.lane }} + CI_ADMIN_BOOTSTRAP_MODE: cold + CI_ADMIN_BROWSER_VALIDATE: '0' + CI_ADMIN_KEEP_STACK: '1' + run: agentic-commerce/bin/ci-admin-smoke.sh "${GITHUB_WORKSPACE}/shopware" auto + # After the bootstrap: bin/ci-smoke.sh copies the plugin checkout into the shop. + - name: Install the acceptance suite + shell: bash + working-directory: agentic-commerce/tests/acceptance + run: | + npm ci --no-audit --no-fund + npx playwright install chromium --with-deps \ No newline at end of file diff --git a/.github/actions/acceptance-report/action.yml b/.github/actions/acceptance-report/action.yml new file mode 100644 index 00000000..0d4d8b74 --- /dev/null +++ b/.github/actions/acceptance-report/action.yml @@ -0,0 +1,35 @@ +name: Acceptance report +description: >- + Merges every acceptance-blob-* artifact of the run into one Playwright report, writes the failure + table to the step summary and uploads the report as the acceptance-report artifact. Expects the + plugin checked out at the workspace root. + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: tests/acceptance/package-lock.json + - name: Install the acceptance suite + shell: bash + working-directory: tests/acceptance + run: npm ci --no-audit --no-fund + - name: Download the shard reports + uses: actions/download-artifact@v7 + with: + pattern: acceptance-blob-* + path: ${{ runner.temp }}/acceptance-blobs + merge-multiple: true + - name: Merge the shard reports + shell: bash + run: bin/ci-acceptance-report.sh "${RUNNER_TEMP}/acceptance-blobs" "${RUNNER_TEMP}/acceptance-report" + - name: Upload the merged report + uses: actions/upload-artifact@v6 + with: + name: acceptance-report + path: ${{ runner.temp }}/acceptance-report + if-no-files-found: error + overwrite: true + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 00000000..7b7381d4 --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,176 @@ +name: Acceptance + +# The tests/acceptance suite: static checks, the lane x shard matrix and, on pull requests, the +# changed specs repeated ten times. The callers merge the shard reports once per run +# (.github/actions/acceptance-report). + +on: + workflow_call: + inputs: + lanes: + description: 'JSON array of bin/ci-write-compose.sh lanes' + type: string + shards: + type: number + repeat_each: + type: number + run_changed: + description: 'On pull requests, repeat the changed specs ten times without retries' + type: boolean + skip_static: + description: 'Skip lint, typecheck and the Signer project (another call runs them)' + type: boolean + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} + +permissions: + contents: read + +jobs: + acceptance-static: + if: ${{ !inputs.skip_static }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: tests/acceptance + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: tests/acceptance/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run lint + - run: npm run typecheck + # The RFC 9421 signer checks need no shop. + - run: npm run test:signer + + acceptance-plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + changed: ${{ steps.plan.outputs.changed }} + steps: + - uses: actions/checkout@v6 + with: + # On pull_request the merge ref's first parent is the base branch. + fetch-depth: 2 + - id: plan + env: + LANES: ${{ inputs.lanes || '["trunk"]' }} + SHARDS: ${{ inputs.shards || 1 }} + RUN_CHANGED: ${{ inputs.run_changed && github.event_name == 'pull_request' }} + run: | + set -euo pipefail + # jq -e fails on an empty lane list instead of planning an empty run. + matrix="$(jq -ce --argjson shards "${SHARDS}" \ + 'select(length > 0) | {include: [.[] as $lane | range(1; $shards + 1) | {lane: $lane, shard: ., shards: $shards}]}' <<<"${LANES}")" + changed='[]' + if [[ "${RUN_CHANGED}" == "true" ]]; then + # Setup and Signer are dependency projects that already run in every acceptance job. + # quotePath=false: git would otherwise print non-ASCII paths quoted, matching no spec. + changed="$(git -c core.quotePath=false diff --name-only --diff-filter=d HEAD^1 HEAD -- \ + ':(glob)tests/acceptance/tests/**/*.spec.ts' \ + ':(exclude,glob)tests/acceptance/tests/Setup/**' \ + ':(exclude,glob)tests/acceptance/tests/Signer/**' \ + | jq -Rnc '[inputs | select(length > 0) | ltrimstr("tests/acceptance/")]')" + fi + echo "matrix=${matrix}" >> "${GITHUB_OUTPUT}" + echo "changed=${changed}" >> "${GITHUB_OUTPUT}" + echo "Changed specs repeated ten times: ${changed}" >> "${GITHUB_STEP_SUMMARY}" + + acceptance: + name: acceptance (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.shard }}/${{ matrix.shards }}) + needs: acceptance-plan + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.acceptance-plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: ./agentic-commerce/.github/actions/acceptance-lane + with: + lane: ${{ matrix.lane }} + shopware-ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} + - id: suite + working-directory: agentic-commerce/tests/acceptance + env: + # bin/ci-write-compose.sh publishes the shop on this URL inside the container and on the + # runner. The agent-profile fixture writes into SHOPWARE_DIR/public and the shop fetches + # the profile back from this URL in development mode. + APP_URL: http://localhost:8000 + SHOPWARE_DIR: ${{ github.workspace }}/shopware + ACCEPTANCE_LANE: ${{ matrix.lane }} + PLAYWRIGHT_BLOB_OUTPUT_FILE: ${{ github.workspace }}/acceptance-blob/acceptance-${{ matrix.lane }}-${{ matrix.shard }}.zip + SHARD: ${{ matrix.shard }}/${{ matrix.shards }} + REPEAT_EACH: ${{ inputs.repeat_each || 1 }} + run: npx playwright test --grep-invert @UcpKnownBlocked --shard="${SHARD}" --repeat-each="${REPEAT_EACH}" --reporter=blob,list + - if: ${{ !cancelled() && steps.suite.outcome != 'skipped' }} + uses: actions/upload-artifact@v6 + with: + name: acceptance-blob-${{ matrix.lane }}-${{ matrix.shard }} + path: acceptance-blob + if-no-files-found: error + overwrite: true + retention-days: 7 + + # A spec that passes once can still be flaky: every spec this pull request adds or changes runs + # ten times without retries on trunk. + acceptance-tests-changed: + needs: acceptance-plan + if: needs.acceptance-plan.outputs.changed != '[]' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: ./agentic-commerce/.github/actions/acceptance-lane + with: + lane: trunk + shopware-ref: ${{ env.SHOPWARE_TRUNK_REF }} + - working-directory: agentic-commerce/tests/acceptance + env: + APP_URL: http://localhost:8000 + SHOPWARE_DIR: ${{ github.workspace }}/shopware + # Paths chosen by the pull request: read as data, never interpolated into the script. + CHANGED_SPECS: ${{ needs.acceptance-plan.outputs.changed }} + run: | + mapfile -t specs < <(jq -r '.[]' <<<"${CHANGED_SPECS}") + # A changed spec under KnownBlocked/ is filtered out completely by --grep-invert. + npx playwright test --grep-invert @UcpKnownBlocked --repeat-each=10 --retries=0 --pass-with-no-tests --reporter=list -- "${specs[@]}" + + acceptance-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - acceptance-static + - acceptance-plan + - acceptance + - acceptance-tests-changed + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} + # Only the two conditional jobs may be skipped, and only when their condition says so. + # alls-green drops the empty entries of this comma-separated list. + allowed-skips: >- + ${{ inputs.skip_static && 'acceptance-static' || '' }}, + ${{ needs.acceptance-plan.outputs.changed == '[]' && 'acceptance-tests-changed' || '' }} \ No newline at end of file diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml new file mode 100644 index 00000000..69ed60e5 --- /dev/null +++ b/.github/workflows/admin.yml @@ -0,0 +1,117 @@ +name: Administration + +# Administration lint, Jest with coverage, and the per-lane webpack and Vite build gates. + +on: + workflow_call: + inputs: + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + +permissions: + contents: read + +jobs: + admin-static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - run: npm run ci:admin-static + working-directory: agentic-commerce + env: + ADMIN_PATH: ${{ github.workspace }}/shopware/src/Administration/Resources/app/administration + - name: Upload Jest coverage + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: admin-jest-coverage + path: agentic-commerce/tests/jest/administration/coverage + if-no-files-found: warn + retention-days: 7 + + admin-matrix: + runs-on: ubuntu-latest + name: admin-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.mode }}) + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + mode: webpack + - lane: '6.6.x' + mode: webpack + - lane: '6.6.x' + mode: vite + - lane: 'trunk' + mode: vite + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} + - name: Install Playwright browser + system deps (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + working-directory: agentic-commerce + - name: Install Playwright system deps (cached browser) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-admin-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-admin-smoke.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.mode }}" + env: + SHOPWARE_REF: ${{ matrix.lane }} + CI_ADMIN_BOOTSTRAP_MODE: cold + CI_ADMIN_BROWSER_VALIDATE: 1 + CI_ADMIN_KEEP_STACK: 0 + + admin-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - admin-static + - admin-matrix + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 255c912a..4cdd06b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,10 @@ name: CI +# Orchestrates the layer workflows (php.yml, admin.yml, storefront.yml, acceptance.yml, +# package-zip.yml) and aggregates them in validation-gate, the one check branch protection and +# store-release.yml rely on. Every event runs the full validation: there is no fast path that +# trusts an earlier run, so a green gate always means the work ran on this commit. + on: push: branches: @@ -7,6 +12,9 @@ on: tags: - 'v[0-9]+.[0-9]+.[0-9]+' pull_request: + # labeled: adding run/acceptance-full has to start a run. Any other label change reruns CI + # too. Skipping the jobs on those runs would post a passing gate over an earlier red one. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: sdk_ref: @@ -25,31 +33,15 @@ on: description: 'Shopware trunk/6.7 ref' default: 'trunk' required: true - run_full_matrix: - description: 'Run the full validation matrix even when no pull request requires it' - default: false - required: false - type: boolean - -env: - # Compatibility lanes intentionally exercise Shopware versions with known - # advisories. Keep dependency resolution running, then report advisories - # without making them the test result. Composer 2.9 uses the first variable; - # newer Composer versions use the policy variable. - COMPOSER_NO_SECURITY_BLOCKING: '1' - COMPOSER_POLICY_ADVISORIES_BLOCK: '0' - UCP_SDK_REF: ${{ github.event.inputs.sdk_ref || vars.UCP_SDK_REF || 'main' }} - SHOPWARE_65_REF: ${{ github.event.inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} - SHOPWARE_66_REF: ${{ github.event.inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} - SHOPWARE_TRUNK_REF: ${{ github.event.inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} - SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} - PHPSTAN_SHOPWARE_VERSION: '0.2.4' permissions: - actions: read - checks: read contents: read - pull-requests: read + +concurrency: + # Only a pull request's superseded runs cancel each other. Every other run gets its own group, so + # a main push, a tag push or a dispatch on the same commit never cancels the run a release reads. + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: shell-lint: @@ -77,617 +69,100 @@ jobs: - name: Changelog parity run: bin/ci-assert-changelog-parity.sh - verify-main-validation: - runs-on: ubuntu-latest - outputs: - run_full_matrix: ${{ steps.decide.outputs.run_full_matrix }} - reason: ${{ steps.decide.outputs.reason }} - pr_number: ${{ steps.decide.outputs.pr_number }} - pr_url: ${{ steps.decide.outputs.pr_url }} - pr_head_sha: ${{ steps.decide.outputs.pr_head_sha }} - pr_merge_commit_sha: ${{ steps.decide.outputs.pr_merge_commit_sha }} - checked_at: ${{ steps.decide.outputs.checked_at }} - steps: - - id: decide - env: - EVENT_NAME: ${{ github.event_name }} - REF_NAME: ${{ github.ref_name }} - RUN_FULL_MATRIX_INPUT: ${{ github.event.inputs.run_full_matrix || 'false' }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - checked_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" - - emit() { - echo "run_full_matrix=$1" >> "$GITHUB_OUTPUT" - echo "reason=$2" >> "$GITHUB_OUTPUT" - echo "pr_number=${3:-}" >> "$GITHUB_OUTPUT" - echo "pr_url=${4:-}" >> "$GITHUB_OUTPUT" - echo "pr_head_sha=${5:-}" >> "$GITHUB_OUTPUT" - echo "pr_merge_commit_sha=${6:-}" >> "$GITHUB_OUTPUT" - echo "checked_at=${checked_at}" >> "$GITHUB_OUTPUT" - echo "Validation decision: run_full_matrix=$1 reason=$2" - } - - if [[ "${EVENT_NAME}" == "pull_request" ]]; then - emit true pull_request - exit 0 - fi - - if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then - if [[ "${RUN_FULL_MATRIX_INPUT}" == "true" ]]; then - emit true workflow_dispatch_requested - else - emit false workflow_dispatch_fast_path - fi - exit 0 - fi - - if [[ "${EVENT_NAME}" != "push" || "${REF_NAME}" != "main" ]]; then - emit true unsupported_event - exit 0 - fi - - pulls_json="$(gh api -H "Accept: application/vnd.github+json" "/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" 2>/tmp/commit-pulls.err || true)" - - if [[ -z "${pulls_json}" ]] || ! jq -e type >/dev/null 2>&1 <<<"${pulls_json}"; then - emit true commit_pr_lookup_failed - exit 0 - fi - - pr_json="$(jq -c '[.[] | select(.merged_at != null and .base.ref == "main")] | sort_by(.merged_at) | last // empty' <<<"${pulls_json}")" - - if [[ -z "${pr_json}" ]]; then - emit true no_merged_pr_for_main_commit - exit 0 - fi - - pr_number="$(jq -r '.number' <<<"${pr_json}")" - pr_url="$(jq -r '.html_url' <<<"${pr_json}")" - pr_head_sha="$(jq -r '.head.sha' <<<"${pr_json}")" - pr_merge_commit_sha="$(jq -r '.merge_commit_sha // ""' <<<"${pr_json}")" - - checks_file="$(mktemp)" - if ! gh pr checks "${pr_number}" --repo "${GITHUB_REPOSITORY}" --json name,state,bucket,workflow >"${checks_file}" 2>/tmp/pr-checks.err; then - emit true pr_checks_lookup_failed "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - - expected_checks=( - "shell-lint" - "docs-lint" - "admin-static" - "php-quality (php-8.1)" - "php-quality (php-8.2)" - "php-quality (6.7.11)" - "php-quality (6.7.12)" - "php-quality (trunk)" - "future-compatibility" - "shopware-matrix (6.5.8)" - "shopware-matrix (6.6.x)" - "shopware-matrix (trunk)" - "mysql84-install" - "admin-matrix (6.5.8, webpack)" - "admin-matrix (6.6.x, webpack)" - "admin-matrix (6.6.x, vite)" - "admin-matrix (trunk, vite)" - "storefront-matrix (6.5.8)" - "storefront-matrix (6.6.x)" - "storefront-matrix (trunk)" - "zip-artifact / Package extension zip" - "zip-artifact / Install the zip (6.5.x)" - "zip-artifact / Install the zip (6.6.x)" - "zip-artifact / Install the zip (trunk)" - ) - - for check_name in "${expected_checks[@]}"; do - states="$(jq -r --arg name "${check_name}" '[.[] | select(.name == $name) | .state] | unique | join(",")' "${checks_file}")" - - if [[ -z "${states}" ]]; then - emit true "missing_check:${check_name}" "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - - if [[ "${states}" != "SUCCESS" ]]; then - emit true "non_success_check:${check_name}:${states}" "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - done - - emit false trusted_pr_checks_success "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - - php-quality: + # A job only blocks a merge if a gate needs it. This proves, for every gated workflow, that no + # job was added without its gate noticing. + workflow-lint: runs-on: ubuntu-latest - name: php-quality (${{ matrix.lane }}) - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: 'php-8.1' - php-version: '8.1' - - lane: 'php-8.2' - php-version: '8.2' - # Future-compatibility rules inspect the upcoming core API annotations, which are - # available only on trunk. - - lane: 'trunk' - php-version: '8.2' - shopware-ref: 'trunk' - # 6.7.11.x: core still ships Defaults::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE here - # (present only in 6.7.10-6.7.11), so this lane keeps exercising the "core owns the - # feature" coexistence path. Moving branch ref -> always the latest 6.7.11 patch. - - lane: '6.7.11' - php-version: '8.2' - shopware-ref: '6.7.11.x' - # 6.7.12.x: core removed that constant and handed the feature back to the plugin, so - # this lane proves the "plugin re-owns the feature" path on the 6.7 framework. Moving - # branch ref -> always the latest 6.7.12 patch. - - lane: '6.7.12' - php-version: '8.2' - shopware-ref: '6.7.12.x' steps: - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - coverage: none - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.shopware-ref || matrix.php-version == '8.1' && env.SHOPWARE_65_REF || env.SHOPWARE_66_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - name: Install opt-in future compatibility rules - if: matrix.php-version != '8.1' - # Install without plugins so phpstan-shopware's default rules.neon is not registered. - # The package requires PHP 8.2, while this plugin remains compatible with PHP 8.1. - run: composer require --dev shopwarelabs/phpstan-shopware:0.2.4 --no-interaction --no-plugins --ignore-platform-req=php - working-directory: agentic-commerce - - name: Capture plugin Composer advisory report - if: matrix.lane == 'php-8.2' - working-directory: agentic-commerce - run: | - audit_file="${GITHUB_WORKSPACE}/composer-audit-plugin.json" - audit_status=0 - - if composer audit --no-dev --format=json > "${audit_file}"; then - audit_status=0 - else - audit_status=$? - fi - - if [[ ! -s "${audit_file}" ]]; then - if [[ "${audit_status}" -eq 0 ]]; then - printf '{"advisories":{},"abandoned":[]}\n' > "${audit_file}" - else - printf '{"error":"composer audit produced no JSON","exitCode":%d}\n' "${audit_status}" > "${audit_file}" - fi - fi - - echo "Composer audit report written to ${audit_file} (exit ${audit_status})." - - name: Upload plugin Composer advisory report - if: always() && matrix.lane == 'php-8.2' - uses: actions/upload-artifact@v6 - with: - name: composer-audit-plugin - path: composer-audit-plugin.json - if-no-files-found: ignore - retention-days: 7 - - run: chmod +x agentic-commerce/bin/ci-smoke.sh - - run: composer ci - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + - name: Every job is aggregated by its workflow's gate + run: bin/ci-assert-gate-coverage.sh .github/workflows/*.yml - # The early warning that used to be spread across every gate job: does this plugin still build - # against the SDK's moving main branch, before that branch becomes a release the plugin pins? - # - # Deliberately the only job that stages an SDK checkout and relabels it as the pinned version, - # and deliberately non-blocking. A path repository pointed at main and labelled `0.0.7` tests a - # branch wearing a release's version number, which is not what any merchant installs -- and - # when main moved to require a newer `core`, that relabelling turned this repository's main red - # for five days and would have done the same to every open pull request at once. The warning is - # worth having; it is not worth blocking merges on, so it is absent from `expected_checks` and - # from `validation-gate`. Read it, and open an SDK issue -- do not let it stop a merge. - sdk-main-compatibility: - runs-on: ubuntu-latest - continue-on-error: true - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - - uses: actions/checkout@v6 - with: - repository: agentic-commerce-alliance/ucp-php-sdk - ref: ${{ env.UCP_SDK_REF }} - path: ucp-php-sdk - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_66_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - name: Configure private SDK path repositories - run: | - composer config repositories.ucp-sdk-core '{"type":"path","url":"../ucp-php-sdk/packages/core","options":{"symlink":false,"versions":{"ucp-php-sdk/core":"0.0.7"}}}' - composer config repositories.ucp-sdk-symfony '{"type":"path","url":"../ucp-php-sdk/packages/symfony-bundle","options":{"symlink":false,"versions":{"ucp-php-sdk/symfony-bundle":"0.0.7"}}}' - working-directory: agentic-commerce - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - run: composer ci - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + php: + uses: ./.github/workflows/php.yml + with: + sdk_ref: ${{ inputs.sdk_ref }} + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} - # What Shopware trunk will require of this plugin, against the SDK release the plugin pins. - # - # Like every other gate job, the SDK is resolved from Packagist at the pinned versions rather - # than staged from a checkout: a job that blocks merges has to test what merchants install. - # See sdk-main-compatibility above for where the SDK-main signal lives now. - future-compatibility: - runs-on: ubuntu-latest - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - name: Install opt-in future compatibility rules - # Do not enable phpstan-shopware's default rules.neon via its extension installer. - run: composer require --dev "shopwarelabs/phpstan-shopware:${PHPSTAN_SHOPWARE_VERSION}" --no-interaction --no-plugins - working-directory: agentic-commerce - - run: php bin/run.php phpstan analyse --memory-limit=1G -c phpstan.neon - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + admin: + uses: ./.github/workflows/admin.yml + with: + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} - admin-static: - runs-on: ubuntu-latest - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - run: npm run ci:admin-static - working-directory: agentic-commerce - env: - ADMIN_PATH: ${{ github.workspace }}/shopware/src/Administration/Resources/app/administration + storefront: + uses: ./.github/workflows/storefront.yml + with: + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} # Builds the archive the way a Store release does, installs it on 6.5, 6.6 and trunk, and leaves # it as the run's downloadable SwagAgenticCommerce artifact. A pull request build carries its PR # number in the extension label, so an installed copy never passes for the release. zip-artifact: - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' permissions: contents: read uses: ./.github/workflows/package-zip.yml with: label_stamp: ${{ github.event_name == 'pull_request' && 'pull-request' || 'none' }} - shopware-matrix: - runs-on: ubuntu-latest - # The 6.5 lane checks out the head of the 6.5.x branch, which is a 6.5.8 patch -- the - # oldest release composer.json accepts. Name the check after that floor so it is - # visible that the lowest supported version is the one under test. - name: shopware-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) - timeout-minutes: 30 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - - lane: '6.6.x' - # The newest release line without connect-time MCP toolsets: the only one where the - # UCP tools reach /ucp/mcp through AdvertiseUcpToolsWithoutToolsetPinningPass. - - lane: '6.7.14.x' - - lane: 'trunk' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: ${{ matrix.lane }} - # Run the integration and functional suites on every shopware-matrix lane. ci-smoke - # installs Shopware's dev deps so the suites run on the lane's own phpunit - # (6.5->9.x, 6.6->10.x, trunk->11.x; Shopware's test base classes are coupled - # to the phpunit major), covering lane-specific behavior everywhere the smoke runs. - CI_SMOKE_RUN_PHPUNIT: '1' - CI_COMPOSER_AUDIT_OUTPUT: ${{ github.workspace }}/composer-audit.json - - name: Upload Composer advisory report - if: always() - uses: actions/upload-artifact@v6 - with: - name: composer-audit-${{ matrix.lane }} - path: composer-audit.json - if-no-files-found: ignore - retention-days: 7 - - run: npm run test:e2e:ucp -- --reporter=list - working-directory: agentic-commerce - env: - BASE_URL: http://localhost:8000 - SHOPWARE_REF: ${{ matrix.lane }} - - composer-security-report: - name: composer-security-report - runs-on: ubuntu-latest - needs: - - verify-main-validation - - php-quality - - shopware-matrix - if: always() && needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - name: Download Composer advisory reports - uses: actions/download-artifact@v7 - continue-on-error: true - with: - pattern: composer-audit-* - path: composer-audits - - name: Summarize Composer security advisories - continue-on-error: true - run: bash agentic-commerce/bin/ci-composer-audit-report.sh composer-audits - - mysql84-install: - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh - # Provision the stack on MySQL 8.4 (not the default MariaDB) and run the - # plugin install/migrations only. MySQL 8.4 rejects a foreign key that - # references a prefix of a composite key, so this lane catches migration - # regressions like the order (id, version_id) FK that MariaDB accepts. - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" trunk - env: - CI_DB_FLAVOR: mysql84 - - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: trunk - CI_DB_FLAVOR: mysql84 - CI_SMOKE_BOOTSTRAP_ONLY: '1' - - admin-matrix: - runs-on: ubuntu-latest - name: admin-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.mode }}) - timeout-minutes: 45 + acceptance: + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["trunk"]' + run_changed: true + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} + + # The 6.5 and 6.6 acceptance lanes. A pull request opts in with the run/acceptance-full label; + # every push and dispatch runs them, so main and a release candidate are validated on all lanes. + acceptance-full: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run/acceptance-full') + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["6.5.x", "6.6.x"]' + skip_static: true + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} + + # One report for the run: merges the blobs of both acceptance calls. + acceptance-report: needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - mode: webpack - - lane: '6.6.x' - mode: webpack - - lane: '6.6.x' - mode: vite - - lane: 'trunk' - mode: vite - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} - - name: Install Playwright browser + system deps (cache miss) - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: agentic-commerce - - name: Install Playwright system deps (cached browser) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-admin-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-admin-smoke.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.mode }}" - env: - SHOPWARE_REF: ${{ matrix.lane }} - CI_ADMIN_BOOTSTRAP_MODE: cold - CI_ADMIN_BROWSER_VALIDATE: 1 - CI_ADMIN_KEEP_STACK: 0 - - storefront-matrix: + - acceptance + - acceptance-full + if: ${{ !cancelled() }} runs-on: ubuntu-latest - name: storefront-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) - timeout-minutes: 45 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - - lane: '6.6.x' - - lane: 'trunk' steps: - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} - - name: Install Playwright browser + system deps (cache miss) - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: agentic-commerce - - name: Install Playwright system deps (cached browser) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-storefront-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-storefront-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: ${{ matrix.lane }} - CI_STOREFRONT_BOOTSTRAP_MODE: cold - CI_STOREFRONT_KEEP_STACK: 0 + - uses: ./.github/actions/acceptance-report validation-gate: - name: validation-gate - runs-on: ubuntu-latest + # store-release.yml requires this job, by this name, in a push run on main. A manual run reports + # as dispatch-gate, so it never posts a validation-gate check on a pull request's head. + name: ${{ github.event_name == 'workflow_dispatch' && 'dispatch-gate' || 'validation-gate' }} + # always(), not !cancelled(): a skipped gate would count as passing. if: always() needs: - - verify-main-validation - shell-lint - - php-quality - - future-compatibility - - admin-static + - docs-lint + - workflow-lint + - php + - admin + - storefront - zip-artifact - - shopware-matrix - - mysql84-install - - admin-matrix - - storefront-matrix + - acceptance + - acceptance-full + - acceptance-report + runs-on: ubuntu-latest steps: - - name: Require complete validation - env: - RUN_FULL_MATRIX: ${{ needs.verify-main-validation.outputs.run_full_matrix }} - VERIFY_RESULT: ${{ needs.verify-main-validation.result }} - SHELL_RESULT: ${{ needs.shell-lint.result }} - PHP_RESULT: ${{ needs.php-quality.result }} - FUTURE_COMPATIBILITY_RESULT: ${{ needs.future-compatibility.result }} - ADMIN_STATIC_RESULT: ${{ needs.admin-static.result }} - ZIP_ARTIFACT_RESULT: ${{ needs.zip-artifact.result }} - SHOPWARE_RESULT: ${{ needs.shopware-matrix.result }} - MYSQL84_RESULT: ${{ needs.mysql84-install.result }} - ADMIN_RESULT: ${{ needs.admin-matrix.result }} - STOREFRONT_RESULT: ${{ needs.storefront-matrix.result }} - run: | - set -euo pipefail - - if [[ "${VERIFY_RESULT}" != "success" || "${SHELL_RESULT}" != "success" ]]; then - echo "Validation setup or shell lint failed." >&2 - exit 1 - fi - - if [[ "${RUN_FULL_MATRIX}" == "false" ]]; then - echo "The exact merged PR checks were already verified." - exit 0 - fi - - if [[ "${RUN_FULL_MATRIX}" != "true" ]]; then - echo "Unable to determine whether the full validation matrix was required." >&2 - exit 1 - fi - - for result in \ - "${PHP_RESULT}" \ - "${FUTURE_COMPATIBILITY_RESULT}" \ - "${ADMIN_STATIC_RESULT}" \ - "${ZIP_ARTIFACT_RESULT}" \ - "${SHOPWARE_RESULT}" \ - "${MYSQL84_RESULT}" \ - "${ADMIN_RESULT}" \ - "${STOREFRONT_RESULT}" - do - if [[ "${result}" != "success" ]]; then - echo "A required validation job did not succeed: ${result}" >&2 - exit 1 - fi - done - - echo "All required validation jobs succeeded." + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} + # acceptance-full may be skipped only on a pull request without the label. + allowed-skips: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run/acceptance-full') && '["acceptance-full"]' || '[]' }} \ No newline at end of file diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..22cde00c --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,58 @@ +name: Nightly + +# The layer workflows once a day on main, with every acceptance lane repeated three times, so +# breakage from the moving Shopware branches shows up between merges. store-release.yml reads only +# ci.yml push runs, so nightly-gate never makes a commit releasable. + +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Includes phpstan-trunk, against SHOPWARE_TRUNK_REF. + php: + uses: ./.github/workflows/php.yml + + admin: + uses: ./.github/workflows/admin.yml + + storefront: + uses: ./.github/workflows/storefront.yml + + zip-artifact: + uses: ./.github/workflows/package-zip.yml + + acceptance: + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["6.5.x", "6.6.x", "trunk"]' + repeat_each: 3 + + acceptance-report: + needs: + - acceptance + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/acceptance-report + + nightly-gate: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - php + - admin + - storefront + - zip-artifact + - acceptance + - acceptance-report + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 00000000..510c5a5f --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,344 @@ +name: PHP + +# PHP quality, the Shopware lanes (smoke incl. the strict signature stage, integration and +# functional suites, UCP e2e), the MySQL 8.4 install and the advisory checks. + +on: + workflow_call: + inputs: + sdk_ref: + type: string + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + # Compatibility lanes intentionally exercise Shopware versions with known + # advisories. Keep dependency resolution running, then report advisories + # without making them the test result. Composer 2.9 uses the first variable; + # newer Composer versions use the policy variable. + COMPOSER_NO_SECURITY_BLOCKING: '1' + COMPOSER_POLICY_ADVISORIES_BLOCK: '0' + UCP_SDK_REF: ${{ inputs.sdk_ref || vars.UCP_SDK_REF || 'main' }} + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} + PHPSTAN_SHOPWARE_VERSION: '0.2.4' + +permissions: + contents: read + +jobs: + php-quality: + runs-on: ubuntu-latest + name: php-quality (${{ matrix.lane }}) + strategy: + fail-fast: false + matrix: + include: + - lane: 'php-8.1' + php-version: '8.1' + - lane: 'php-8.2' + php-version: '8.2' + # Future-compatibility rules inspect the upcoming core API annotations, which are + # available only on trunk. + - lane: 'trunk' + php-version: '8.2' + shopware-ref: 'trunk' + # 6.7.11.x: core still ships Defaults::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE here + # (present only in 6.7.10-6.7.11), so this lane keeps exercising the "core owns the + # feature" coexistence path. Moving branch ref -> always the latest 6.7.11 patch. + - lane: '6.7.11' + php-version: '8.2' + shopware-ref: '6.7.11.x' + # 6.7.12.x: core removed that constant and handed the feature back to the plugin, so + # this lane proves the "plugin re-owns the feature" path on the 6.7 framework. Moving + # branch ref -> always the latest 6.7.12 patch. + - lane: '6.7.12' + php-version: '8.2' + shopware-ref: '6.7.12.x' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.shopware-ref || matrix.php-version == '8.1' && env.SHOPWARE_65_REF || env.SHOPWARE_66_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - name: Install opt-in future compatibility rules + if: matrix.php-version != '8.1' + # Install without plugins so phpstan-shopware's default rules.neon is not registered. + # The package requires PHP 8.2, while this plugin remains compatible with PHP 8.1. + run: composer require --dev shopwarelabs/phpstan-shopware:0.2.4 --no-interaction --no-plugins --ignore-platform-req=php + working-directory: agentic-commerce + - name: Capture plugin Composer advisory report + if: matrix.lane == 'php-8.2' + working-directory: agentic-commerce + run: | + audit_file="${GITHUB_WORKSPACE}/composer-audit-plugin.json" + audit_status=0 + + if composer audit --no-dev --format=json > "${audit_file}"; then + audit_status=0 + else + audit_status=$? + fi + + if [[ ! -s "${audit_file}" ]]; then + if [[ "${audit_status}" -eq 0 ]]; then + printf '{"advisories":{},"abandoned":[]}\n' > "${audit_file}" + else + printf '{"error":"composer audit produced no JSON","exitCode":%d}\n' "${audit_status}" > "${audit_file}" + fi + fi + + echo "Composer audit report written to ${audit_file} (exit ${audit_status})." + - name: Upload plugin Composer advisory report + if: always() && matrix.lane == 'php-8.2' + uses: actions/upload-artifact@v6 + with: + name: composer-audit-plugin + path: composer-audit-plugin.json + if-no-files-found: ignore + retention-days: 7 + - run: chmod +x agentic-commerce/bin/ci-smoke.sh + - run: composer ci + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # What Shopware trunk will require of this plugin, against the SDK release the plugin pins. + # + # Like every other gate job, the SDK is resolved from Packagist at the pinned versions rather + # than staged from a checkout: a job that blocks merges has to test what merchants install. + # See sdk-main-compatibility below for where the SDK-main signal lives now. + phpstan-trunk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - name: Install opt-in future compatibility rules + # Do not enable phpstan-shopware's default rules.neon via its extension installer. + run: composer require --dev "shopwarelabs/phpstan-shopware:${PHPSTAN_SHOPWARE_VERSION}" --no-interaction --no-plugins + working-directory: agentic-commerce + - run: php bin/run.php phpstan analyse --memory-limit=1G -c phpstan.neon + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # The early warning that used to be spread across every gate job: does this plugin still build + # against the SDK's moving main branch, before that branch becomes a release the plugin pins? + # + # Deliberately the only job that stages an SDK checkout and relabels it as the pinned version, + # and deliberately non-blocking. A path repository pointed at main and labelled `0.0.7` tests a + # branch wearing a release's version number, which is not what any merchant installs -- and + # when main moved to require a newer `core`, that relabelling turned this repository's main red + # for five days and would have done the same to every open pull request at once. The warning is + # worth having; it is not worth blocking merges on, so the job is continue-on-error. + # Read it, and open an SDK issue -- do not let it stop a merge. + sdk-main-compatibility: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - uses: actions/checkout@v6 + with: + repository: agentic-commerce-alliance/ucp-php-sdk + ref: ${{ env.UCP_SDK_REF }} + path: ucp-php-sdk + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_66_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - name: Configure private SDK path repositories + run: | + composer config repositories.ucp-sdk-core '{"type":"path","url":"../ucp-php-sdk/packages/core","options":{"symlink":false,"versions":{"ucp-php-sdk/core":"0.0.7"}}}' + composer config repositories.ucp-sdk-symfony '{"type":"path","url":"../ucp-php-sdk/packages/symfony-bundle","options":{"symlink":false,"versions":{"ucp-php-sdk/symfony-bundle":"0.0.7"}}}' + working-directory: agentic-commerce + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - run: composer ci + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # Advisory: reports public API breaks between the base and this change without blocking it. On a + # pull request HEAD is the merge commit, so HEAD^ is the base branch. + bc-check: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer bc-check + + shopware-matrix: + runs-on: ubuntu-latest + # The 6.5 lane checks out the head of the 6.5.x branch, which is a 6.5.8 patch -- the + # oldest release composer.json accepts. Name the check after that floor so it is + # visible that the lowest supported version is the one under test. + name: shopware-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + - lane: '6.6.x' + # The newest release line without connect-time MCP toolsets: the only one where the + # UCP tools reach /ucp/mcp through AdvertiseUcpToolsWithoutToolsetPinningPass. + - lane: '6.7.14.x' + - lane: 'trunk' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: ${{ matrix.lane }} + # Run the integration and functional suites on every shopware-matrix lane. ci-smoke + # installs Shopware's dev deps so the suites run on the lane's own phpunit + # (6.5->9.x, 6.6->10.x, trunk->11.x; Shopware's test base classes are coupled + # to the phpunit major), covering lane-specific behavior everywhere the smoke runs. + CI_SMOKE_RUN_PHPUNIT: '1' + CI_COMPOSER_AUDIT_OUTPUT: ${{ github.workspace }}/composer-audit.json + # The e2e step below runs against this stack. + CI_SMOKE_KEEP_STACK: '1' + - name: Upload Composer advisory report + if: always() + uses: actions/upload-artifact@v6 + with: + name: composer-audit-${{ matrix.lane }} + path: composer-audit.json + if-no-files-found: ignore + retention-days: 7 + - run: npm run test:e2e:ucp -- --reporter=list + working-directory: agentic-commerce + env: + BASE_URL: http://localhost:8000 + SHOPWARE_REF: ${{ matrix.lane }} + + # Non-blocking through continue-on-error (see CONTRIBUTING.md, Composer advisory reporting). + composer-security-report: + name: composer-security-report + runs-on: ubuntu-latest + continue-on-error: true + needs: + - php-quality + - shopware-matrix + # Runs after failed lanes too, to report whatever audits they uploaded. + if: always() + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - name: Download Composer advisory reports + uses: actions/download-artifact@v7 + continue-on-error: true + with: + pattern: composer-audit-* + path: composer-audits + - name: Summarize Composer security advisories + continue-on-error: true + run: bash agentic-commerce/bin/ci-composer-audit-report.sh composer-audits + + mysql84-install: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh + # Provision the stack on MySQL 8.4 (not the default MariaDB) and run the + # plugin install/migrations only. MySQL 8.4 rejects a foreign key that + # references a prefix of a composite key, so this lane catches migration + # regressions like the order (id, version_id) FK that MariaDB accepts. + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" trunk + env: + CI_DB_FLAVOR: mysql84 + - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: trunk + CI_DB_FLAVOR: mysql84 + CI_SMOKE_BOOTSTRAP_ONLY: '1' + + php-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - php-quality + - phpstan-trunk + - sdk-main-compatibility + - bc-check + - shopware-matrix + - composer-security-report + - mysql84-install + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index a5e99910..8fb67a02 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -39,7 +39,8 @@ jobs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: - checks: read + # Job-level permissions replace the workflow's: reading CI's runs needs actions: read here. + actions: read contents: read outputs: version: ${{ steps.metadata.outputs.version }} @@ -92,18 +93,49 @@ jobs: exit 1 fi - gate_id="$( - gh api --paginate "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" \ - --jq '.check_runs[] | select(.name == "validation-gate" and .conclusion == "success") | .id' \ - | head -n 1 + # A check run named validation-gate proves nothing: any workflow, dispatch or app can + # create one. Require the newest ci.yml push run on main for this commit to be complete, + # with a successful validation-gate job in its latest attempt. A run still in progress + # fails, so an older green run cannot outvote a newer one. + runs="$( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&event=push&branch=main&per_page=100" \ + --jq '.workflow_runs[]' \ + | jq -s --arg sha "${GITHUB_SHA}" --arg repo "${GITHUB_REPOSITORY}" ' + map(select(.event == "push" and .head_branch == "main" and .head_sha == $sha + and .head_repository.full_name == $repo + and (.path | startswith(".github/workflows/ci.yml")))) + | sort_by(.id)' )" - if [[ -z "${gate_id}" ]]; then - echo "The exact main commit has no successful validation-gate check." >&2 + if [[ "$(jq length <<<"${runs}")" -eq 0 ]]; then + echo "No CI push run on main exists for ${GITHUB_SHA}." >&2 exit 1 fi - echo "Validated main HEAD ${GITHUB_SHA} with CI gate ${gate_id}." + if jq -e 'any(.[]; .status != "completed")' <<<"${runs}" >/dev/null; then + echo "CI for ${GITHUB_SHA} is still running. Release once it has finished." >&2 + exit 1 + fi + + run_id="$(jq -r 'last.id' <<<"${runs}")" + attempt="$(jq -r 'last.run_attempt' <<<"${runs}")" + + # filter=latest lists each job's latest attempt. The gate needs every other job, so it is + # re-run whenever any of them is. + gates="$( + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \ + --jq '.jobs[] | select(.name == "validation-gate")' \ + | jq -s . + )" + + if ! jq -e --argjson attempt "${attempt}" \ + 'length == 1 and .[0].conclusion == "success" and .[0].run_attempt == $attempt' <<<"${gates}" >/dev/null; then + echo "CI run ${run_id} (attempt ${attempt}) has no successful validation-gate job." >&2 + exit 1 + fi + + echo "Validated main HEAD ${GITHUB_SHA} with CI run ${run_id} (attempt ${attempt})." release-package: name: Build and install-test the release package diff --git a/.github/workflows/storefront.yml b/.github/workflows/storefront.yml new file mode 100644 index 00000000..978b1b75 --- /dev/null +++ b/.github/workflows/storefront.yml @@ -0,0 +1,84 @@ +name: Storefront + +# The per-lane storefront build gates. + +on: + workflow_call: + inputs: + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + +permissions: + contents: read + +jobs: + storefront-matrix: + runs-on: ubuntu-latest + name: storefront-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + - lane: '6.6.x' + - lane: 'trunk' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} + - name: Install Playwright browser + system deps (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + working-directory: agentic-commerce + - name: Install Playwright system deps (cached browser) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-storefront-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-storefront-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: ${{ matrix.lane }} + CI_STOREFRONT_BOOTSTRAP_MODE: cold + CI_STOREFRONT_KEEP_STACK: 0 + + storefront-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - storefront-matrix + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a07beb5..cc106d01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,8 +178,8 @@ through the centralized reporting flow: `storefront-matrix`, MySQL, or individual smoke jobs. A nonzero `composer audit` status can also represent abandoned packages; inspect the JSON `advisories` data instead of treating the exit code as proof of a security advisory. -- Keep `composer-security-report` outside `validation-gate`. Missing, malformed, or known-vulnerable - compatibility reports must remain visible without blocking functional validation. +- Keep `composer-security-report` non-blocking (job-level `continue-on-error`). Missing, malformed, + or known-vulnerable compatibility reports must remain visible without failing `validation-gate`. Do not reuse a complete installed Shopware tree across these jobs. Lanes use different Shopware versions and administration build modes, and the tests mutate dependencies, assets, databases, and diff --git a/bin/ci-acceptance-report.sh b/bin/ci-acceptance-report.sh new file mode 100755 index 00000000..21b9fc5f --- /dev/null +++ b/bin/ci-acceptance-report.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# Merges the acceptance blob reports of a run into one HTML report and writes the failed and flaky +# tests to the step summary. Test failures are reported, not returned: the acceptance jobs already +# fail for them. This fails only when there is nothing to merge or the merge writes no report. +# +# Usage: bin/ci-acceptance-report.sh + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: bin/ci-acceptance-report.sh " >&2 + exit 2 +fi + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mkdir -p "$1" "$2" +blob_dir="$(cd "$1" && pwd)" +report_dir="$(cd "$2" && pwd)" + +if ! compgen -G "${blob_dir}/*.zip" >/dev/null; then + echo "FAIL: no blob reports in ${blob_dir}. Did every acceptance job fail before running the suite?" >&2 + exit 1 +fi + +# Without -c, merge-reports does not load the suite's config, which would demand APP_URL. +( + cd "${PLUGIN_ROOT}/tests/acceptance" + PLAYWRIGHT_HTML_OUTPUT_DIR="${report_dir}/html" PLAYWRIGHT_HTML_OPEN=never \ + PLAYWRIGHT_JSON_OUTPUT_FILE="${report_dir}/report.json" \ + npx playwright merge-reports --reporter=html,json "${blob_dir}" +) || true + +if [[ ! -s "${report_dir}/report.json" || ! -s "${report_dir}/html/index.html" ]]; then + echo "FAIL: merging the blob reports in ${blob_dir} wrote no report." >&2 + exit 1 +fi + +{ + echo "### Acceptance report" + echo + jq -r '.stats | "\(.expected // 0) passed, \(.unexpected // 0) failed, \(.flaky // 0) flaky, \(.skipped // 0) skipped. The HTML report (tests tagged by lane) is the acceptance-report artifact."' \ + "${report_dir}/report.json" + echo + jq -r ' + [.suites[] | recurse(.suites[]?) | .specs[]? as $spec | $spec.tests[] + | select(.status == "unexpected" or .status == "flaky") + | "| \(if .status == "unexpected" then "failed" else "flaky" end) | \(.projectName) | `\($spec.file):\($spec.line) \($spec.title | gsub("\\|"; "\\|"))` |"] + | unique + | if length == 0 then "No failed or flaky tests." else (["| Status | Project | Test |", "| --- | --- | --- |"] + .)[] end + ' "${report_dir}/report.json" +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" \ No newline at end of file diff --git a/bin/ci-assert-gate-coverage.sh b/bin/ci-assert-gate-coverage.sh new file mode 100755 index 00000000..697241e5 --- /dev/null +++ b/bin/ci-assert-gate-coverage.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Fails unless every job of each given workflow is aggregated by that workflow's gate. +# +# Usage: bin/ci-assert-gate-coverage.sh ... +# +# The gate is the one job that runs re-actors/alls-green. It must run with `if: always()` (a skipped +# gate counts as passing), pass `jobs: ${{ toJSON(needs) }}`, and need every other job of its file. +# A job that should not block is made advisory with continue-on-error, not left outside the gate's +# needs. Jobs are compared by id, not by display name. +# +# A reusable workflow (workflow_call) must have a gate, so a skipped job inside it stays visible to +# its caller. Other workflows without a gate (release, notifications) are not checked. +# Needs yq (mikefarah) and jq. + +set -euo pipefail + +# Reusable workflows that need no gate: package-zip.yml has no conditional jobs and is also called by +# store-release.yml, where a gate job would only add a check. +ungated_reusable=(package-zip.yml) + +if [[ $# -eq 0 ]]; then + echo "Usage: bin/ci-assert-gate-coverage.sh ..." >&2 + exit 2 +fi + +status=0 + +for workflow in "$@"; do + workflow_json="$(yq -o=json '.' "${workflow}")" + jobs_json="$(jq '.jobs // {}' <<<"${workflow_json}")" + + mapfile -t gates < <(jq -r ' + to_entries[] + | select([.value.steps[]?.uses // "" | startswith("re-actors/alls-green@")] | any) + | .key' <<<"${jobs_json}") + + if [[ ${#gates[@]} -eq 0 ]]; then + reusable="$(jq -r '.on | type == "object" and has("workflow_call")' <<<"${workflow_json}")" + if [[ "${reusable}" != "true" ]] || [[ " ${ungated_reusable[*]} " == *" $(basename "${workflow}") "* ]]; then + echo "skip: ${workflow} has no gate and needs none." + continue + fi + fi + + if [[ ${#gates[@]} -ne 1 ]]; then + echo "FAIL: ${workflow} needs exactly one re-actors/alls-green gate job, found ${#gates[@]}: ${gates[*]:-none}." >&2 + status=1 + continue + fi + gate="${gates[0]}" + + problems="$(jq -r --arg gate "${gate}" ' + .[$gate] as $g + | ($g.needs // [] | if type == "string" then [.] else . end) as $needs + | (keys - [$gate]) as $jobs + | ([$g.steps[] | select((.uses // "") | startswith("re-actors/alls-green@"))][0].with.jobs // "") as $input + | [ + (if ($g.if // "" | gsub("[${} ]"; "")) != "always()" then "\($gate) must run with if: always()" else empty end), + (if ($input | gsub(" "; "")) != "${{toJSON(needs)}}" then "\($gate) must pass jobs: ${{ toJSON(needs) }} to alls-green" else empty end), + (($jobs - $needs)[] | "job \(.) is missing from \($gate).needs"), + (($needs - $jobs)[] | "\($gate).needs names \(.), which is not a job of this workflow") + ] + | .[]' <<<"${jobs_json}")" + + if [[ -n "${problems}" ]]; then + while IFS= read -r problem; do + echo "FAIL: ${workflow}: ${problem}." >&2 + done <<<"${problems}" + status=1 + continue + fi + + echo "ok: ${workflow}: ${gate} aggregates all $(jq 'keys | length - 1' <<<"${jobs_json}") other job(s)." +done + +exit "${status}" \ No newline at end of file diff --git a/docs/releasing.md b/docs/releasing.md index 40ea8575..12b0d347 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -103,11 +103,11 @@ Every job that blocks a merge resolves both SDK packages **from Packagist at the exception and the only job that stages an SDK checkout: it points a path repository at `UCP_SDK_REF` and relabels that source as the pinned version. -That relabelling is why the exception is `continue-on-error` and absent from both `expected_checks` -and `validation-gate`. A branch wearing a release's version number is not what anyone installs, and -when SDK `main` moved to require a newer `core`, having it on the merge path turned this -repository's `main` red for five days — and would have done the same to every open pull request at -once. Read the job, open an SDK issue; do not let it stop a merge. +That relabelling is why the exception is `continue-on-error`, so it never fails `validation-gate`. A +branch wearing a release's version number is not what anyone installs, and when SDK `main` moved to +require a newer `core`, having it on the merge path turned this repository's `main` red for five +days — and would have done the same to every open pull request at once. Read the job, open an SDK +issue; do not let it stop a merge. `bin/ci-smoke.sh` follows the same rule through `UCP_SDK_SOURCE`, which defaults to `packagist`; only `sdk-main-compatibility` and local manual testing set `path`. diff --git a/tests/acceptance/playwright.config.ts b/tests/acceptance/playwright.config.ts index bac8cd3c..730090db 100644 --- a/tests/acceptance/playwright.config.ts +++ b/tests/acceptance/playwright.config.ts @@ -55,6 +55,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: 'html', + // Tells the lanes apart in the report CI merges from all of them. + tag: process.env.ACCEPTANCE_LANE ? `@lane-${process.env.ACCEPTANCE_LANE}` : undefined, timeout: 60_000, expect: { timeout: 15_000, From 79c3d8b5a00a974784559ae99508b017875be84c Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Mon, 19 Oct 2026 15:56:48 +0300 Subject: [PATCH 02/16] feat: Add verbose to ci --- .github/workflows/acceptance.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 7b7381d4..226156ac 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -116,6 +116,9 @@ jobs: # the profile back from this URL in development mode. APP_URL: http://localhost:8000 SHOPWARE_DIR: ${{ github.workspace }}/shopware + # The lane's container runs with SHELL_VERBOSITY=-1 (bin/ci-write-compose.sh), which mutes + # the JSON the signing-key commands print. + UCP_CONSOLE: docker compose exec -T -e SHELL_VERBOSITY=0 web php bin/console ACCEPTANCE_LANE: ${{ matrix.lane }} PLAYWRIGHT_BLOB_OUTPUT_FILE: ${{ github.workspace }}/acceptance-blob/acceptance-${{ matrix.lane }}-${{ matrix.shard }}.zip SHARD: ${{ matrix.shard }}/${{ matrix.shards }} @@ -149,6 +152,7 @@ jobs: env: APP_URL: http://localhost:8000 SHOPWARE_DIR: ${{ github.workspace }}/shopware + UCP_CONSOLE: docker compose exec -T -e SHELL_VERBOSITY=0 web php bin/console # Paths chosen by the pull request: read as data, never interpolated into the script. CHANGED_SPECS: ${{ needs.acceptance-plan.outputs.changed }} run: | From 5e960b655d5c9a76b99b5fb7904ba6330ed8ff67 Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Tue, 20 Oct 2026 10:18:15 +0300 Subject: [PATCH 03/16] feat: Fix signer only --- tests/acceptance/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/acceptance/playwright.config.ts b/tests/acceptance/playwright.config.ts index fc601190..64680e21 100644 --- a/tests/acceptance/playwright.config.ts +++ b/tests/acceptance/playwright.config.ts @@ -54,7 +54,7 @@ const browser = { const browserWithOwnHosts = { ...browser, launchOptions: { - args: [`--host-resolver-rules=MAP *.ucp-acceptance.test ${new URL(appUrl).hostname}`], + args: signerOnly ? [] : [`--host-resolver-rules=MAP *.ucp-acceptance.test ${new URL(appUrl).hostname}`], }, }; From 7fdede0005e33730c8a218e9a72e504fb55d5d63 Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Tue, 20 Oct 2026 11:33:34 +0300 Subject: [PATCH 04/16] fix: Add known issue number --- tests/acceptance/known-blockers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/known-blockers.ts b/tests/acceptance/known-blockers.ts index 5f44cbb1..deb6807d 100644 --- a/tests/acceptance/known-blockers.ts +++ b/tests/acceptance/known-blockers.ts @@ -80,14 +80,14 @@ export const knownBlockers: KnownBlocker[] = [ key: 'D10', description: 'On Shopware 6.7.12.x core looks discovery files up case-sensitively and ships only agents.md, and the plugin registers no fallback route there, so /AGENTS.md answers 404.', owner: 'dgrothaus-sw', - issueUrl: 'TODO: file the issue for /AGENTS.md on 6.7.12.x', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/187', reviewBy: '2026-12-31', }, { key: 'D11', description: 'Below Shopware 6.7.10.0 the Validate action builds the product export without its provider, so AbstractProviderValidator skips every agentic validator and Validate reports no errors for a feed whose export fails.', owner: 'dgrothaus-sw', - issueUrl: 'TODO: file the issue for Validate skipping the provider validators', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/187', reviewBy: '2026-12-31', }, ]; From 22949a117d6407adfe9be7c3d4f20b65ab80cc5f Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Tue, 20 Oct 2026 15:18:13 +0300 Subject: [PATCH 05/16] fix: Correct tests for 6.5 line --- tests/acceptance/fixtures/UcpAclUsers.ts | 16 +++++++++++++++- .../page-objects/AgenticCommerceFeedDetail.ts | 4 ++-- .../UcpAdmin/export-channel-attribution.spec.ts | 8 +++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/acceptance/fixtures/UcpAclUsers.ts b/tests/acceptance/fixtures/UcpAclUsers.ts index e7d99f46..8aa2838e 100644 --- a/tests/acceptance/fixtures/UcpAclUsers.ts +++ b/tests/acceptance/fixtures/UcpAclUsers.ts @@ -91,7 +91,12 @@ export const test = base.extend { const pending = TestDataService.createUserRetryingTokenConflicts({ admin: true }) - .then(async user => loginToAdministration(await createNewAdminPageContext(browser, SalesChannelBaseConfig), user, AdminApiContext)); + .then(async (user) => { + const loginPage = await createNewAdminPageContext(browser, SalesChannelBaseConfig); + await withoutUpdateNotification(loginPage); + + return loginToAdministration(loginPage, user, AdminApiContext); + }); otherPages.push(pending); return pending; @@ -112,6 +117,15 @@ export const test = base.extend { + await page.context().route('**/api/_action/update/check', route => route.fulfill({ json: {} })); +} + function aclRoleSuffix(): string { return Math.random().toString(36).slice(2, 8); } diff --git a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts index 490c63a0..db3767aa 100644 --- a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts +++ b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts @@ -60,10 +60,10 @@ export class AgenticCommerceFeedDetail { await expect(this.templateModal).toBeHidden(); } - /** The template modal's secondary action keeps the current template. */ + /** The template modal's secondary action keeps the current template. On 6.5 it is a footer button without a variant. */ async cancelTemplateChange(): Promise { await expect(this.templateModal).toBeVisible(); - await this.templateModal.locator('.mt-button--secondary, .sw-button--secondary').click(); + await this.templateModal.locator('.mt-button--secondary, .sw-button--secondary, .sw-modal__footer .sw-button:not(.sw-button--primary)').click(); await expect(this.templateModal).toBeHidden(); } diff --git a/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts b/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts index e9758393..673524ce 100644 --- a/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts +++ b/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts @@ -13,10 +13,12 @@ function isFilteredSearch(response: Response, entity: 'order' | 'customer', feed && criteria.includes(feedId); } -/** The write that stores the user's list filters. */ +/** The write that stores the user's list filters: `config-me`, or on 6.5 the `user_config` entity itself. */ function isStoredFilter(response: Response, feedId: string): boolean { - return response.request().method() === 'PATCH' - && new URL(response.url()).pathname.endsWith('/api/_info/config-me') + const pathname = new URL(response.url()).pathname; + + return ['PATCH', 'POST'].includes(response.request().method()) + && (pathname.endsWith('/api/_info/config-me') || /\/api\/user-config(\/[0-9a-f]{32})?$/.test(pathname)) && (response.request().postData() ?? '').includes(feedId); } From ee168664e7959b6ef764af7d6bc34760dbc02a61 Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Tue, 20 Oct 2026 16:26:53 +0300 Subject: [PATCH 06/16] fix: the Agentic Commerce tab page object on 6.6 --- .../page-objects/UcpSalesChannelDetail.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/page-objects/UcpSalesChannelDetail.ts b/tests/acceptance/page-objects/UcpSalesChannelDetail.ts index 6b5248b1..57e0c0ac 100644 --- a/tests/acceptance/page-objects/UcpSalesChannelDetail.ts +++ b/tests/acceptance/page-objects/UcpSalesChannelDetail.ts @@ -102,10 +102,15 @@ export class UcpSalesChannelDetail { } private async loadAgenticCommerceTab(salesChannelId: string, navigate: () => Promise): Promise { - const salesChannel = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId))); - const config = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId, '/config'))); - const savedPreview = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId, '/profile-preview'))); - const loadedFormPreview = this.page.waitForResponse(response => isRequest(response, 'POST', ucpApiPath(salesChannelId, '/profile-preview'))); + // Before 6.7.9 the save loads the UCP state once more, so a request of the page being left can + // still answer after the navigation started; its body is gone once the navigation commits. + const navigatedAt = Date.now(); + const loaded = (method: string, path: string) => this.page.waitForResponse(response => isRequest(response, method, path) + && response.request().timing().startTime >= navigatedAt); + const salesChannel = loaded('GET', ucpApiPath(salesChannelId)); + const config = loaded('GET', ucpApiPath(salesChannelId, '/config')); + const savedPreview = loaded('GET', ucpApiPath(salesChannelId, '/profile-preview')); + const loadedFormPreview = loaded('POST', ucpApiPath(salesChannelId, '/profile-preview')); await navigate(); @@ -120,7 +125,8 @@ export class UcpSalesChannelDetail { private async waitUntilSettled(): Promise { await expect(this.agenticCommerceTab).toBeVisible(); await expect(this.agenticCommerceTab.locator('.sw-skeleton')).toHaveCount(0); - await expect(this.page.getByTestId('ac-ucp__expose-switch')).toBeVisible(); + // On 6.6 `mt-switch` passes the hook to its container and its field, so it matches twice. + await expect(this.page.getByTestId('ac-ucp__expose-switch').first()).toBeVisible(); } /** The hook sits on the checkbox's wrapper on every lane. */ From 42247f86e37277f0a5734ede748d02279a9b3e3f Mon Sep 17 00:00:00 2001 From: Dumea Alexandru Date: Wed, 21 Oct 2026 14:53:08 +0300 Subject: [PATCH 07/16] fix: Add known issues to blocker specs --- tests/acceptance/known-blockers.ts | 14 +++++++ .../page-objects/AgenticCommerceFeedDetail.ts | 19 +++++++++ .../KnownBlocked/product-feed-export.spec.ts | 41 +++++++++++++++++++ .../export-channel-attribution.spec.ts | 4 +- .../UcpAdmin/product-feed-export.spec.ts | 33 ++++++++++++++- .../tests/UcpContent/knownBlockerRanges.ts | 3 ++ 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/tests/KnownBlocked/product-feed-export.spec.ts diff --git a/tests/acceptance/known-blockers.ts b/tests/acceptance/known-blockers.ts index deb6807d..3caca8c0 100644 --- a/tests/acceptance/known-blockers.ts +++ b/tests/acceptance/known-blockers.ts @@ -90,4 +90,18 @@ export const knownBlockers: KnownBlocker[] = [ issueUrl: 'https://github.com/shopware/agentic-commerce/issues/187', reviewBy: '2026-12-31', }, + { + key: 'D12', + description: 'A feed channel\'s Save with an empty required provider setting is inconsistent. On 6.5 the first click does nothing visible: validateAgenticCommerceExportConfig() adds the error as a new key, which Vue 2 does not track, so the field is not marked. On a new channel (6.5 and 6.6) the second click saves anyway, because the plugin\'s onSave() override returns early inside the create page\'s $super chain.', + owner: 'alexdumea', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/309', + reviewBy: '2026-12-31', + }, + { + key: 'D13', + description: 'Before the Meteor select, cancelling the "Apply template" modal on a saved feed channel leaves the cancelled template in the select: onTemplateModalClose() restores the value templateName already has, so the select is not redrawn.', + owner: 'alexdumea', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/310', + reviewBy: '2026-12-31', + }, ]; diff --git a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts index db3767aa..0ec8e176 100644 --- a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts +++ b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test'; import type { Locator, Page, Response } from '@playwright/test'; +import { FEED_SALES_CHANNEL_TYPE_ID } from '@services/UcpTestDataService'; import { goToAdminRoute } from './adminRoute'; const OPEN_AI_CONFIG_DOMAIN = 'SwagAgenticCommerce.openAiProductExport'; @@ -53,6 +54,24 @@ export class AgenticCommerceFeedDetail { await this.page.getByTestId(`mt-select-option--${templateName}`).click(); } + /** Opens the create page of a new feed channel and waits until its provider settings are rendered. */ + async openNew(): Promise { + await goToAdminRoute(this.page, `/sw/sales/channel/create/${FEED_SALES_CHANNEL_TYPE_ID}/base`); + await expect(this.returnPolicyUrl).toBeVisible(); + } + + /** 6.5 and 6.6 show the template as the native ``, later lanes as a Meteor select. */ @@ -54,10 +55,15 @@ export class AgenticCommerceFeedDetail { await this.page.getByTestId(`mt-select-option--${templateName}`).click(); } - /** Opens the create page of a new feed channel and waits until its provider settings are rendered. */ + /** Opens the create page of a new feed channel and waits until its provider settings are rendered and no loader is left. */ async openNew(): Promise { await goToAdminRoute(this.page, `/sw/sales/channel/create/${FEED_SALES_CHANNEL_TYPE_ID}/base`); await expect(this.returnPolicyUrl).toBeVisible(); + await this.expectLoaded(); + } + + private async expectLoaded(): Promise { + await expect(this.page.locator('.sw-skeleton, .sw-loader')).toHaveCount(0); } /** 6.5 and 6.6 show the template as the native `