Merge pull request 'ci(build): skip the cold build when the Cypress job will not run' (#470) from ci/faster-build-job into release/7.22
Sites / deploy (push) Successful in 4m39s
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m3s
Build / Build-and-test-development (pull_request) Canceled after 7m6s

Reviewed-on: #470
This commit was merged in pull request #470.
This commit is contained in:
allan committed 2026-11-04 20:28:42 +00:00
commit f0769b46aa
3 files changed
+172 -47

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Decide whether this pull request can change the Cypress job's outcome.
#
# The Cypress job runs the mocked estate (sas/mocks) against the client, so a
# pull request that touches none of what it exercises cannot change its
# result: a sas/ service change is not deployed there, a documentation change
# is not read, and neither is lighthouse.yaml. The build job decides once and
# publishes the answer as a job output, so its gate on the cold production
# build and the artifact upload cannot disagree with the job that downloads
# them - re-deciding in the second job could see a moved base branch and ask
# for an artifact the first job never built.
#
# Writes client=true|false to $GITHUB_OUTPUT when a decision can be made, and
# prints the files it saw. It exits 0 WITHOUT writing anything when it cannot
# decide (no base ref, a failed fetch, a failed diff): every caller gates on
# `!= 'false'`, so an undecidable pull request runs everything. A CI change
# whose worst case is "slow" is safe; one whose worst case is "silently tests
# nothing" is not, and one whose worst case is "every pull request goes red
# because a fetch failed" is not acceptable either.
#
# .gitea/scripts/ is in the set because the spec list (cypress-specs.sh) and
# the memory gate (check-runner-memory.sh) live there: a pull request that
# changes what the suite runs, or the gate that lets it run, has to run it.
# build.yaml is in the set for the same reason.
#
# sas/package.json is in the set because the job installs the CLI from the
# version it pins (`CLI_VERSION=$(node -p "...dependencies['@sasjs/cli']")`
# in "Deploy mocked services"): bumping that pin changes the toolchain the
# suite runs, even though no file under sas/mocks/ changes. The sas lockfile
# is not in the set - the job installs that CLI globally by version string,
# so the lockfile cannot change what it resolves.
#
# Usage: BASE_REF=<base branch> bash .gitea/scripts/cypress-changes.sh
set -uo pipefail
# The paths the Cypress job reads, and nothing else. The tests in
# tests/cypress-changes.test.sh exist because this list is the whole gate: a
# dropped alternative would silently stop the suite running on a class of
# change, and the only symptom would be a green job that finished sooner.
PATHS='client/|sas/mocks/|sas/package\.json|\.gitea/scripts/|package-lock\.json|\.gitea/workflows/build\.yaml'
# --match < file-list reads newline-separated paths on stdin and prints
# true/false, so the matcher can be exercised with fixed lists and no
# repository. It is what the test above drives.
if [ "${1:-}" = "--match" ]; then
if grep -qE "^(${PATHS})"; then echo true; else echo false; fi
exit 0
fi
BASE="${BASE_REF:-}"
if [ -z "$BASE" ]; then
echo "no base ref (not a pull request) - running the suite"
exit 0
fi
if ! git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE}:refs/remotes/origin/${BASE}"; then
echo "::warning::could not fetch ${BASE} - running the suite rather than deciding"
exit 0
fi
if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
echo "::warning::could not diff against ${BASE} - running the suite rather than deciding"
exit 0
fi
if grep -qE "^(${PATHS})" <<<"$CHANGED"; then
echo "client=true" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "the Cypress job's inputs changed:"
else
echo "client=false" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "::notice::Cypress suite skipped - nothing under client/, sas/mocks/ or .gitea/scripts/ changed"
echo "Skipping the Cypress suite - nothing it exercises changed:"
fi
printf '%s\n' "$CHANGED"
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# The path list in cypress-changes.sh is the whole gate: drop an alternative
# and the Cypress suite silently stops running for a class of change, with a
# green job that just finished sooner. These cases pin the list to what the
# suite actually reads, and run in the build job on every pull request.
#
# Run by hand: bash .gitea/scripts/tests/cypress-changes.test.sh
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
matcher="$here/../cypress-changes.sh"
fails=0
check() { # <expected> <path>...
local want="$1" got
shift
got=$(printf '%s\n' "$@" | bash "$matcher" --match)
if [ "$got" != "$want" ]; then
echo "FAIL: expected $want for: $*"
echo " got: $got"
fails=$((fails + 1))
fi
}
# Everything the Cypress job reads: the client it drives, the mock estate it
# deploys, the CLI pin it installs, the scripts that decide what it runs, and
# the dependency/workflow files that change the first two.
check true client/src/app/app.component.ts
check true sas/mocks/sasjs/services/makedata.js
check true sas/package.json
check true .gitea/scripts/cypress-specs.sh
check true package-lock.json
check true .gitea/workflows/build.yaml
# Not inputs: a sas/ service the mock estate does not deploy, documentation,
# the Lighthouse workflow, an unrelated directory whose name starts with
# "client", and the sas lockfile (the job installs the CLI by version string,
# so the lockfile cannot change what it resolves).
check false sas/macros/mp_abort.sas
check false AGENTS.md
check false docs/readme.md
check false .gitea/workflows/lighthouse.yaml
check false clientx/foo.ts
check false sas/package-lock.json
if [ "$fails" -ne 0 ]; then
echo "cypress-changes matcher: $fails case(s) failed"
exit 1
fi
echo "cypress-changes matcher: all cases passed"
+47 -47
View File
@@ -44,6 +44,13 @@ env:
jobs:
Build-and-ng-test:
runs-on: ubuntu-latest
outputs:
# The Cypress job consumes this instead of deciding again: the base
# branch can move between the two jobs, and a second answer could say
# "run" after this job had already skipped the build and the upload,
# which would fail there as a missing artifact. Empty (the decision
# could not be made) means the Cypress job runs everything.
client: ${{ steps.changes.outputs.client }}
steps:
- uses: actions/checkout@v4
@@ -52,6 +59,21 @@ jobs:
# Karma disconnect twenty minutes in. Fail here instead, in seconds,
# with the measured numbers in the log - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- name: Detect the Cypress job's inputs
id: changes
# Decides whether the Cypress job will run at all, so the production
# build and its artifact upload can be skipped when nothing the suite
# exercises has changed. Same script the Cypress job itself calls, so
# the two jobs cannot disagree about which paths matter.
env:
BASE_REF: ${{ github.base_ref }}
run: bash .gitea/scripts/cypress-changes.sh
- name: Test the changed-path matcher
# The path list is the whole gate: a dropped alternative silently stops
# the suite running for a class of change, and the only symptom is a
# green job that finished sooner. The cases pin the list to what the
# suite reads.
run: bash .gitea/scripts/tests/cypress-changes.test.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
@@ -101,9 +123,9 @@ jobs:
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# policy skips, so it is installed explicitly here
npx cypress install
# cypress's binary is installed in the Cypress job, where it is
# actually used; nothing in this job starts it, so downloading and
# unzipping it here only cost time (11s, measured).
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
@@ -170,11 +192,19 @@ jobs:
run: bash .gitea/scripts/cypress-specs.sh coverage .
- name: Production Build
# The build exists to give the Cypress job the client/dist it streams
# as the mock estate's frontend. When nothing that job exercises has
# changed, that job skips and nobody downloads the artifact, so the
# cold build (109s, measured on run 3132) would be spent on no one.
if: steps.changes.outputs.client != 'false'
run: |
cd client
npm run build
- name: Save the production build for the Cypress job
# Gated with the build it packages: when the Cypress job skips, this
# artifact has no consumer.
if: steps.changes.outputs.client != 'false'
# The Cypress job needs client/dist: the server-ci target streams it as
# the 'clickme' web app (see CONTRIBUTING.md). Handing it the build this
# job has already made saves a second cold `npm run build` (1.6 minutes,
@@ -212,51 +242,21 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Detect client changes
- name: Take the changed-path decision from the build job
id: changes
# This job runs the mocked estate (sas/mocks) against the client, so a
# pull request that touches none of what it exercises cannot change its
# outcome: a sas/ service change is not deployed here, a documentation
# change is not read, and neither is .gitea/workflows/lighthouse.yaml.
# Skipping saves the twenty-six minute suite plus its installs.
#
# The condition on every gated step is `!= 'false'`, not `== 'true'`, and
# every failure below exits 0 without writing `client=false`: if the
# decision cannot be made, the suite runs. A CI change whose worst case
# is "slow" is safe; one whose worst case is "silently tests nothing" is
# not, and one whose worst case is "every pull request goes red because a
# fetch failed" is not acceptable either.
# The build job made this decision before it built (or skipped) the
# client-dist this job downloads, and published it as a job output.
# Deciding again here could disagree if the base branch moved in
# between, and the disagreement would surface as a missing artifact.
# An empty value means that job could not decide, so the suite runs.
env:
BASE_REF: ${{ github.base_ref }}
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
set -euo pipefail
BASE="${BASE_REF:-}"
if [ -z "$BASE" ]; then
echo "no base ref (not a pull request) - running the suite"
exit 0
fi
if ! git fetch --no-tags --depth=1 origin "+refs/heads/${BASE}:refs/remotes/origin/${BASE}"; then
echo "::warning::could not fetch ${BASE} - running the suite rather than deciding"
exit 0
fi
if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
echo "::warning::could not diff against ${BASE} - running the suite rather than deciding"
exit 0
fi
# .gitea/scripts/ is in the set because the suite's spec list
# (cypress-specs.sh) and the memory gate (check-runner-memory.sh) live
# there: a pull request that changes what the suite runs, or the gate
# that lets it run, has to run it.
if printf '%s\n' "$CHANGED" | grep -qE '^(client/|sas/mocks/|\.gitea/scripts/|package-lock\.json|\.gitea/workflows/build\.yaml)'; then
echo "client=true" >> "$GITHUB_OUTPUT"
else
echo "client=false" >> "$GITHUB_OUTPUT"
echo "::notice::Cypress suite skipped - nothing under client/, sas/mocks/ or .gitea/scripts/ changed"
printf 'Skipping the Cypress suite - nothing it exercises changed:\n%s\n' "$CHANGED"
fi
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the suite" ;;
esac
- name: Cypress suite not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
@@ -267,8 +267,8 @@ jobs:
# evidence for it.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks or .gitea/scripts change in this pull request - the Cypress suite was skipped by design."
echo "The detection step above lists the files it saw, and warns instead if it could not decide (in which case the suite runs)."
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the Cypress suite was skipped by design."
echo "The build job's 'Detect the Cypress job's inputs' step lists the files it saw, and warns instead if it could not decide (in which case the suite runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# Each Cypress job compiles the frontend and runs a browser on top; a