Skip to content

feat(makie): implement scatter-color-mapped #2454

feat(makie): implement scatter-color-mapped

feat(makie): implement scatter-color-mapped #2454

Workflow file for this run

# Build the shipped API image and smoke-test the container, before merge.
#
# Until this workflow existed, the first build attempt of a changed Dockerfile
# happened in Cloud Build — after the merge. The deploy-api trigger sat red from
# 2026-08-30 until #10821 for exactly that reason: the runtime stage was missing
# a library that only the RUNNING image can reveal, and every PR check was green
# throughout. The sibling repo kurrentschrift added the same job for the same
# class of miss (its pyproject.toml fell out of the runtime stage and the API
# would have reported version 0.0.0 in production); keep the two in the same
# shape.
#
# Three conventions this file keeps, all shared with the sibling ci-* workflows:
#
# - Every `uses:` is pinned to a commit SHA with the version as a comment. A
# movable tag is a write handle into these runners. Dependabot bumps SHA pins
# exactly as it bumps tags (the `github-actions` ecosystem in
# .github/dependabot.yml), so a pinned workflow costs nothing to maintain.
# - Change detection over `paths:` filters: a job that always reports a result
# is easier to read on a PR than a check that silently never appears, and it
# is the shape ci-lint.yml and ci-tests.yml already use. `plots/**` is
# deliberately NOT a trigger — the automated plot pipeline opens hundreds of
# PRs that touch nothing the image serves.
# - `timeout-minutes` on the job. GitHub's default is 360, so a docker build
# that hangs on a package index would burn six hours and report nothing.
name: "CI: Image"
run-name: "Image: ${{ github.ref_name }}"
on:
push:
branches:
- main
- develop
- 'feature/**'
pull_request:
branches:
- main
- develop
- 'specification/**'
- 'implementation/**'
merge_group:
workflow_dispatch:
inputs:
force_run:
description: 'Force the image build (ignore change detection)'
type: boolean
default: true
concurrency:
group: ci-image-${{ github.ref }}
cancel-in-progress: true
jobs:
image:
name: Build API image and smoke the container
runs-on: ubuntu-latest
permissions:
contents: read
# A cold build (no layer cache) installs the full runtime dependency set —
# pandas, scipy, scikit-learn, statsmodels, matplotlib, anthropic — so it is
# minutes, not seconds; a warm one is dominated by the context transfer.
# 30 leaves room for a cold build plus the 90 s readiness window below, and
# still fails loudly instead of hanging.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The change detection below diffs against the base commit, which a
# shallow checkout lacks.
fetch-depth: 0
- name: Check for image-relevant changes
id: check
# The event payload travels through env, not through `${{ }}` inside the
# script: a shell that never sees interpolated event data cannot be made
# to execute it.
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MG_BASE_SHA: ${{ github.event.merge_group.base_sha }}
MG_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.event.after }}
FORCE_RUN: ${{ inputs.force_run }}
run: |
set -uo pipefail
# A diff that cannot be computed says NOTHING about the image, so it
# must never read as "nothing changed" — that is a gate which passes
# by failing. On a PR or a merge group the refs are guaranteed present
# (fetch-depth: 0), so a failure there is a real problem and stops the
# job; on a push whose parent is unreachable the job builds instead.
case "$EVENT_NAME" in
pull_request)
if ! CHANGED_FILES=$(git diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA"); then
echo "::error::could not diff $PR_BASE_SHA..$PR_HEAD_SHA — refusing to decide whether the image needs building"
exit 1
fi ;;
merge_group)
if ! CHANGED_FILES=$(git diff --name-only "$MG_BASE_SHA" "$MG_HEAD_SHA"); then
echo "::error::could not diff $MG_BASE_SHA..$MG_HEAD_SHA — refusing to decide whether the image needs building"
exit 1
fi ;;
push)
# The whole pushed range, not HEAD~1..HEAD: a push can carry
# several commits, and an earlier one touching api/ under a tip
# commit that does not would otherwise skip the gate.
# github.event.before is all zeroes on a new branch, and after a
# force-push the old tip may be gone — neither says anything about
# the image, so both build.
if [[ "$PUSH_BEFORE" =~ ^0+$ ]] || ! CHANGED_FILES=$(git diff --name-only "$PUSH_BEFORE" "$PUSH_AFTER"); then
echo "::warning::the pushed range $PUSH_BEFORE..$PUSH_AFTER is not diffable — building the image rather than skipping the gate"
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi ;;
*)
if ! CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD); then
echo "::warning::no parent commit to diff against — building the image rather than skipping the gate"
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi ;;
esac
echo "Changed files:"
echo "$CHANGED_FILES"
# What can change what the image contains or how it boots: the two
# Dockerfiles, the dependency lock, the source the runtime stage
# copies, README.md (the builder copies it next to pyproject.toml, so
# a rename breaks the install) and a root .dockerignore, which decides
# what a `context: .` build can see at all. plots/ is not among them
# and is no longer in the image either — nothing under api/ or core/
# reads it at runtime, the implementations being served from Postgres —
# so the plot pipeline's PRs must not each pay for a container build.
IMAGE_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(api/|core/|pyproject\.toml$|uv\.lock$|README\.md$|\.dockerignore$|app/Dockerfile$|\.github/workflows/ci-image\.yml$)' || true)
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$FORCE_RUN" == "true" ]]; then
echo "Manual trigger with force_run=true, will build the image"
echo "should_build=true" >> "$GITHUB_OUTPUT"
elif [[ -n "$IMAGE_CHANGES" ]]; then
echo "Found image-relevant changes, will build the image"
echo "should_build=true" >> "$GITHUB_OUTPUT"
else
echo "No image-relevant changes, skipping the build"
echo "should_build=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Buildx
if: steps.check.outputs.should_build == 'true'
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# push: false — nothing here reaches a registry; Cloud Build still owns the
# published image. load: true puts the result into the local daemon so the
# smoke below can actually run it. The GHA cache keeps the repeat builds of
# an unchanged dependency set at seconds instead of minutes.
- name: Build the API image
if: steps.check.outputs.should_build == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: api/Dockerfile
push: false
load: true
tags: anyplot-api:ci
cache-from: type=gha
cache-to: type=gha,mode=max
# No database, no secrets: the container is started bare and only asked the
# questions that need no Cloud SQL. That is deliberate — this is a check of
# the IMAGE, not of the deployment. api/main.py guards its DB init with
# is_db_configured(), so a bare container boots and serves /health.
- name: Container smoke (no database)
if: steps.check.outputs.should_build == 'true'
run: |
set -euo pipefail
docker run -d --name api -p 8000:8000 anyplot-api:ci
# 90 s: this image imports matplotlib, scikit-learn, statsmodels and
# the MCP server before the first request is served.
ready=0
for _ in $(seq 45); do
if curl -fsS localhost:8000/health >/dev/null 2>&1; then ready=1; break; fi
sleep 2
done
if [ "$ready" -ne 1 ]; then
echo "::error::the container never answered /health within 90 s"
exit 1
fi
curl -fsS localhost:8000/health | grep -q '"healthy"'
echo "health OK"
# THE assert this job exists for. api/version.py reads the INSTALLED
# distribution's metadata and falls back to "0.0.0+unknown" when it is
# absent — silently, in a field /health, /openapi.json and the MCP
# server all report. The builder stage installs the project from a
# context that holds pyproject.toml and uv.lock but no source yet, so
# the dist-info that carries the version is a genuinely fragile
# artefact of that ordering, and nothing else in CI looks at it.
want=$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
got=$(curl -fsS localhost:8000/health | python3 -c "import json,sys;print(json.load(sys.stdin)['version'])")
if [ "$got" != "$want" ]; then
echo "::error::the image reports version $got, pyproject.toml says $want"
exit 1
fi
echo "version OK: $got"
# Pins the runtime stage's COPY list against a future rebuild. This is
# the payload the image is expected to SERVE and cannot import its way
# to: og_images.py reads it off disk as the last resort when dynamic OG
# rendering fails, which is the branch that runs when a fresh container
# cannot reach the font bucket.
docker exec api test -f /app/api/static/og-image.png
echo "COPY list OK"
# The image must serve as the unprivileged user the Dockerfile creates.
# `USER appuser` is one line above the CMD and nothing else notices if
# a rebuild drops it — Cloud Run runs whatever the image says.
uid=$(docker exec api id -u)
if [ "$uid" != "1000" ]; then
echo "::error::the container runs as uid $uid (expected 1000, the appuser the Dockerfile creates)"
exit 1
fi
echo "non-root OK: uid $uid"
- name: Container logs on failure
if: failure() && steps.check.outputs.should_build == 'true'
run: docker logs api || true
# Threshold `warning` and NO file-wide exceptions: a warning of any code
# blocks, which is the point of having the linter at all. The two rules
# api/Dockerfile declines (DL3008 apt pinning, DL3025 shell-form
# HEALTHCHECK) now carry `# hadolint ignore=<code>` comments at the exact
# instruction they excuse, so a NEW occurrence elsewhere in the file is
# still caught — which a file-wide `ignore:` silently swallowed. The third
# former exception, DL3013, is simply gone: `pip install uv==<version>` is
# pinned. DL3066 (non-numeric USER) still fires at info level, below the
# threshold, so it stays visible without blocking. app/Dockerfile declines
# exactly one, DL3064 on the origin gate's `ORIGIN_SECRET=""` default: the
# rule reads the variable NAME, the value is the empty string, and the
# reason it has to be declared at all is written beside the ignore. It sits
# on an ENV instruction of its own so it excuses that line and nothing
# else. Verified against hadolint 2.15.1, the version this action pins.
- name: Hadolint (api/Dockerfile)
if: steps.check.outputs.should_build == 'true'
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: api/Dockerfile
failure-threshold: warning
- name: Hadolint (app/Dockerfile)
if: steps.check.outputs.should_build == 'true'
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: app/Dockerfile
failure-threshold: warning
- name: Skip notice
if: steps.check.outputs.should_build == 'false'
run: echo "::notice::Image build skipped - no changes to api/, core/, the dependency lock or a Dockerfile"
# The same idea for the frontend container, and for a sharper reason: what
# app/Dockerfile produces is not a program that fails to import, it is an
# nginx that either boots or does not — and since the origin gate
# (app/origin-gate.conf.template) the config it boots with is RENDERED at
# container start from two environment variables. Nothing before this job
# ever ran that entrypoint: the deploy's pre-traffic smoke is the first place
# the rendered config exists, and it lives in Cloud Build, after the merge.
#
# A separate job rather than more steps in the one above, so an SPA build
# never delays the API smoke and neither failure hides the other. Its change
# detection is deliberately narrow: `app/src/**` cannot affect any of this,
# and the frontend opens far too many PRs to pay for a container build each
# time. The event-shape handling is the one above in short form — the long
# rationale for each branch is written out there.
app-image:
name: Build app image and smoke the origin gate
runs-on: ubuntu-latest
permissions:
contents: read
# yarn install + vite build cold is minutes; the gate matrix below is
# seconds. 20 fails loudly instead of hanging on a slow package index.
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Check for app-container changes
id: check
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MG_BASE_SHA: ${{ github.event.merge_group.base_sha }}
MG_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.event.after }}
FORCE_RUN: ${{ inputs.force_run }}
run: |
set -uo pipefail
CHANGED_FILES=""
case "$EVENT_NAME" in
pull_request)
if ! CHANGED_FILES=$(git diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA"); then
echo "::error::could not diff $PR_BASE_SHA..$PR_HEAD_SHA — refusing to decide whether the app image needs building"
exit 1
fi ;;
merge_group)
if ! CHANGED_FILES=$(git diff --name-only "$MG_BASE_SHA" "$MG_HEAD_SHA"); then
echo "::error::could not diff $MG_BASE_SHA..$MG_HEAD_SHA — refusing to decide whether the app image needs building"
exit 1
fi ;;
push)
if [[ "$PUSH_BEFORE" =~ ^0+$ ]] || ! CHANGED_FILES=$(git diff --name-only "$PUSH_BEFORE" "$PUSH_AFTER"); then
echo "::warning::the pushed range is not diffable — building the app image rather than skipping the gate"
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi ;;
*)
if ! CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD); then
echo "::warning::no parent commit to diff against — building the app image rather than skipping the gate"
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi ;;
esac
echo "Changed files:"
echo "$CHANGED_FILES"
# What can change how this container boots or what it serves: the
# Dockerfile, the three nginx files, the build inputs, and what the
# build context can see. Not app/src — the gate is upstream of every
# line of it.
APP_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(app/(Dockerfile|nginx\.conf|security-headers\.conf|origin-gate\.conf\.template|package\.json|yarn\.lock|vite\.config\.ts|index\.html|\.dockerignore)$|\.github/workflows/ci-image\.yml$)' || true)
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$FORCE_RUN" == "true" ]]; then
echo "Manual trigger with force_run=true, will build the app image"
echo "should_build=true" >> "$GITHUB_OUTPUT"
elif [[ -n "$APP_CHANGES" ]]; then
echo "Found app-container changes, will build the app image"
echo "should_build=true" >> "$GITHUB_OUTPUT"
else
echo "No app-container changes, skipping the build"
echo "should_build=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Buildx
if: steps.check.outputs.should_build == 'true'
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Its own cache scope: this image shares no layer with the API's, and one
# shared scope would have them evicting each other.
- name: Build the app image
if: steps.check.outputs.should_build == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: app
file: app/Dockerfile
push: false
load: true
tags: anyplot-app:ci
build-args: |
VITE_API_URL=https://api.anyplot.ai
VITE_DEBUG_API_URL=/api
cache-from: type=gha,scope=app-image
cache-to: type=gha,mode=max,scope=app-image
# THE assert this job exists for: the gate's three states, against the
# real image, through the real entrypoint. A test over the config text can
# only say what the file says; only a running container can say that
# envsubst rendered it, that nginx accepted the result, and that the maps
# decide what they were meant to decide.
- name: Origin gate matrix (off / armed / armed with no secret)
if: steps.check.outputs.should_build == 'true'
run: |
set -euo pipefail
# Not a production value and never one: this container is thrown away
# at the end of the job, and the real secret has no business on a
# runner that prints its own logs on failure. It is the LENGTH of a
# real one on purpose — 64 characters, what `openssl rand -hex 32`
# produces. A short placeholder passed this job while the production
# length did not: the tagged map key is `presented:` plus the secret,
# and nginx cannot hash a key longer than one bucket, so with the gate
# armed the container refused to start. That is the bug this line now
# keeps caught (see map_hash_bucket_size in
# app/origin-gate.conf.template).
SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
start() { # start <name> <host-port> [docker run args...]
local name="$1" port="$2"; shift 2
docker run -d --name "$name" -p "$port:8080" "$@" anyplot-app:ci >/dev/null
for _ in $(seq 30); do
if curl -fsS "localhost:$port/_health" >/dev/null 2>&1; then return 0; fi
sleep 1
done
echo "::error::container $name never answered /_health within 30 s"
docker logs "$name" || true
exit 1
}
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
verdict() { curl -sI "$@" | tr -d '\r' | sed -n 's/^[Xx]-[Oo]rigin-[Gg]ate: //p'; }
want() { # want <what> <got> <expected>
if [ "$2" != "$3" ]; then echo "::error::$1: got '$2', expected '$3'"; exit 1; fi
echo "OK: $1 = $3"
}
# 1. Unset — the state this change ships in and rolls back to. Nothing
# is refused, and /_health already reports whether a header arrived,
# which is what makes arming a measurement instead of a leap.
start gate-off 8081
want "gate off: the shell still serves" "$(code localhost:8081/)" 200
want "gate off: no header" "$(verdict localhost:8081/_health)" off
want "gate off: a header arrived" "$(verdict -H 'X-Origin-Secret: anything' localhost:8081/_health)" off-seen
# 2. Armed. The door is shut, the exempt path is not, and the refusal
# says which of the two armed failures it is.
start gate-on 8082 -e ORIGIN_GATE=on -e ORIGIN_SECRET="$SECRET"
want "armed: no header is refused" "$(code localhost:8082/)" 403
want "armed: wrong secret is refused" "$(code -H "X-Origin-Secret: wrong" localhost:8082/)" 403
want "armed: the right secret passes" "$(code -H "X-Origin-Secret: $SECRET" localhost:8082/)" 200
want "armed: /_health stays exempt" "$(code localhost:8082/_health)" 200
want "armed: /_health names the failure" "$(verdict localhost:8082/_health)" missing
want "armed: a wrong secret is a mismatch" "$(verdict -H "X-Origin-Secret: wrong" localhost:8082/_health)" mismatch
want "armed: the right secret is ok" "$(verdict -H "X-Origin-Secret: $SECRET" localhost:8082/_health)" ok
# The refusal is a page, not nginx's stock one — which would print the
# exact nginx version to anyone knocking on the raw origin.
curl -s localhost:8082/ -o denied.html
grep -qF "answers only requests that came through the edge" denied.html \
|| { echo "::error::the 403 body is not the gate's own page"; cat denied.html; exit 1; }
# The one thing that must never leak, in the two places it could.
# `if !` rather than `grep … && exit`: under `set -e` a grep that
# finds nothing — the passing case — would end the script itself.
if grep -qF "$SECRET" denied.html; then
echo "::error::the 403 body carries the secret"; exit 1
fi
if docker logs gate-on 2>&1 | grep -qF "$SECRET"; then
echo "::error::the container logged the secret"; exit 1
fi
echo "OK: the secret is in neither the refusal nor the logs"
# The rendered config has to be valid nginx, and `nginx -t` says so
# without dumping it — `nginx -T` would print the secret into this log.
docker exec gate-on nginx -t
echo "OK: the rendered config validates"
# 3. Armed with NO secret must fail CLOSED. This is the whole reason
# the map keys are tagged: an untagged `"${ORIGIN_SECRET}"` key
# would render as `""`, which is exactly what an absent header
# looks like — and a forgotten variable would open the door to the
# entire internet while looking armed.
start gate-shut 8083 -e ORIGIN_GATE=on
want "armed, no secret: refused" "$(code localhost:8083/)" 403
want "armed, no secret: empty header too" "$(code -H 'X-Origin-Secret;' localhost:8083/)" 403
want "armed, no secret: /_health exempt" "$(code localhost:8083/_health)" 200
- name: Container logs on failure
if: failure() && steps.check.outputs.should_build == 'true'
run: |
for c in gate-off gate-on gate-shut; do
echo "=== $c ==="
docker logs "$c" 2>&1 || true
done
- name: Skip notice
if: steps.check.outputs.should_build == 'false'
run: echo "::notice::App image build skipped - no changes to app/Dockerfile, the nginx configuration or the build inputs"