Files
dc/.gitea/workflows/sites.yaml
T
dc 07615c2ab9
Build / Build-and-ng-test (pull_request) Successful in 3m35s
Build / lighthouse (pull_request) Successful in 6m43s
Build / Build-and-test-development (pull_request) Successful in 28m29s
feat(sites): deploy the clock-named control library on staging and demo
makedata.sas mints its control library from the clock -
%let dc_libref=%upcase(DC%substr(%sysevalf(%sysfunc(datetime())/60),3,6)) - so a
real deployment lands in a new DC<n> library about every minute. The mock
makedata hardcoded DC_JSLIB, so the two published sites behaved unlike the
product: every seed and every visitor's wizard run landed in the same library,
and two deployments could never be told apart.

The mock cannot simply follow the clock: its fixtures, parity scenarios and
Cypress specs all name DC_JSLIB, and a libref that changed with the minute would
make every one of them unreproducible. So the choice is per deployment:

- services/site-mode.json with {"clockLibref": true} switches makedata to the
  SAS rule, and the Sites workflow writes that marker onto the drive after the
  deploy (which wipes the appLoc) and before the seed, so
    - demo: the tag job seeds into a clock-named library and the snapshot
      carries it
    - staging: the marker ships in the snapshot, so the visitor's wizard run
      lands in a clock-named library too
- without the marker - CI, the parity harness, the unit tests - makedata keeps
  DC_JSLIB exactly as before

Test: mock-settings.test.js asserts both halves - a plain estate seeds
DC_JSLIB, and an estate with the marker seeds a DC\d{6} library with
mpe_tables.json under it. Whole mock suite: 163 pass.
2026-11-08 11:29:28 +00:00

268 lines
12 KiB
YAML

name: Sites
on:
push:
branches:
- 'release/**'
tags:
- 'v*'
paths-ignore:
- '**.md'
workflow_dispatch:
env:
# the server the mocked services and app tests run against - bump deliberately
# (build.yaml carries the full rationale; this workflow runs the same mocked
# estate, so it stays on the same pin).
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
# A release branch publishes the staging site (a named site on the instance,
# served at its alias domain); a release tag publishes the demo, which is the
# default site and so needs no site name.
STAGING_SITE: staging
STAGING_DOMAIN: staging.datacontroller.io
# One deploy per target site: a newer push supersedes the one in flight, and two
# release branches (which share the staging site) cannot interleave their swaps.
# The demo and staging deploys are independent, so they do not queue.
concurrency:
group: surfer-${{ startsWith(gitea.ref, 'refs/tags/') && 'demo' || 'staging' }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The tag is what the client's build stamp describes (src/version.ts
# runs git-describe), so the demo build needs the tags in the clone.
fetch-depth: 0
- name: Check runner size
# The demo build is the same cold production build; an undersized
# runner fails inside it - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
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 client 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 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend
# The site serves this build. `sasjs build` rewrites its index.html to
# point the adapter at the in-browser backend.
run: |
cd client
npm run postinstall
npm run build
- name: Install the SASjs CLI
# Pinned in sas/package.json - the browser build needs CLI >= 6.1.0
run: |
cd sas
npm ci
- name: Cache the npm downloads
# 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: Start SASjs server
# Needed only to compile and deploy the mocked services, and - for a tag -
# to seed the drive the demo ships with (see the two steps below).
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
echo RUN_AS=root >> .env
nohup npx --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }} > server.log 2>&1 &
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"
cat server.log
exit 1
- name: Deploy the mocked services
run: |
cd sas/mocks/sasjs
# Start from a clean appLoc so the site below carries this build alone,
# not a merge with whatever a previous run left on the drive.
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=$APP_LOC"
../../node_modules/.bin/sasjs cbd -t server-ci
- name: Mark this deployment as site mode
# makedata.sas mints its control library from the clock
# (DC + substr(datetime()/60,3,6)), so a real deployment lands in a new
# DC<n> library about every minute. The mock estate keeps the fixed
# DC_JSLIB so its fixtures, parity scenarios and specs stay
# reproducible; this marker is how a *site* asks for the product's
# behaviour instead. It is written after the deploy, because that step
# wipes the appLoc folder first, and before the seed below, so the demo
# is seeded into a clock-named library.
run: |
MARKER="sasjs_root/drive/files$APP_LOC/services/site-mode.json"
mkdir -p "$(dirname "$MARKER")"
printf '{\n "clockLibref": true\n}\n' > "$MARKER"
echo "wrote $MARKER:"
cat "$MARKER"
- name: Seed the demo drive
# A tag publishes the demo, the site a visitor can use straight away, so
# its drive is seeded here and captured for publishing. A release branch
# publishes staging, which is deliberately left unconfigured (see the
# step below) so it opens on the deploy screen.
if: ${{ startsWith(gitea.ref, 'refs/tags/') }}
run: |
cd sas/mocks/sasjs
# the payload asks for OUTFMT=JSON, so a failed seed fails this step
# rather than being published as an empty site (the drive is checked
# as well, before the snapshot is taken)
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# the sample tables the demo site opens with are a separate service
# now, so that makedata matches what a SAS deployment builds
../../node_modules/.bin/sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
echo "makedata seeded $SEEDED table(s) under $DATA_DIR"
if [ -z "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ] || [ "$SEEDED" -lt 10 ]; then
echo "makedata did not seed the drive - refusing to publish a data-less site"
find ../../../sasjs_root/drive/files -maxdepth 3 2>/dev/null | head -20
exit 1
fi
- name: Check the staging drive is unconfigured
# Staging exists to show the deployment, so a visitor has to land on the
# deploy screen rather than on a control library. DC routes there while
# makedata is still on the drive, and stops once the wizard has run it
# and it has removed itself - so the drive is shipped with makedata and
# without any seeded tables. Seeding staging by accident would only be
# visible as a site that opens on the home screen, so it is refused here.
if: ${{ !startsWith(gitea.ref, 'refs/tags/') }}
run: |
ADMIN_DIR="sasjs_root/drive/files$APP_LOC/services/admin"
DATA_DIR="sasjs_root/drive/files$APP_LOC/data"
if [ -z "$(find "$ADMIN_DIR" -name 'makedata.js' 2>/dev/null | head -1)" ]; then
echo "makedata is not on the drive - staging would not offer the deploy screen"
find "$ADMIN_DIR" -maxdepth 1 2>/dev/null | head -20
exit 1
fi
if [ -n "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ]; then
echo "the staging drive is seeded - staging would open on the home screen"
exit 1
fi
echo "staging ships an unconfigured drive: makedata present, no control library"
- name: Build the browser-hosted site
run: |
# The published sites ship a working licence so they are usable beyond
# the free tier. The pair is committed in sas/mocks/mock-licence.json
# rather than minted here, so every deploy of both sites carries the
# same key; the CI estate stays unlicensed because that file is outside
# the deployed sync folder, so the specs still exercise the free tier.
# The pair must round trip through the same WebCrypto the app uses,
# because a committed pair can drift (one half regenerated, a value
# truncated on copy) and nothing else would notice before publishing.
# Identical values in the two fields is the HTTP-format marker the app
# refuses in a secure context, so refuse to publish one.
node -e "
const d = require('./sas/mocks/mock-licence.json')
const b = (s) => Buffer.from(String(s || ''), 'base64')
if (!d.licenceKey || !d.activationKey || d.licenceKey === d.activationKey) {
console.error('sas/mocks/mock-licence.json must hold an HTTPS-format key pair')
process.exit(1)
}
crypto.subtle
.importKey('pkcs8', b(d.activationKey), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
.then((key) => crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, key, b(d.licenceKey)))
.then((plain) => {
const data = JSON.parse(new TextDecoder().decode(plain))
console.log('licence pair round trips: site', data.site_id, 'until', data.valid_until)
})
.catch((e) => {
console.error('sas/mocks/mock-licence.json does not decrypt:', e.message || e)
process.exit(1)
})
"
LICENCE_DIR="sasjs_root/drive/files$APP_LOC/mock-storage"
mkdir -p "$LICENCE_DIR"
cp sas/mocks/mock-licence.json "$LICENCE_DIR/licence.json"
# The drive is captured and shipped as the site's starting drive: for the
# demo that is a seeded control library; for staging it is the services
# and the licence only, with makedata still on it so the app opens on
# the deploy screen. services/web9 is the streamed frontend, which is
# already the site itself.
node sas/mocks/snapshot.js sasjs_root/drive "$APP_LOC" sas/mocks/sasjsbuild/mock-snapshot.json services/web9
cd sas/mocks/sasjs
../../node_modules/.bin/sasjs b -t server-ci
- name: Publish to Surfer
env:
SURFER_USER: allan
SURFER_PASSWORD: ${{ secrets.DEMO_DATACONTROLLER_IO }}
REF: ${{ gitea.ref }}
MESSAGE: Build from ${{ gitea.ref_name }} @ ${{ gitea.sha }}
run: |
# `deploy` uploads the folder as an archive and swaps the site in one
# step, so a failed upload leaves the current site serving.
if [ -z "$REF" ]; then
echo "REF is empty - cannot tell a branch push from a tag push"
exit 1
fi
SITE_ARGS=()
case "$REF" in
refs/tags/*) ;;
*) SITE_ARGS=(--site-name "$STAGING_SITE" --domain "$STAGING_DOMAIN") ;;
esac
npx --yes @cloudron/surfer@7.2.0 deploy \
--server "$SURFER_HOST" \
--username "$SURFER_USER" \
--password "$SURFER_PASSWORD" \
--message "$MESSAGE" \
"${SITE_ARGS[@]}" \
sas/mocks/sasjsbuild/mock
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"