Files
dc/.gitea/workflows/build.yaml
T
dc db99b5868f test(cypress): clear the session instead of visiting the logout route
The specs opened with `cy.visit(`${hostUrl}/SASLogon/logout`)`. Against a dev
server that only navigated the single-page app: every path answered index.html,
so nothing was logged out. With the app served by the server it talks to, the
route answers 403 - `desktopRestrict` in @sasjs/server refuses it by design, and
desktop is that server's default mode - so the visit failed the before hook and
every spec aborted on its first line.

`cy.clearSession()` clears cookies and localStorage, which is what the visit was
standing in for, and needs nothing from the server. The MODE=desktop line and its
comment go with it: the server already defaults to desktop, so the line changed
nothing and the comment described a mode the job never ran in.

Verified locally against a 4.14.0 estate with the build streamed to
/AppStream/clickme and the mock services seeded: viewer-labels 5/5 (the spec that
failed in CI), filtering 10/10.

Also in this push, three review findings on the harness itself:

- resolve-browser.sh searches ${DC_PATH:-$PATH} through its own loop, so the
  test pins the whole decision to fixtures instead of inheriting a chromium from
  the host; case 2 was passing here and failing on any machine with one.
- the Cypress step requires the exported path (`${CYPRESS_BROWSER:?...}`) rather
  than falling back to a browser name, so a missing export fails with a message
  naming the step that should have set it.
- the step asserts the app is served at /AppStream/clickme before the suite
  starts, because a deploy that streamed no frontend would otherwise surface as
  twenty-six navigation failures.
2026-11-08 00:32:33 +00:00

829 lines
40 KiB
YAML

