feat(cache-magento): add stamp caching for vendor/ directory (#245)

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.
This commit is contained in:
Damien Retzinger
2026-05-09 15:42:40 -04:00
parent 2d7238de14
commit 8d00f8149a
6 changed files with 260 additions and 39 deletions
+61 -22
View File
@@ -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' |
| -------------------- | -------------------------------------------------------------------------------------- | -------- | ---------- |
| `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 | <os> | <composer_cache_key> | <composer-version> | <php-version>
```
When `stamp: true`, the `vendor/` cache key has the format:
```
composer | stamp | v5.8 | <os> | <composer_cache_key> | <composer-version> | <php-version> | <composer.lock-hash>
```
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
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
shell: bash
name: 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 25 minutes of package extraction per job.
The trade-off is size. The `vendor/` directory for a Magento project runs 300600 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.
+72
View File
@@ -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<<EOF"
echo "$PATHS"
echo "EOF"
} >> "$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"
+14 -2
View File
@@ -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}"
+18
View File
@@ -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"
+9
View File
@@ -0,0 +1,9 @@
{
"packages": [
{
"name": "magento/product-community-edition",
"version": "2.4.7"
}
],
"packages-dev": []
}
+82 -11
View File
@@ -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"