Warm the Cypress deps cache from master and prefer it over the artifact (#38261)

* Warm the Cypress deps cache from master and prefer it over the artifact

The parent commit hands the Cypress deps to the workers as a run-scoped
artifact, which costs the shared Actions cache nothing but makes
prep-deps install on every run. A baseline run measured the cache path
it replaced at 6s per worker with prep-deps skipping the install
entirely, so paying an install plus a ~275 MB upload on every run is a
poor trade for the common case.

Restore the fast path without restoring the churn. A new scheduled
workflow becomes the sole writer of the deps key, warming it from master
daily and on any push that changes the cypress lockfile. prep-deps then
probes the key with lookup-only — a metadata call, not a download — and
publishes the result as a job output. Workers restore the warmed entry
on a hit and unpack the artifact only on a miss, so exactly one of the
two paths runs.

The net effect on the cache budget is one warm entry per master lockfile
instead of one per lockfile any run happens to build; the four live
entries today total 1.10 GB. PR and merge runs still never write.

restore-cypress-deps centralises the key so the warm workflow, the probe
and the workers cannot drift apart. Its payload must stay identical to
pack/unpack-cypress-deps, since the cache entry and the artifact are
interchangeable by construction.

A separate binary-only cache is deliberately omitted: keyed on the same
lockfile hash, it would invalidate in lockstep with the deps key and
earn its keep only when one entry is evicted and the other survives.
Keying it on the resolved cypress version instead would be a real win
and can follow on its own.

* Spell out which transport each Cypress deps action provides

restore-cypress-deps and unpack-cypress-deps both leave a worker with
node_modules and the Cypress binary, differing only in where they fetch
them from, and nothing in their descriptions said so. Name the transport
each one implements, when it is the one that runs, and the invariant that
lets the workers accept either.

No behaviour change; description metadata only.

* Warm the cache only from master, even on manual dispatch

* Report the Cypress deps cache outcome to the job summary

Which transport a run took was only legible by opening prep-deps and
reading which steps the probe skipped, and whether the warm workflow
actually saved anything was just as buried. Report both to
GITHUB_STEP_SUMMARY: prep-deps prints hit or miss, and the warm job
prints saved or already warm.

Both print the computed key alongside the verdict, which is what makes a
miss diagnosable — a lockfile or path drift reads as an unexpected key
rather than an unexplained miss.

The workers are deliberately left alone. Their summary would be twenty
near-identical lines per run, and the hit path already runs with
fail-on-cache-miss, so a worker either restores or fails loudly.
This commit is contained in:
Jesse Hallam authored and GitHub committed 2026-09-03 20:13:12 +00:00
1 parent bbd00c4f35
commit 1cf3c87173
5 files changed
+204 -10

No files matched your search

+9 -3
View File
@@ -1,8 +1,14 @@
name: "Pack Cypress deps artifact"
description: >
Tar the Cypress node_modules and binary into a run-scoped artifact for the
workers in e2e-tests-cypress-template.yml. The payload must stay in sync with
unpack-cypress-deps.
The producing half of the artifact transport for the Cypress deps: tars the
node_modules and Cypress binary just installed by prep-deps and uploads them
as a run-scoped artifact for this run's workers to unpack.
This is the fallback path, reached only when restore-cypress-deps found the
cache key cold. It is not a cache — the artifact is scoped to one run, costs
the shared Actions cache nothing, and is retained for a day. It must produce
the payload restore-cypress-deps would otherwise have restored, since the
workers accept either.
inputs:
fips:
@@ -0,0 +1,51 @@
name: "Restore Cypress deps cache"
description: >
The Actions-cache transport for the Cypress deps — node_modules plus the
Cypress binary — keyed on the cypress lockfile.
This is the fast path, taken whenever the key is warm: prep-deps asks whether
it exists with lookup-only, and the workers do the real restore. Read-only, as
e2e-ci-cache-warm-cypress-deps.yml is the only writer of the key, so PR and
merge runs never write to the Actions cache.
When the key is cold the template falls back to the artifact transport instead
— pack-cypress-deps into unpack-cypress-deps — which must land the same
payload in the same paths, so the two transports are interchangeable.
inputs:
lookup-only:
description: "Probe whether the key exists without downloading the entry."
required: false
default: "false"
fail-on-cache-miss:
description: "Fail the step when the exact key is absent."
required: false
default: "false"
outputs:
cache-hit:
description: "true on an exact-key hit"
value: ${{ steps.restore.outputs.cache-hit }}
key:
description: "The computed cache key"
value: ${{ steps.key.outputs.key }}
runs:
using: "composite"
steps:
- name: ci/compute-cypress-deps-key
id: key
shell: bash
run: echo "key=e2e-cypress-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/cypress/package-lock.json') }}" >> "${GITHUB_OUTPUT}"
- name: ci/restore-cypress-deps
id: restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
# No restore-keys: an older node_modules is not a usable substitute for
# this lockfile, and a partial hit would silently diverge from `npm ci`.
with:
path: |
e2e-tests/cypress/node_modules
~/.cache/Cypress
key: ${{ steps.key.outputs.key }}
lookup-only: ${{ inputs.lookup-only }}
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
@@ -1,7 +1,12 @@
name: "Unpack Cypress deps artifact"
description: >
Download and extract the run-scoped artifact built by pack-cypress-deps,
restoring the Cypress node_modules and binary into place.
The consuming half of the artifact transport for the Cypress deps: downloads
this run's artifact from pack-cypress-deps and extracts the node_modules and
Cypress binary into place.
Runs on a worker only when restore-cypress-deps found the cache key cold, and
must leave the same paths populated that a cache restore would have, so the
steps that follow cannot tell which transport ran.
inputs:
fips:
@@ -0,0 +1,91 @@
# Warms the Cypress deps cache (node_modules + the Cypress binary) from master.
# This workflow is the only writer of the key, so PR and merge runs never write
# to the Actions cache; they restore it read-only via
# .github/actions/restore-cypress-deps.
#
# Until this workflow has warmed a given key, runs on that tree fall back to the
# run-scoped artifact built by prep-deps in e2e-tests-cypress-template.yml.
name: "E2E Cache Warm: Cypress deps"
on:
schedule:
- cron: "0 3 * * *" # Daily 3am UTC — after the webapp node_modules and E2E npm registry caches warm at 1am
# Warm as soon as the key's only input lands on master, so the merge run and
# subsequent PRs hit the cache instead of paying for the artifact fallback all
# day. The schedule is the backstop.
push:
branches:
- master
paths:
- "e2e-tests/cypress/package-lock.json"
workflow_dispatch:
# Keep manual, scheduled and push-triggered runs from racing to save the same key.
concurrency:
group: e2e-ci-cache-warm-cypress-deps
cancel-in-progress: false
permissions:
contents: read
actions: write # required to save the Actions cache
jobs:
warm:
name: Warm Cypress deps cache
runs-on: ubuntu-24.04
# workflow_dispatch can target any branch. Warming from one would write a
# branch-scoped entry keyed on that branch's lockfile — quota spent on a key
# no other branch can read, which is the churn this workflow exists to avoid.
if: github.ref == 'refs/heads/master'
# Bound a hung run so it can't hold the concurrency group for the default
# 6-hour timeout and block subsequent warm runs.
timeout-minutes: 30
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: ci/probe-cypress-deps
id: deps
uses: ./.github/actions/restore-cypress-deps
with:
lookup-only: "true"
# Ahead of webapp-setup so its install benefits too, and so cache/restore
# never untars ~/.npm on top of entries npm has already written this job.
- name: ci/restore-npm-cache
if: steps.deps.outputs.cache-hit != 'true'
uses: ./.github/actions/restore-e2e-npm-cache
# Provides the @mattermost/eslint-plugin target the cypress install
# symlinks to.
- name: ci/setup-webapp-node-modules
if: steps.deps.outputs.cache-hit != 'true'
uses: ./.github/actions/webapp-setup
with:
read-only: "true"
# The postinstall downloads the Cypress binary to ~/.cache/Cypress rather
# than into node_modules, which is why the save below covers both.
- name: ci/install-cypress-deps
if: steps.deps.outputs.cache-hit != 'true'
working-directory: e2e-tests/cypress
run: npm ci
# Must stay in sync with restore-cypress-deps and pack-cypress-deps.
- name: ci/save-cypress-deps
if: steps.deps.outputs.cache-hit != 'true'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
e2e-tests/cypress/node_modules
~/.cache/Cypress
key: ${{ steps.deps.outputs.key }}
# This job is the sole writer of the key, so its summary is where "is
# warming actually working?" gets answered.
- name: ci/report-warm-outcome
env:
CACHE_HIT: ${{ steps.deps.outputs.cache-hit }}
CACHE_KEY: ${{ steps.deps.outputs.key }}
run: |
if [ "${CACHE_HIT}" = "true" ]; then
echo "Cypress deps: \`${CACHE_KEY}\` already warm; nothing new to save." >> "${GITHUB_STEP_SUMMARY}"
else
echo "Cypress deps: saved \`${CACHE_KEY}\`." >> "${GITHUB_STEP_SUMMARY}"
fi
@@ -198,14 +198,20 @@ jobs:
echo "workers=$(jq -nc --argjson n "${INPUT_WORKERS}" '[range(1; $n+1)]')" >> $GITHUB_OUTPUT
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
# Build the Cypress deps once and hand them to the workers as a run-scoped
# artifact.
# Decide how the workers get their deps, and bridge the gap when they can't use
# the cache. On a hit this job is a checkout and a metadata lookup, and the
# workers restore the warmed entry directly. On a miss — a PR that changes the
# cypress lockfile, or a master commit that landed before the warm workflow ran
# — it builds the deps once and ships them as a run-scoped artifact, until the
# next warm populates the key.
prep-deps:
name: prep-deps
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
outputs:
cache-hit: ${{ steps.probe.outputs.cache-hit }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -213,20 +219,47 @@ jobs:
persist-credentials: false
ref: ${{ inputs.commit_sha }}
fetch-depth: 1
# lookup-only: asking whether the key exists costs a metadata call rather
# than a ~275 MB download. The workers do the real restore.
- name: ci/probe-cypress-deps
id: probe
uses: ./.github/actions/restore-cypress-deps
with:
lookup-only: "true"
# Which transport a run took is otherwise only legible from which steps
# below were skipped. Reporting the key alongside the verdict is what makes
# a miss diagnosable: a lockfile or path drift reads as an unexpected key
# rather than an unexplained miss.
- name: ci/report-cypress-deps-transport
env:
CACHE_HIT: ${{ steps.probe.outputs.cache-hit }}
CACHE_KEY: ${{ steps.probe.outputs.key }}
run: |
if [ "${CACHE_HIT}" = "true" ]; then
echo "Cypress deps: cache **hit** on \`${CACHE_KEY}\` — workers restore it directly from the Actions cache." >> "${GITHUB_STEP_SUMMARY}"
else
echo "Cypress deps: cache **miss** on \`${CACHE_KEY}\` — installing once and shipping the run-scoped artifact." >> "${GITHUB_STEP_SUMMARY}"
fi
# Everything below is the cache-miss path.
#
# Provides the @mattermost/eslint-plugin target the cypress install
# symlinks to.
- name: ci/setup-webapp-node-modules
if: steps.probe.outputs.cache-hit != 'true'
uses: ./.github/actions/webapp-setup
# Read-only restore of the registry cache warmed daily from master, so the
# install below refetches as little as possible.
- name: ci/restore-npm-cache
if: steps.probe.outputs.cache-hit != 'true'
uses: ./.github/actions/restore-e2e-npm-cache
# The postinstall downloads the Cypress binary to ~/.cache/Cypress rather
# than into node_modules, which is why the artifact carries both.
- name: ci/install-cypress-deps
if: steps.probe.outputs.cache-hit != 'true'
working-directory: e2e-tests/cypress
run: npm ci
- name: ci/pack-cypress-deps
if: steps.probe.outputs.cache-hit != 'true'
uses: ./.github/actions/pack-cypress-deps
with:
fips: ${{ inputs.server_edition == 'fips' }}
@@ -271,7 +304,9 @@ jobs:
name: dispatch-run-${{ matrix.worker_index }}
runs-on: ubuntu-24.04
timeout-minutes: 30
needs: [prepare-run, dispatch-begin]
# prep-deps is already an ancestor via dispatch-begin; naming it directly
# makes its cache-hit output readable here.
needs: [prepare-run, prep-deps, dispatch-begin]
permissions:
contents: read
id-token: write
@@ -316,9 +351,15 @@ jobs:
uses: ./.github/actions/webapp-setup
with:
read-only: "true"
# prep-deps built these; it is an ancestor of this job via dispatch-begin,
# so the artifact is always present by now.
# Exactly one of the next two steps runs, per prep-deps' probe. The warmed
# cache is the normal path; the artifact exists only when the probe missed.
- name: ci/restore-cypress-deps
if: needs.prep-deps.outputs.cache-hit == 'true'
uses: ./.github/actions/restore-cypress-deps
with:
fail-on-cache-miss: "true"
- name: ci/reuse-cypress-deps
if: needs.prep-deps.outputs.cache-hit != 'true'
uses: ./.github/actions/unpack-cypress-deps
with:
fips: ${{ inputs.server_edition == 'fips' }}