name: Build
run-name: Lint, unit tests, Cypress suite and Lighthouse on Pull Request
on: [pull_request]
# One run per pull request: a newer push supersedes the one in flight for the
# older head. Both runs test the same thing over the same twenty-six minute
# suite, the older head can no longer be what gets merged, and this workflow
# shares three runner boxes with every other pull request in the repo - so
# leaving the old run queued only delays the head that matters. The group is the
# base build only: the release workflow is never cancelled by something it does
# not supersede.
concurrency:
group: build-pr-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
# the server the mocked services and app tests run against - bump deliberately.
#
# 4.14.0 is the pin because it is the first release that compresses its
# responses and sends cache headers for the app it streams (sasjs/server#575).
# That is not cosmetic here: the audit runs against this server, and the app it
# serves is a single 4.83MB build output. Measured on the previous pin, same
# build and same machine: FCP 8.48s and 0.34 performance with the assets served
# raw, 2.81s and 0.49 with the same bytes gzipped and hashed files immutable.
#
# 4.0.0's breaking change is the auth statuses - `POST /SASjsApi/auth/token` and
# `/auth/refresh` answer 401 where they answered 403, the undocumented DELETE on
# the logout route is gone, and `GET /SASLogon/logout` answers 500 where it
# answered 403 when the session store fails. The estate does touch all three,
# through @sasjs/adapter: auth/AuthManager.js logs out with a GET on the logout
# route and auth/getAccessTokenForSasjs.js posts the token. What it keys on is
# the call succeeding rather than the code being 403 in particular, so 403 to
# 401 changes nothing here - and the suite on this pin checks the logout path
# still completes.
#
# The floors this pin also carries, kept because a future bump has to keep
# clearing them:
#
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which
# client/cypress/e2e/va-ddc-host.cy.ts needs. 3.12.2 also carries the
# packaged-executable fix for the same page, so the version the tests run
# against is the one that ships.
#
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands). It declared
# @sasjs/utils ^4.7.0, which is why the envelope *formats* (SYSCC as text,
# DATE7, TIME5, E8601DT26.6) used to depend on what a fresh install resolved:
# 4.8 introduced them, a 4.7 tree did not have them. 4.14.0 declares ^4.8.0,
# so the pin guarantees them now rather than being a floor that hoped for them.
#
# 3.13.0 is the one that matters for licensing: @sasjs/utils 4.7 reports
# SYSSITE, which %webout takes from &syssite. Before it the prelude wrote an
# empty string, so the client - which routes expired and wrong-organisation
# licence keys on that value - never reached those pages on the mock estate.
#
# 3.12.3 is on top of that for a different reason: it is the first release whose
# package.json declares @sasjs/utils ^4.6.0, which is what gives webout() its
# options argument. getdata, viewdata and getcolvals pass { showmeta }, and a
# 4.5 install drops it without a word - so the pin is what makes the metadata
# members guaranteed here rather than whatever a fresh install happens to
# resolve. sas/mocks/test/harness-webout.test.js is what holds that: showmeta
# is written beside the rows, before CLOSE.
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
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
- name: Check runner size
# An undersized runner dies inside `ng build` (exitcode 137) or as a
# 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
- name: Test the browser matcher
# Same reason, for the browser: the wrong answer reads as a broken
# image rather than a broken decision.
run: bash .gitea/scripts/tests/resolve-browser.test.sh
- name: Resolve the browser
# Runners differ: this decides which browser this one has (or fetches
# one), and writes CHROME_BIN for the steps below.
run: bash .gitea/scripts/resolve-browser.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Scan for secrets (gitleaks)
# The local pre-commit hook is suppressed entirely when .npmrc sets
# ignore-scripts=true (the prepare script that would activate the
# hooks never runs), so CI is the enforcement point: a leaked
# credential must fail the build, not just a commit.
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
# gitleaks-action requires a license key for organization repos.
# Runs before the registry config is written, so the scan cannot see
# the token this job injects.
# --no-git scans the working tree, which is what a pull request gate
# is about.
#
# The cache steps below deliberately run AFTER this one: the Angular
# cache holds webpack/babel JSON full of `{ token: ... }` fragments,
# and a restored copy in the tree is scanned as a pile of generic API
# keys (it failed the gate once, on run 3174).
run: |
npm ci
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
- name: Restore the npm download cache
# The runner's embedded cache server is reachable from job containers
# (they share a network with the runner), so these entries can actually
# come back. The key is the client lockfile: a dependency change
# invalidates it, and the prefix restore-keys keep the previous entries
# usable while the new ones are fetched.
#
# act_runner scopes cache entries to the REPOSITORY, not the ref (the
# handler stamps each entry with its repo and checks it on read), so
# the base branch is part of the key: a pull request into release/7.22
# cannot seed the entry a pull request into main restores. Entries
# still live on the runner that saved them - each act_runner runs its
# own cache server - so a box warms its cache from its own first run.
#
# An unreachable cache server degrades both the restore and the save to
# a warning - the action never fails the job - so a box on an older
# runner config costs a timeout, not a red build.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Write .npmrc file
run: |
# the registry secret stays outside the repo: client/.npmrc is
# tracked now, and one git add -A would commit a live token
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# 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: Restore the Angular build cache
# `ng build` and both `ng test` runs use this directory, so a restored
# entry speeds up the production build and the Karma suites alike.
#
# This step MUST stay below "Install dependencies". postinstall runs
# scripts/strip-clr-base64-fonts.mjs, which edits Clarity's CSS inside
# node_modules and then deletes client/.angular/cache, because the
# builder treats node_modules as immutable and would otherwise serve
# styles built from the unstripped file (measured: a marker @font-face
# injected into clr-ui.min.css is absent from a warm build's CSS and
# present in a cold one). Every run strips before it builds, so every
# entry in this cache was produced from the stripped file - restoring
# after the clear is what keeps that true.
#
# Exact key only, no restore-keys: the builder cannot see a change
# inside node_modules, so an entry compiled against a different
# Clarity must not be restored. A lockfile bump misses and rebuilds,
# which is the correct outcome.
#
# Angular keeps the cache in memory when CI is set, so client/
# angular.json sets cli.cache.environment to "all" - without it this
# step has nothing to save ("Path Validation Error: Path(s) specified
# in the action for caching do(es) not exist").
#
# Saving is skipped for a key that already exists, so this refreshes
# when client/package-lock.json changes; bump the v1- prefix to force
# a reset by hand. The base ref keeps a pull request into main from
# seeding what a pull request into release/7.22 restores.
uses: actions/cache@v4
with:
path: client/.angular/cache
key: angular-v1-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
- name: Check audit
# Root and sas fail on moderate and above (npm's default), client on
# low and above - the flags below are what makes that true, so they have
# to stay in step with this comment.
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
cd ../client
npm audit --omit=dev --audit-level=low
- name: Check archiver directory walk
# the readdir-glob minimatch override in client/package.json is a major
# bump; archiver's directory() is the only runtime caller of that path,
# so exercise it rather than trusting that the install succeeded
run: |
cd client
npm run check:archiver
- name: Lint check
run: npm run lint:check
- name: SAS lint check
# sas/.sasjslint promotes each rule to "error" as its findings are
# cleared, and only errors change the exit code - so this gate fails on
# a regression in a cleared rule and passes while other rules still
# carry warnings.
run: |
cd sas
npm ci --ignore-scripts
npm run lint:sas
- name: Licence checker
run: |
cd client
npm run license-checker
- name: Mock estate service tests
# The deploy screen's questions (ACTION=DIRLIST / WRITECHECK) are
# answered by the mock makedata service on the browser-hosted sites.
# These run that service against a temporary estate, so a browse that
# seeds the site and deletes the program fails here instead of on the
# staging site, where it only shows as a wizard that cannot be used.
run: node --test sas/mocks/test/*.test.js
- name: Angular Tests
run: |
cd client
npm run test:headless
- name: Angular Tests in other timezones
# The VA filter clauses are built from local calendar dates, so a
# UTC-only run cannot see a date that slips a day west of UTC or east of
# it. Only the VA specs carry that property, so both scripts run that
# subset rather than the whole suite a second and third time.
run: |
cd client
npm run test:tz-west
npm run test:tz-east
- name: Every Cypress spec is in the CI list
# The spec list used to be spelled out inline in --spec, and five files
# quietly fell out of it - among them the VA embed suite. Fails when a
# spec on disk is not listed.
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,
# measured over thirteen runs) and guarantees both jobs test the same
# bytes.
#
# if-no-files-found: error, because an empty artifact is worse than a
# failed upload: the Cypress job would download nothing, deploy a service
# pack with no frontend, and the symptom would reappear there as the
# "webSourcePath ... doesn't exist" line this artefact exists to remove.
#
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all - the download in
# Build-and-test-development is @v3 for the same reason.
uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
if-no-files-found: error
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-and-ng-test
env:
# Pin OS locale
LANG: en_GB.UTF-8
LC_ALL: en_GB.UTF-8
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
# 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:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
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
# says why it took a minute: this job reports success having tested
# nothing, which is the intended behaviour for a pull request that
# changes no client code. If this job is a required check, "green" for
# such a pull request means "not applicable", and this step is the
# evidence for it.
if: steps.changes.outputs.client == 'false'
run: |
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
# runner too small for that fails as a flaky-looking disconnect - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install system dependencies
if: steps.changes.outputs.client != 'false'
run: |
apt-get update
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
locale-gen en_GB.UTF-8
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
- name: Resolve the browser
if: steps.changes.outputs.client != 'false'
# Which browser this runner has decides the path, the name Cypress is
# given and whether one is fetched; this also writes CHROME_PATH, which
# lhci's chrome-launcher reads.
run: bash .gitea/scripts/resolve-browser.sh
- name: Write cypress credentials
if: steps.changes.outputs.client != 'false'
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
shell: bash
env:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Restore the npm download cache
if: steps.changes.outputs.client != 'false'
# The build job saves this key with the same lockfile, so npm ci here
# should not download a tarball that job already fetched.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Restore the Cypress binary
if: steps.changes.outputs.client != 'false'
# npx cypress install fetches a ~50MB binary per run without this; the
# key follows the lockfile, and a stale hit is re-checked by the installer.
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-binary-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
cypress-binary-
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# 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
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
# The server-ci target streams client/dist as the 'clickme' web app (see
# CONTRIBUTING.md), so the production build has to exist before the mock
# services are deployed. Without it the deploy logs
# "webSourcePath: .../client/dist ... doesn't exist" and streams no
# frontend. It is the artefact Build-and-ng-test published, so this job
# does not compile the frontend a second time.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
npm i -g pm2@7.1.2
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
# No CORS: the app under test is served by this same server at
# /AppStream/clickme, so every request the specs make is same-origin.
echo HTTP_PORT=5000 >> .env
# /ddc-host is a testing tool, so the server answers 404 for it unless
# this is set. va-ddc-host.cy.ts drives the app through that page with
# timing=early.
echo DDC_HOST_ENABLED=true >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
- name: Deploy mocked services
if: steps.changes.outputs.client != 'false'
run: |
cd ./sas/mocks/sasjs
# The CLI comes from the version sas/package.json pins, as docs.yaml and
# release-assets.yaml already do: an unpinned global install runs
# whatever release is current, unreviewed.
CLI_VERSION=$(node -p "require('../../package.json').dependencies['@sasjs/cli']")
npm install -g "@sasjs/cli@${CLI_VERSION}"
npm install -g replace-in-files-cli@4.0.0
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). The payload
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
# seed fails the job - createdemodata runs next and would only report
# a missing control library.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# Then the Cypress fixtures: TESTDATA, the search/wide viewers and the
# multi-load library. makedata builds only what a real deployment
# builds, so the specs need this second service.
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
- name: Prepare and run frontend and cypress
if: steps.changes.outputs.client != 'false'
# The suite drives the production build the job already streamed to the
# server at /AppStream/clickme. That is the artefact Lighthouse audits
# and the one that ships, so a spec that passes here has passed against
# the same bytes a user gets; nothing in this job rebuilds the frontend.
#
# Page and mocked services share one origin, so index.html keeps the
# repository's `serverUrl=""` - relative requests - and no rewrite of it
# is needed to point the app at a server on another port.
timeout-minutes: 50
env:
CYPRESS_USERNAME: ${{ secrets.CYPRESS_USERNAME_SASJS }}
CYPRESS_PWD: ${{ secrets.CYPRESS_PWD_SASJS }}
run: |
cd ./client
# The spec list lives in one place, and an empty list is a broken
# workflow rather than a green run with no tests.
SPECS="$(bash ../.gitea/scripts/cypress-specs.sh all)"
if [ -z "$SPECS" ]; then
echo "the spec list resolved to nothing"
exit 1
fi
echo "specs: $SPECS"
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'"$CYPRESS_USERNAME"'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'"$CYPRESS_PWD"'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# The app under test is fetched from the server, so prove it is there
# before the suite starts: a deploy that streamed no frontend leaves
# the route answering 404, and twenty-six specs would then each fail on
# a navigation error rather than on the thing they test.
if ! curl -sf -o /dev/null http://localhost:5000/AppStream/clickme/; then
echo "the streamed app is not served at /AppStream/clickme/" >&2
exit 1
fi
# hosturl and appLocation are passed as --env because --env overrides
# config.env, whereas rewriting cypress.config.ts with a regex fails
# silently whenever the pattern does not match the file as written.
#
# timeout 2700: SIGTERM after 45 min so Cypress can flush screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
#
# video=false, screenshotOnRunFailure=true: recording twenty-four
# specs costs encode time on every run, and the recording is only ever
# opened after a failure. A screenshot of the failing step carries the
# same information for a fraction of the cost - and cypress.config.ts
# sets screenshotOnRunFailure false by default, so both are passed
# here, as CLI overrides, leaving local runs (and the demo recorder,
# which sets RECORD_WINDOW_SIZE) on video.
#
# --browser takes the resolved path: the Resolve the browser step
# exports it, and a name would depend on Cypress' own detection
# agreeing with that step about which binary to run.
timeout 2700 npx cypress run --browser "${CYPRESS_BROWSER:?the Resolve the browser step exports the browser path}" --env hosturl=http://localhost:5000,appLocation=/AppStream/clickme --config video=false,screenshotOnRunFailure=true --spec "$SPECS"
- name: Zip Cypress failure evidence
# Only on a failure: a green run has nothing to look at.
if: failure() && steps.changes.outputs.client != 'false'
run: |
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
- name: Add cypress failure evidence as an artifact
if: failure() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
uses: actions/upload-artifact@v3
with:
name: cypress-artifacts.zip
path: cypress-artifacts.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
# The Lighthouse audit lives in this workflow so it can reuse the production
# build the first job already made: an artifact belongs to the run that
# produced it and `needs:` cannot cross workflows, so a separate workflow would
# compile the frontend a second time. Every URL in client/lighthouserc.js is on
# :5000, which the SASjs server feeds from client/dist.
#
# It takes the changed-path decision from the build job instead of deciding
# again, for the reason given there: the same inputs gate both, and two
# answers could disagree about whether the artifact exists.
lighthouse:
runs-on: ubuntu-latest
needs: Build-and-ng-test
# The job comments the scores back onto the pull request, so it needs to
# write there; nothing else is elevated.
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
env:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the audit" ;;
esac
- name: Lighthouse audit not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
# says why it took a second: this job reports success having audited
# nothing, which is the intended behaviour for a pull request that
# changes none of its inputs.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the audit 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 audit runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# This job runs a SASjs server, Chrome and the full mock estate; an
# undersized runner dies inside one of them with exitcode 137. Fail here
# in seconds instead - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the registry secret arrives through the environment, not interpolated
# into this script
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
# Only the client's node_modules is needed here - lhci comes from it and
# nothing is compiled in this job, so the source generator (postinstall)
# and its node_modules edits are not run.
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc), and nothing here needs
# what they would generate
npm ci --ignore-scripts
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
# Every audited URL is on the copy the SASjs server streams from
# client/dist, so this audits exactly the bytes the build job produced
# instead of compiling a second, possibly different, copy of them.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Install global packages
if: steps.changes.outputs.client != 'false'
run: |
# pinned, so what runs here is a release this repo chose: the CLI at
# the version sas/package.json pins, pm2 at the version named here
CLI_VERSION=$(node -p "require('./sas/package.json').dependencies['@sasjs/cli']")
npm i -g pm2@7.1.2
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
# No CORS: the audited app is served by this same server (:5000), so
# every call it makes is same-origin. Neither job has a dev server on
# another port any more, and the server refuses to start with
# CORS=enable and no WHITELIST.
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account (a
# security change in the recent releases). This job runs as root in a
# throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
- name: Deploy JS mocked services and frontend to the SASjs server
if: steps.changes.outputs.client != 'false'
# `cd sas/mocks/sasjs` is explicit: sas/mocks holds no sasjsconfig.json,
# and running from there made the CLI search for the project while its
# `npm ci` installed whatever package.json the prefix walk found. The
# server-ci target deploys to /Public/app/dc, which is the appLoc the
# production build already carries, so the audited app calls the
# services this step creates with no attribute patching in between.
run: |
test -f client/dist/index.html || { echo "the artifact carries no client/dist/index.html"; ls -la client/dist; exit 1; }
cd sas/mocks/sasjs
# This server is fresh in this container, so there is no previous
# deployment to delete - cbd is the first thing to write the appLoc.
sasjs cbd -t server-ci
# Seed the DC database and the Cypress fixtures, exactly as the Cypress
# job does: most audited routes are data views (tables, view/data, the
# review lists, excel-maps, multi-load), and an unseeded estate renders
# empty or error states instead of the app users get. The payload asks
# for OUTFMT=JSON so a failed seed fails this job; createdemodata runs
# after it because it needs the control library makedata creates.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks), not
# the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
- name: Resolve the browser
if: steps.changes.outputs.client != 'false'
# Writes CHROME_PATH for lhci, from whichever browser this box has.
run: bash .gitea/scripts/resolve-browser.sh
- name: Run Lighthouse CI
if: steps.changes.outputs.client != 'false'
# One run per URL: the assertions aggregate by median, so three runs
# bought a steadier median at three times the navigations (13.6 minutes
# measured). A pull request gate only needs to know the page still
# clears the thresholds it cleared before.
#
# CHROME_PATH comes from the Resolve the browser step: lhci launches the
# browser itself through chrome-launcher rather than being handed one.
# --no-sandbox comes from client/lighthouserc.js, which is what makes it
# work as root.
run: |
cd client
npx lhci autorun
- name: Publish the scores to the pull request
# One comment, rewritten in place on every push: the marker in the body is
# how the script finds the previous run's comment again, so a re-run
# replaces the old table rather than adding another one. It runs on
# failure too - a red audit is exactly when the scores are worth reading -
# and a comment that cannot be posted never fails the job, because the
# audit's own step carries the verdict.
if: always() && steps.changes.outputs.client != 'false' && github.event_name == 'pull_request'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .gitea/scripts/lighthouse-report.mjs
- name: Lighthouse Result Artifacts
if: always() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError" for the
# artifact action's current major. No `include-hidden-files` input -
# that arrived with v4.4 and v3 only warns about it as an unexpected
# input, which reads as a misconfiguration in every run's log.
uses: actions/upload-artifact@v3
with:
name: Lighthouse results
path: client/lighthouse-reports
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"