diff --git a/.github/actions/pack-cypress-deps/action.yml b/.github/actions/pack-cypress-deps/action.yml index cdd970f0d05..3b83e50b821 100644 --- a/.github/actions/pack-cypress-deps/action.yml +++ b/.github/actions/pack-cypress-deps/action.yml @@ -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: diff --git a/.github/actions/restore-cypress-deps/action.yml b/.github/actions/restore-cypress-deps/action.yml new file mode 100644 index 00000000000..4649a9b2840 --- /dev/null +++ b/.github/actions/restore-cypress-deps/action.yml @@ -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 }} diff --git a/.github/actions/unpack-cypress-deps/action.yml b/.github/actions/unpack-cypress-deps/action.yml index b52958a9f2f..e48a417aada 100644 --- a/.github/actions/unpack-cypress-deps/action.yml +++ b/.github/actions/unpack-cypress-deps/action.yml @@ -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: diff --git a/.github/workflows/e2e-ci-cache-warm-cypress-deps.yml b/.github/workflows/e2e-ci-cache-warm-cypress-deps.yml new file mode 100644 index 00000000000..e75dcbe2f85 --- /dev/null +++ b/.github/workflows/e2e-ci-cache-warm-cypress-deps.yml @@ -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 diff --git a/.github/workflows/e2e-tests-cypress-template.yml b/.github/workflows/e2e-tests-cypress-template.yml index 67f3d6f0a85..6d9bf3febc6 100644 --- a/.github/workflows/e2e-tests-cypress-template.yml +++ b/.github/workflows/e2e-tests-cypress-template.yml @@ -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' }}