feat(highcharts): implement scatter-color-mapped #2452
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |