Skip to content

Merge pull request #380 from handsontable/docs/o11y-waf-verify-curl-h… #148

Merge pull request #380 from handsontable/docs/o11y-waf-verify-curl-h…

Merge pull request #380 from handsontable/docs/o11y-waf-verify-curl-h… #148

Workflow file for this run

name: Master
# The single master-push pipeline: deploy first, verify production after.
#
# changes ─► build ─┬─► deploy-authoring ─┐
# └─► deploy-api ───────┴─► smoke (@smoke e2e against prod)
#
# One build, artifacts reused (Dan, #189 review): the workspace packages and
# the authoring app are built once, and the deploy jobs only download what
# they ship — before this, a push touching runner/packages/** installed and
# built the workspace twice, once per deploy job.
#
# No test gate before the deploys, deliberately (Dan, #184 review): the full
# suite already ran on the PR, the shared `build` job below still has to
# succeed before either deploy runs (a broken build cannot reach wrangler),
# and what a master push needs verified is PRODUCTION — which only exists
# after the deploy. The smoke job is that verification; the nightly e2e-live
# canary covers the rest (03:00 UTC). The compensating
# control for merge skew (a PR tested before master moved) is branch
# protection's "require branches to be up to date" — a repo setting, not a
# workflow.
#
# Deploys are path-gated by a plain `git diff` (the push-level `paths:` filter
# can't be used once the deploys share a workflow). Manual deploys:
# workflow_dispatch with the two checkboxes.
on:
push:
branches: [master]
workflow_dispatch:
inputs:
deploy_authoring:
description: 'Deploy the authoring frontend'
type: boolean
default: false
deploy_api:
description: 'Deploy the API worker + Tier-2 container image'
type: boolean
default: false
deploy_o11y:
description: 'Deploy the observability worker + Grafana box image (T10)'
type: boolean
default: false
permissions:
contents: read
# Never cancel: a run may be mid container-build/push (the old
# deploy-runner-api rule, now covering both deploys). Queued runs wait.
concurrency:
group: master-${{ github.ref }}
cancel-in-progress: false
jobs:
changes:
runs-on: ubuntu-latest
outputs:
authoring: ${{ steps.detect.outputs.authoring }}
api: ${{ steps.detect.outputs.api }}
o11y: ${{ steps.detect.outputs.o11y }}
steps:
- uses: actions/checkout@v5
with:
# Full history: the diff below spans the whole push range, and a
# multi-commit push (rebase-and-merge, a direct push of several
# commits) reaches past any fixed depth.
fetch-depth: 0
- id: detect
name: Detect what this push touches
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
{
echo "authoring=${{ inputs.deploy_authoring }}"
echo "api=${{ inputs.deploy_api }}"
echo "o11y=${{ inputs.deploy_o11y }}"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# The push event's `before` bounds the range: HEAD^ only covers the
# tip commit, so a multi-commit push would skip deploys for files
# changed in the earlier commits (Bugbot, #184). `before` is the
# zero sha on a branch creation, and unreachable after a force push
# that discarded it — fall back to the tip diff in both cases.
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$before" 2>/dev/null; then
before="$(git rev-parse HEAD^)"
fi
git diff --name-only "$before" HEAD > /tmp/changed.txt
echo "--- changed files ---"; cat /tmp/changed.txt
# The same path sets the two deploy workflows used to declare under
# `on.push.paths` (catalog.json is authoring-only: the app bundles it
# at build time; scripts/ and containers/ are api-only).
authoring=false; api=false; o11y=false
grep -qE '^runner/(apps/authoring/|packages/|config/|catalog\.json$|pnpm-lock\.yaml$)|^\.github/workflows/(master|ci)\.yml$' /tmp/changed.txt && authoring=true
# Minor triage item 4 (C-M5): `containers/` used to match ALL of
# it, including a Grafana-box image edit under containers/o11y/ —
# any such edit also redeployed the API worker and rebuilt/pushed
# the unrelated Tier-2 image. Narrowed to the two containers/
# subtrees the API worker's own deploy actually builds/pushes
# (containers/live/, containers/builder/); containers/o11y/ stays
# gated on the o11y line below only.
grep -qE '^runner/(workers/api/|containers/(live|builder)/|scripts/|config/|packages/|pnpm-lock\.yaml$)|^\.github/workflows/(master|ci)\.yml$' /tmp/changed.txt && api=true
# T10: workers/o11y (the worker itself), containers/o11y (the Grafana
# box image) and packages/ (the shared telemetry module — gated on the
# whole directory, not just packages/runtime/src/telemetry/, since
# e.g. scrub.ts imports redactPreviewHosts from monitor.ts, outside
# that subpath) all redeploy the o11y worker.
grep -qE '^runner/(workers/o11y/|containers/o11y/|packages/|pnpm-lock\.yaml$)|^\.github/workflows/(master|ci)\.yml$' /tmp/changed.txt && o11y=true
{
echo "authoring=$authoring"
echo "api=$api"
echo "o11y=$o11y"
} >> "$GITHUB_OUTPUT"
# One install, one workspace build — shared by all three deploys via artifacts.
build:
needs: [changes]
if: needs.changes.outputs.authoring == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.o11y == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: runner
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with:
package_json_file: runner/package.json
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: runner/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Upload the runtime build
uses: actions/upload-artifact@v6
with:
name: runtime-dist
path: runner/packages/runtime/dist/
retention-days: 1
if-no-files-found: error
# The authoring app builds here too — the deploy job then only ships it.
# VITE_API_BASE comes from apps/authoring/.env.production (committed) so
# it targets prod. SENTRY_* are only set on this deploying build; PR CI
# (ci.yml) gets no token, so PR builds neither emit source maps nor
# upload a release — see apps/authoring/vite.config.ts. VITE_SENTRY_SCOPE
# (contract §11) is pinned to "full" explicitly here, rather than relying
# on the code's own absent-means-full default.
- name: Build authoring (skipped when only the api/o11y deploys)
if: needs.changes.outputs.authoring == 'true'
run: pnpm --filter @handsontable/demo-authoring build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
GITHUB_SHA: ${{ github.sha }}
VITE_SENTRY_SCOPE: full
# ADR §C.3: the Sentry plugin above already uploaded these maps to
# Sentry for stack-trace symbolication there; here they're removed from
# dist/ only after also landing in R2 for the o11y worker's own
# symbolicator (workers/o11y/src/drain/symbolicate.ts#mapKeyFor:
# `sourcemaps/<service.version>/<original asset path>.map`). No maps
# means SENTRY_* wasn't configured (a PR build, or an api/o11y-only
# push) — the loop is then a no-op.
#
# Dedicated R2 S3 credential (Object Read & Write, scoped to
# `handsontable-demos-o11y-maps` — one-time setup step 2 in
# docs/run-and-deploy.md), via `aws s3 cp` (the AWS CLI ships
# preinstalled on GitHub-hosted Ubuntu runners).
- name: Upload source maps to R2, then remove them from dist
if: needs.changes.outputs.authoring == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_MAPS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_MAPS_SECRET_ACCESS_KEY }}
# Required by the SDK/CLI's request signing; R2 does not use it
# (contract: the region for an R2 bucket's S3 API is always "auto").
AWS_DEFAULT_REGION: auto
# EU jurisdiction (contract §2 — same value workers/o11y/wrangler.jsonc's
# own account_id + box.ts's Loki-bucket endpoint use), not a secret.
R2_MAPS_ENDPOINT: https://15111272c53ed0aaf84a908f0c9c7f8b.eu.r2.cloudflarestorage.com
# A modern AWS CLI defaults to a CRC32 trailing checksum on every
# request and validates one on every response; R2's S3 API doesn't
# implement that trailer, so the upload can be rejected outright.
# Both must be "when_required" (only send/validate when the
# operation explicitly requires it) so `aws s3 cp` against R2 works
# with whatever CLI version the runner image ships.
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
run: |
set -o pipefail
mapfile -t maps < <(find apps/authoring/dist -name '*.map')
if [ ${#maps[@]} -eq 0 ]; then
echo "no source maps in this build — nothing to upload to R2 (expected unless SENTRY_* secrets are set)"
else
for f in "${maps[@]}"; do
rel="${f#apps/authoring/dist/}"
key="sourcemaps/${GITHUB_SHA}/${rel}"
echo "uploading ${rel} -> ${key}"
aws s3 cp "$f" "s3://handsontable-demos-o11y-maps/${key}" \
--endpoint-url "$R2_MAPS_ENDPOINT" --content-type application/json
rm -f "$f"
done
fi
# AGENTS.md's own dev-bypass sanity check ("Always build prod with
# .env.local absent"). Must run AFTER the maps are gone
# above — a source map's sourcesContent literally embeds
# "localhost:8787" (App.tsx's API-base fallback) and "VITE_DEV_USER"
# (auth.ts), which would false-fire this exact grep against a map file
# that was never actually leaked into the served JS.
- name: Leak check — dev-login bypass must not reach the production bundle
if: needs.changes.outputs.authoring == 'true'
run: |
if grep -rl "localhost:8787\|VITE_DEV_USER\|dev@handsontable.com" apps/authoring/dist; then
echo "::error::dev-login bypass leaked into the production bundle — rebuild with .env.local absent (AGENTS.md)"
exit 1
fi
echo "ok: no dev-bypass sentinel found"
# Contract §10's own leak check: the local-only telemetry path
# (VITE_TELEMETRY_LOCAL) must never survive dead-code elimination into a
# production bundle. scripts/check-telemetry-leak.mjs greps
# apps/authoring/dist/assets/*.js for its sentinels.
- name: Leak check — local telemetry path must not reach the production bundle
if: needs.changes.outputs.authoring == 'true'
run: pnpm check:telemetry-leak
# The one regression that only shows up in a deploy: the compiler chunk's content hash
# coming back, which strands every tab opened before the next deploy (DEV-2569). ci.yml
# checks the PR build; a push or workflow_dispatch that never had a PR build reaches this
# job instead, so it is checked here too — before the upload, not after.
- name: Check the compiler chunk (DEV-2569)
if: needs.changes.outputs.authoring == 'true'
run: node scripts/check-compiler-chunk.mjs
- name: Upload the authoring build
if: needs.changes.outputs.authoring == 'true'
uses: actions/upload-artifact@v6
with:
name: authoring-dist
path: runner/apps/authoring/dist/
retention-days: 1
if-no-files-found: error
deploy-authoring:
needs: [changes, build]
if: needs.changes.outputs.authoring == 'true'
runs-on: ubuntu-latest
# id-token: write — the deploy-event step below mints a GitHub OIDC token
# for POST /telemetry/deploy. Job-level permissions REPLACE the
# workflow-level block, so contents: read is repeated here.
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: runner
steps:
- uses: actions/checkout@v5
- name: Download the authoring build
uses: actions/download-artifact@v7
with:
name: authoring-dist
path: runner/apps/authoring/dist/
# No pnpm install: wrangler is pinned through npx and ships ./dist as
# Workers Assets — the checkout only supplies wrangler.jsonc.
- name: Deploy authoring worker
id: deploy
working-directory: runner/apps/authoring
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -o pipefail
npx -y wrangler@4.108.0 deploy | tee /tmp/deploy-authoring.log
# Minor triage item 3 (C-M3): `|| true` so a wording change in
# wrangler's own "Current Version ID:" line (grep finds nothing,
# exits 1) can't fail this step — under `set -o pipefail` that
# would fail the whole job AFTER the deploy already shipped,
# skipping the deploy-event report and the smoke test below it.
version_id=$(grep -oE 'Current Version ID:.*' /tmp/deploy-authoring.log | awk '{print $NF}') || true
# B-I1: the `|| true` above trades "job goes red" for "job stays
# green" on a wrangler wording change — this warning is what makes
# that trade safe: an empty version_id would otherwise ship a
# deploy-event row with cf_version_id:"" (silently corrupting the
# ADR §C.2 deploy-correlation record) with nothing pointing at the
# actual root cause (the version-id parse, not the deploy itself).
[ -n "$version_id" ] || echo "::warning::could not parse Current Version ID from wrangler deploy output (handsontable-demos-authoring) — the deploy-event report below will ship with an empty cf_version_id"
echo "version_id=${version_id}" >> "$GITHUB_OUTPUT"
- name: Report the deploy to the o11y worker (T10, ADR §C.2)
# Never fails the job — see "Deploy events" in docs/run-and-deploy.md:
# a deploy that shipped must not go red because reporting it hiccuped,
# and on the very first merge of this feature the o11y route may not
# be reachable yet.
if: always() && steps.deploy.outcome == 'success'
working-directory: .
# VERSION_ID passed through env, the same way GITHUB_SHA already is —
# never spliced with ${{ }} straight into the script, which would let
# an unexpectedly-shaped value (e.g. one containing a quote) break out
# of the JSON string it's built into.
env:
VERSION_ID: ${{ steps.deploy.outputs.version_id }}
run: |
oidc_token=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://demos.handsontable.com/telemetry/deploy" \
| jq -r '.value') || true
if [ -z "$oidc_token" ]; then
echo "::warning::could not mint a GitHub OIDC token — skipping the deploy event"
exit 0
fi
code=$(curl -sf -o /dev/null -w '%{http_code}' -X POST https://demos.handsontable.com/telemetry/deploy \
-H "Authorization: Bearer ${oidc_token}" -H 'Content-Type: application/json' \
--data "{\"event\":\"deploy\",\"service\":\"handsontable-demos-authoring\",\"sha\":\"${GITHUB_SHA}\",\"cf_version_id\":\"${VERSION_ID}\"}") \
|| true
echo "deploy-event response: ${code:-<none>}"
[ "$code" = "204" ] || [ "$code" = "200" ] || echo "::warning::deploy event for handsontable-demos-authoring did not return 204 (${code:-<none>})"
- name: Smoke test — prod frontend serves current bundle
working-directory: .
run: |
# The entry bundle is the one referenced by the built index.html —
# don't glob dist/assets (lazy chunks like index-<digits>-*.js sort
# before the entry and produce a false mismatch).
built_asset=$(grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' runner/apps/authoring/dist/index.html | head -1)
if [ -z "$built_asset" ]; then
echo "::error::could not find entry bundle in built index.html"
exit 1
fi
for i in $(seq 1 12); do
prod_asset=$(curl -s https://demos.handsontable.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1)
echo "attempt $i -> prod: $prod_asset, built: $built_asset"
[ "$prod_asset" = "$built_asset" ] && exit 0
sleep 5
done
echo "::error::prod frontend does not serve the freshly built bundle"
exit 1
# Post-deploy smoke (DEV-2203): the @smoke E2E subset against prod — one
# render per engine, a share fixture, a docs example, one Style round-trip.
# One run however many deploys fired (the per-workflow design would have
# smoked twice on a packages/** push). `always()` unwraps the skipped-deploy
# case; the result checks keep it off when nothing deployed or one failed.
smoke:
needs: [deploy-authoring, deploy-api]
if: >-
always() &&
(needs.deploy-authoring.result == 'success' || needs.deploy-api.result == 'success') &&
needs.deploy-authoring.result != 'failure' &&
needs.deploy-api.result != 'failure'
uses: ./.github/workflows/e2e-live.yml
with:
base_url: 'https://demos.handsontable.com'
smoke: true
deploy-api:
# Also needs deploy-o11y (mutual service bindings — API's own O11Y
# binding and o11y's own API#O11yUsage binding). `if` accepts a skipped
# deploy-o11y (unrelated push, nothing to wait for) but not a failed one —
# see "First deploy, in order" in docs/run-and-deploy.md.
needs: [changes, build, deploy-o11y]
if: >-
!cancelled() &&
needs.changes.outputs.api == 'true' &&
needs.build.result == 'success' &&
needs.deploy-o11y.result != 'failure'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: runner
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with:
package_json_file: runner/package.json
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: runner/pnpm-lock.yaml
# Install stays: `pnpm run deploy` runs the workspace's own wrangler and
# bundles the worker, which resolves @handsontable/demo-runtime from the
# artifact downloaded below instead of rebuilding it.
- run: pnpm install --frozen-lockfile
- name: Download the runtime build
uses: actions/download-artifact@v7
with:
name: runtime-dist
path: runner/packages/runtime/dist/
# Apply pending D1 schema changes to the remote DB before shipping code
# that may depend on them. Migrations are idempotent (IF NOT EXISTS).
- name: Apply D1 migrations (remote)
working-directory: runner/workers/api
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: pnpm exec wrangler d1 migrations apply handsontable-demos --remote
# ubuntu-latest ships Docker with the daemon running; wrangler uses it to
# build + push the containers/live image. Use the package `deploy` script,
# which attaches the demos.handsontable.com routes via --routes (routes are
# intentionally NOT in wrangler.jsonc — see ADR-0020 / run-and-deploy.md).
- name: Deploy API worker (builds + pushes Tier-2 image, attaches routes)
id: deploy
working-directory: runner/workers/api
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -o pipefail
pnpm run deploy | tee /tmp/deploy-api.log
# Minor triage item 3 (C-M3): see the matching comment in
# deploy-authoring — same reasoning, same fix.
version_id=$(grep -oE 'Current Version ID:.*' /tmp/deploy-api.log | awk '{print $NF}') || true
# B-I1: see the matching comment in deploy-authoring — same reasoning, same fix.
[ -n "$version_id" ] || echo "::warning::could not parse Current Version ID from wrangler deploy output (handsontable-demos-api) — the deploy-event report below will ship with an empty cf_version_id"
echo "version_id=${version_id}" >> "$GITHUB_OUTPUT"
- name: Report the deploy to the o11y worker (T10, ADR §C.2)
if: always() && steps.deploy.outcome == 'success'
working-directory: .
env:
VERSION_ID: ${{ steps.deploy.outputs.version_id }}
run: |
oidc_token=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://demos.handsontable.com/telemetry/deploy" \
| jq -r '.value') || true
if [ -z "$oidc_token" ]; then
echo "::warning::could not mint a GitHub OIDC token — skipping the deploy event"
exit 0
fi
code=$(curl -sf -o /dev/null -w '%{http_code}' -X POST https://demos.handsontable.com/telemetry/deploy \
-H "Authorization: Bearer ${oidc_token}" -H 'Content-Type: application/json' \
--data "{\"event\":\"deploy\",\"service\":\"handsontable-demos-api\",\"sha\":\"${GITHUB_SHA}\",\"cf_version_id\":\"${VERSION_ID}\"}") \
|| true
echo "deploy-event response: ${code:-<none>}"
[ "$code" = "204" ] || [ "$code" = "200" ] || echo "::warning::deploy event for handsontable-demos-api did not return 204 (${code:-<none>})"
- name: Smoke test — prod API health
working-directory: .
run: |
for i in $(seq 1 12); do
code=$(curl -s -o /dev/null -w "%{http_code}" https://demos.handsontable.com/api/health || true)
echo "health attempt $i -> $code"
[ "$code" = "200" ] && exit 0
sleep 6
done
echo "::error::prod /api/health did not return 200 after deploy"
exit 1
# The observability worker (InboxWriter + GrafanaBox). Deployed before
# deploy-api (see that job's `needs` and docs/run-and-deploy.md's "First
# deploy, in order") because of their mutual service bindings.
deploy-o11y:
needs: [changes, build]
if: needs.changes.outputs.o11y == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: runner
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with:
package_json_file: runner/package.json
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: runner/pnpm-lock.yaml
# Install stays: `pnpm run deploy` runs the workspace's own wrangler and
# bundles the worker, which resolves @handsontable/demo-runtime/telemetry
# from the artifact downloaded below instead of rebuilding it.
- run: pnpm install --frozen-lockfile
- name: Download the runtime build
uses: actions/download-artifact@v7
with:
name: runtime-dist
path: runner/packages/runtime/dist/
# Builds + pushes the Grafana box image (Docker required, same as
# deploy-api) and attaches the /telemetry/* and /grafana/* routes via
# --routes (never in wrangler.jsonc — ADR-0020), plus
# --var SERVICE_VERSION:$GITHUB_SHA (contract §2).
- name: Deploy o11y worker (builds + pushes the Grafana box image, attaches routes)
id: deploy
working-directory: runner/workers/o11y
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -o pipefail
pnpm run deploy | tee /tmp/deploy-o11y.log
# Minor triage item 3 (C-M3): see the matching comment in
# deploy-authoring — same reasoning, same fix.
version_id=$(grep -oE 'Current Version ID:.*' /tmp/deploy-o11y.log | awk '{print $NF}') || true
# B-I1: see the matching comment in deploy-authoring — same reasoning, same fix.
[ -n "$version_id" ] || echo "::warning::could not parse Current Version ID from wrangler deploy output (handsontable-demos-o11y) — the deploy-event report below will ship with an empty cf_version_id"
echo "version_id=${version_id}" >> "$GITHUB_OUTPUT"
- name: Report the deploy to the o11y worker (T10, ADR §C.2)
if: always() && steps.deploy.outcome == 'success'
working-directory: .
env:
VERSION_ID: ${{ steps.deploy.outputs.version_id }}
run: |
oidc_token=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://demos.handsontable.com/telemetry/deploy" \
| jq -r '.value') || true
if [ -z "$oidc_token" ]; then
echo "::warning::could not mint a GitHub OIDC token — skipping the deploy event"
exit 0
fi
code=$(curl -sf -o /dev/null -w '%{http_code}' -X POST https://demos.handsontable.com/telemetry/deploy \
-H "Authorization: Bearer ${oidc_token}" -H 'Content-Type: application/json' \
--data "{\"event\":\"deploy\",\"service\":\"handsontable-demos-o11y\",\"sha\":\"${GITHUB_SHA}\",\"cf_version_id\":\"${VERSION_ID}\"}") \
|| true
echo "deploy-event response: ${code:-<none>}"
[ "$code" = "204" ] || [ "$code" = "200" ] || echo "::warning::deploy event for handsontable-demos-o11y did not return 204 (${code:-<none>})"