From 8d00f8149abb5fe9dc9cc87775b108f30284cf21 Mon Sep 17 00:00:00 2001 From: Damien Retzinger Date: Sat, 9 May 2026 15:42:40 -0400 Subject: [PATCH] feat(cache-magento): add stamp caching for vendor/ directory (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `stamp` mode that caches the extracted `vendor/` directory in addition to the Composer download cache. On a warm hit, `composer install` is effectively a no-op and shaves 2–5 minutes off a full Magento install. --- cache-magento/README.md | 91 +++++++++++++++++++-------- cache-magento/action.yml | 72 +++++++++++++++++++++ cache-magento/compute-cache-keys.sh | 16 ++++- cache-magento/compute-stamp-paths.sh | 18 ++++++ cache-magento/fixtures/composer.lock | 9 +++ cache-magento/test.sh | 93 ++++++++++++++++++++++++---- 6 files changed, 260 insertions(+), 39 deletions(-) create mode 100755 cache-magento/compute-stamp-paths.sh create mode 100644 cache-magento/fixtures/composer.lock diff --git a/cache-magento/README.md b/cache-magento/README.md index f7d41dc..4c187c1 100644 --- a/cache-magento/README.md +++ b/cache-magento/README.md @@ -4,35 +4,74 @@ A Github Action that creates a composer cache for a Magento extension or store. ## Inputs - See the [action.yml](./action.yml) -| Input | Description | Required | Default | -| ------------------ | -------------------------------------------------------------------------------------- | -------- | ------------ | -| composer_cache_key | A key to version the composer cache. Can be incremented if you need to bust the cache. | false | '__mageos' | +| Input | Description | Required | Default | +| -------------------- | -------------------------------------------------------------------------------------- | -------- | ---------- | +| `composer_cache_key` | A key to version the composer cache. Can be incremented if you need to bust the cache. | false | `__mageos` | +| `working-directory` | The directory where Magento is installed (location of `vendor/` and `composer.lock`). | false | `.` | +| `stamp` | Cache the `vendor/` directory in addition to the Composer download cache. | false | `false` | -### Usage +## Cache keys + +The download cache key has the format: + +``` +composer | v5.8 | | | | +``` + +When `stamp: true`, the `vendor/` cache key has the format: + +``` +composer | stamp | v5.8 | | | | | +``` + +The `composer.lock` hash is derived from `working-directory/composer.lock` using `hashFiles`. The download key also gains the hash suffix when a Magento product package is detected at `working-directory`. + +## Usage + +### Extension (download cache only) ```yml -name: Magento Cache - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - showcase_cache: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: graycoreio/github-actions-magento2/cache-magento@main - id: cache-magento - - - run: composer install - shell: bash - name: Composer install +- uses: graycoreio/github-actions-magento2/cache-magento@main + with: + composer_cache_key: ${{ inputs.composer_cache_key }} ``` + +### Extension or store (download + vendor stamp cache) + +```yml +- uses: graycoreio/github-actions-magento2/setup-magento@main + id: setup-magento + with: + mode: extension # or store + # ... + +- uses: graycoreio/github-actions-magento2/cache-magento@main + with: + composer_cache_key: ${{ inputs.composer_cache_key }} + working-directory: ${{ steps.setup-magento.outputs.path }} + stamp: true + +- run: composer install + working-directory: ${{ steps.setup-magento.outputs.path }} +``` + +### Stamp Mode + +On a warm cache hit, `composer install` completes in ~0s because `vendor/` is already present — Composer sees everything installed and exits immediately. For a full Magento install this saves 2–5 minutes of package extraction per job. + +The trade-off is size. The `vendor/` directory for a Magento project runs 300–600 MB, so a frequent cache miss means you are consistently paying the upload cost without recouping it on the next run. + +As such, use `stamp: true` when `composer.lock` is stable across most runs — a store on a release branch, or extension CI against a pinned Magento version. Skip it when the lock changes often or when runner storage is constrained. + +> [!WARNING] +> **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@main +> with: +> stamp: ${{ github.actor != 'dependabot[bot]' }} +> ``` +> +> If you use Renovate, check its bot account name and adjust the condition accordingly. Dependabot PRs will pay full `composer install` time on every run, which is acceptable — nobody is waiting on them. diff --git a/cache-magento/action.yml b/cache-magento/action.yml index fed6235..c29f001 100644 --- a/cache-magento/action.yml +++ b/cache-magento/action.yml @@ -7,11 +7,31 @@ inputs: required: false default: "__mageos" description: A key to version the composer cache. Can be incremented if you need to bust the cache. + working-directory: + required: false + default: "." + description: "The working directory where Magento is installed (location of vendor/ and composer.lock)" + stamp: + required: false + default: "false" + description: "Cache the vendor/ directory in addition to the Composer download cache" + exclude-from-stamp: + required: false + default: "" + description: | + Newline-separated list of Composer package names to exclude from the stamp cache (e.g. magento/module-foo). + magento/magento2-base and mage-os/magento2-base are always excluded regardless of this input. outputs: cache-hit: description: "A boolean value to indicate an exact match was found for the key" value: ${{ steps.cache-magento-cache.outputs.cache-hit }} + key: + description: "The cache key used for the Composer download cache" + value: ${{ steps.cache-magento-keys.outputs.download-key }} + stamp-key: + description: "The cache key used for the vendor/ stamp cache (only set when stamp: true)" + value: ${{ steps.cache-magento-keys.outputs.stamp-key }} runs: using: "composite" @@ -31,6 +51,31 @@ runs: name: Compute Composer Version id: cache-magento-get-composer-version + - name: Validate working-directory is inside GITHUB_WORKSPACE + if: inputs.stamp == 'true' + shell: bash + run: | + WORKING_DIR="$(realpath '${{ inputs.working-directory }}')" + WORKSPACE="$(realpath '${{ github.workspace }}')" + if [[ "$WORKING_DIR" != "$WORKSPACE"* ]]; then + echo "::error::cache-magento: working-directory '${{ inputs.working-directory }}' resolves outside GITHUB_WORKSPACE. You cannot use the `stamp` cache in folders outside the workspace." + exit 1 + fi + + - uses: graycoreio/github-actions-magento2/get-magento-version@main + id: cache-magento-get-magento-version + with: + working-directory: ${{ inputs.working-directory }} + + - name: Validate composer.lock exists for stamp cache (store mode) + if: inputs.stamp == 'true' && steps.cache-magento-get-magento-version.outputs.project != '' + shell: bash + run: | + if [[ ! -f "${{ inputs.working-directory }}/composer.lock" ]]; then + echo "::error::cache-magento: stamp: true on a store requires a '${{ inputs.working-directory }}/composer.lock' to exist." + exit 1 + fi + - name: Compute cache keys id: cache-magento-keys shell: bash @@ -40,6 +85,8 @@ runs: "${{ runner.os }}" \ "${{ steps.cache-magento-get-php-version.outputs.version }}" \ "${{ steps.cache-magento-get-composer-version.outputs.version }}" \ + "${{ steps.cache-magento-get-magento-version.outputs.project }}" \ + "${{ hashFiles(format('{0}/composer.lock', inputs.working-directory)) }}" \ >> $GITHUB_OUTPUT - name: "Cache Composer Packages" @@ -47,8 +94,33 @@ runs: id: cache-magento-cache with: key: ${{ steps.cache-magento-keys.outputs.download-key }} + restore-keys: | + ${{ steps.cache-magento-keys.outputs.download-restore-key }} path: ${{ steps.cache-magento-composer-cache.outputs.dir }} + - name: Compute stamp paths + id: cache-magento-stamp-paths + if: inputs.stamp == 'true' + shell: bash + env: + EXCLUDE_FROM_STAMP: ${{ inputs.exclude-from-stamp }} + WORKING_DIR: ${{ inputs.working-directory }} + run: | + PATHS=$(bash "${{ github.action_path }}/compute-stamp-paths.sh" "$(realpath "$WORKING_DIR")" "$EXCLUDE_FROM_STAMP") + { + echo "paths<> "$GITHUB_OUTPUT" + + - name: "Cache Vendor (Stamp)" + uses: actions/cache@v5 + if: inputs.stamp == 'true' + with: + key: ${{ steps.cache-magento-keys.outputs.stamp-key }} + path: | + ${{ steps.cache-magento-stamp-paths.outputs.paths }} + branding: icon: "code" color: "green" diff --git a/cache-magento/compute-cache-keys.sh b/cache-magento/compute-cache-keys.sh index 9ed78cb..eb9c7f8 100644 --- a/cache-magento/compute-cache-keys.sh +++ b/cache-magento/compute-cache-keys.sh @@ -1,8 +1,20 @@ #!/usr/bin/env bash -# Args: composer_cache_key os php_version composer_version +# Args: composer_cache_key os php_version composer_version project lock_hash COMPOSER_CACHE_KEY="$1" OS="$2" PHP_VERSION="$3" COMPOSER_VERSION="$4" +PROJECT="$5" +LOCK_HASH="${6:-}" -echo "download-key=composer | v5.8 | ${OS} | ${COMPOSER_CACHE_KEY} | ${COMPOSER_VERSION} | ${PHP_VERSION}" +MODE="extension" +SUFFIX="" +if [ -n "$PROJECT" ]; then + MODE="store" + [ -n "$LOCK_HASH" ] && SUFFIX=" | $LOCK_HASH" +fi + +BASE="composer | v5.8 | ${OS} | ${MODE} | ${COMPOSER_CACHE_KEY} | ${COMPOSER_VERSION} | ${PHP_VERSION}" +echo "download-key=${BASE}${SUFFIX}" +echo "download-restore-key=${BASE}" +echo "stamp-key=composer | stamp | v5.8 | ${OS} | ${MODE} | ${COMPOSER_CACHE_KEY} | ${COMPOSER_VERSION} | ${PHP_VERSION}${SUFFIX}" diff --git a/cache-magento/compute-stamp-paths.sh b/cache-magento/compute-stamp-paths.sh new file mode 100755 index 0000000..edfc88d --- /dev/null +++ b/cache-magento/compute-stamp-paths.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Args: working_directory exclude_from_stamp +# working_directory: absolute path (caller is responsible for realpath resolution) +# exclude_from_stamp: newline-separated list of composer package names to exclude +WORKING_DIR="$1" +EXCLUDE_FROM_STAMP="${2:-}" +VENDOR="${WORKING_DIR}/vendor" + +PATHS="${VENDOR}/**"$'\n'"!${VENDOR}/**/"$'\n'"!${VENDOR}/magento/magento2-base"$'\n'"!${VENDOR}/magento/magento2-base/**"$'\n'"!${VENDOR}/mage-os/magento2-base"$'\n'"!${VENDOR}/mage-os/magento2-base/**" + +while IFS= read -r pkg; do + pkg="${pkg#"${pkg%%[![:space:]]*}"}" + pkg="${pkg%"${pkg##*[![:space:]]}"}" + [[ -z "$pkg" ]] && continue + PATHS="${PATHS}"$'\n'"!${VENDOR}/${pkg}"$'\n'"!${VENDOR}/${pkg}/**" +done <<< "$EXCLUDE_FROM_STAMP" + +echo "$PATHS" diff --git a/cache-magento/fixtures/composer.lock b/cache-magento/fixtures/composer.lock new file mode 100644 index 0000000..d3c6e89 --- /dev/null +++ b/cache-magento/fixtures/composer.lock @@ -0,0 +1,9 @@ +{ + "packages": [ + { + "name": "magento/product-community-edition", + "version": "2.4.7" + } + ], + "packages-dev": [] +} diff --git a/cache-magento/test.sh b/cache-magento/test.sh index 02e7e18..7b38b53 100644 --- a/cache-magento/test.sh +++ b/cache-magento/test.sh @@ -2,6 +2,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$SCRIPT_DIR/compute-cache-keys.sh" +SCRIPT_STAMP="$SCRIPT_DIR/compute-stamp-paths.sh" +LOCK_HASH=$(sha256sum "$SCRIPT_DIR/fixtures/composer.lock" | cut -d' ' -f1) PASS=0 FAIL=0 @@ -22,23 +24,92 @@ field() { echo "$1" | grep "^${2}=" | cut -d= -f2- } -# Default cache key (Linux) -OUT=$(bash "$SCRIPT" "_mageos" "Linux" "8.3.0" "2.2.6") -assert_eq "linux: download-key" \ - "composer | v5.8 | Linux | _mageos | 2.2.6 | 8.3.0" \ +# Extension mode: no project resolved, mode derived as "extension", no lock suffix +OUT=$(bash "$SCRIPT" "_mageos" "Linux" "8.3.0" "2.2.6" "" "") +assert_eq "extension: download-key" \ + "composer | v5.8 | Linux | extension | _mageos | 2.2.6 | 8.3.0" \ "$(field "$OUT" download-key)" +assert_eq "extension: download-restore-key" \ + "composer | v5.8 | Linux | extension | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" download-restore-key)" +assert_eq "extension: stamp-key" \ + "composer | stamp | v5.8 | Linux | extension | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" stamp-key)" -# OS segment differentiates macOS from Linux -OUT=$(bash "$SCRIPT" "_mageos" "macOS" "8.3.0" "2.2.6") -assert_eq "macos: download-key" \ - "composer | v5.8 | macOS | _mageos | 2.2.6 | 8.3.0" \ +# Store mode: project resolved, mode derived as "store", lock hash appended (restore-key drops lock for prefix match) +OUT=$(bash "$SCRIPT" "_mageos" "Linux" "8.3.0" "2.2.6" "magento/project-community-edition" "$LOCK_HASH") +assert_eq "store: download-key" \ + "composer | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0 | $LOCK_HASH" \ "$(field "$OUT" download-key)" +assert_eq "store: download-restore-key" \ + "composer | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" download-restore-key)" +assert_eq "store: stamp-key" \ + "composer | stamp | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0 | $LOCK_HASH" \ + "$(field "$OUT" stamp-key)" -# Custom composer_cache_key -OUT=$(bash "$SCRIPT" "custom-v2" "Linux" "8.1.5" "2.4.2") +# Store mode without composer.lock (e.g. stamp=false on a store before `composer install`): +# lock_hash is empty, so keys must not carry a trailing " | " with an empty hash slot. +OUT=$(bash "$SCRIPT" "_mageos" "Linux" "8.3.0" "2.2.6" "magento/project-community-edition" "") +assert_eq "store no-lock: download-key" \ + "composer | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" download-key)" +assert_eq "store no-lock: download-restore-key" \ + "composer | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" download-restore-key)" +assert_eq "store no-lock: stamp-key" \ + "composer | stamp | v5.8 | Linux | store | _mageos | 2.2.6 | 8.3.0" \ + "$(field "$OUT" stamp-key)" + +# Custom composer_cache_key, no project resolved +OUT=$(bash "$SCRIPT" "custom-v2" "Linux" "8.1.5" "2.4.2" "" "") assert_eq "custom key: download-key" \ - "composer | v5.8 | Linux | custom-v2 | 2.4.2 | 8.1.5" \ + "composer | v5.8 | Linux | extension | custom-v2 | 2.4.2 | 8.1.5" \ "$(field "$OUT" download-key)" +assert_eq "custom key: download-restore-key" \ + "composer | v5.8 | Linux | extension | custom-v2 | 2.4.2 | 8.1.5" \ + "$(field "$OUT" download-restore-key)" +assert_eq "custom key: stamp-key" \ + "composer | stamp | v5.8 | Linux | extension | custom-v2 | 2.4.2 | 8.1.5" \ + "$(field "$OUT" stamp-key)" + +# Stamp paths: no excludes — base paths only, with magento2-base always excluded +OUT=$(bash "$SCRIPT_STAMP" "/work" "") +EXPECTED="/work/vendor/** +!/work/vendor/**/ +!/work/vendor/magento/magento2-base +!/work/vendor/magento/magento2-base/** +!/work/vendor/mage-os/magento2-base +!/work/vendor/mage-os/magento2-base/**" +assert_eq "stamp paths: no excludes" "$EXPECTED" "$OUT" + +# Stamp paths: single exclude appended after the always-excluded base entries +OUT=$(bash "$SCRIPT_STAMP" "/work" "magento/module-foo") +EXPECTED="/work/vendor/** +!/work/vendor/**/ +!/work/vendor/magento/magento2-base +!/work/vendor/magento/magento2-base/** +!/work/vendor/mage-os/magento2-base +!/work/vendor/mage-os/magento2-base/** +!/work/vendor/magento/module-foo +!/work/vendor/magento/module-foo/**" +assert_eq "stamp paths: single exclude" "$EXPECTED" "$OUT" + +# Stamp paths: multiple excludes, with whitespace and blank lines tolerated +OUT=$(bash "$SCRIPT_STAMP" "/work" "$(printf 'magento/module-foo\n magento/module-bar \n\nvendor/pkg-baz\n')") +EXPECTED="/work/vendor/** +!/work/vendor/**/ +!/work/vendor/magento/magento2-base +!/work/vendor/magento/magento2-base/** +!/work/vendor/mage-os/magento2-base +!/work/vendor/mage-os/magento2-base/** +!/work/vendor/magento/module-foo +!/work/vendor/magento/module-foo/** +!/work/vendor/magento/module-bar +!/work/vendor/magento/module-bar/** +!/work/vendor/vendor/pkg-baz +!/work/vendor/vendor/pkg-baz/**" +assert_eq "stamp paths: multiple excludes with whitespace and blank lines" "$EXPECTED" "$OUT" echo "" echo "$PASS passed, $FAIL failed"