PR previews: publish the seed mission as a dashboard per pull request
Summary
When a PR is opened, we want a live, clickable preview of the work. Rather than standing up a full MMGIS instance per PR, the preview is the deployed dashboard — the same artifact MMGIS's publish flow already produces — driven by CI: on PR open/update, CI publishes the seed mission as a dashboard and links it from the PR; on PR close, CI tears that dashboard down using the existing delete path. We reuse the publish boot and teardown MMGIS already has, instead of building new per-PR instance infrastructure.
Motivation
Reviewing tool/plugin work from a diff is slow. A published dashboard per PR lets a reviewer open the branch's rendered app and see the tools. MMGIS already has a publish pipeline that bakes a mission into a static, password-gated dashboard and a teardown that deletes it. Reusing that — instead of inventing per-PR instance plumbing — is far less to build and maintain, much cheaper (static hosting, no running compute per PR), and the teardown story is already solved.
What the preview is (and isn't)
- It's the published static dashboard — the same read-only artifact a real publish produces, not an interactive admin instance.
- Fidelity tradeoff, stated plainly: the static dashboard runtime serves a reduced API surface (some calls are baked at publish time, some rerouted, some computed client-side, some dropped). Tool behavior that depends on a live backend won't fully exercise in the preview. This is the accepted cost of reusing the publish flow — it previews how tools render and lay out, which is the main review goal for plugin work, rather than full backend interactivity. (This reverses an earlier draft that used an interactive lean instance for higher fidelity; we chose reuse-of-proven-infra and cheap, clean teardown over fidelity.)
Decisions baked into this issue
- The preview is the deployed dashboard, produced by the existing publish flow — not a per-PR lean admin instance, and not a publish button a reviewer clicks.
- Reuse the existing publish boot and teardown, driven by CI. CI runs the publish on PR open/update and the existing stack-delete on PR close. No new instance infrastructure (no per-PR Fargate/ALB/Traefik).
- Publish the generated seed mission (companion issue) so the dashboard reflects the current tool/plugin set.
- One CI-owned dashboard per PR, deterministically named so it's trackable and torn down cleanly.
Behavior / acceptance
- Opening a PR publishes a dashboard for that PR via the existing publish flow and posts its URL on the PR. Updating the PR re-publishes / updates the same dashboard.
- Closing or merging the PR runs the existing teardown so the dashboard's resources are deleted. No dashboard outlives its PR.
- The published dashboard is built from the generated seed mission and shows the current tools/plugins.
- The dashboard is access-gated — it inherits the publish flow's password gate — not open to the world.
Decision record to ship with this PR
This PR should add a short standalone ADR at docs/adr/20260617-pr-preview-published-dashboard.md:
PR previews are published dashboards produced by the existing publish flow, orchestrated by CI.
Context: PRs need a live preview; MMGIS already has a publish pipeline that bakes a mission into a static (S3 + CloudFront, password-gated) dashboard and a teardown that deletes it.
Decision: the preview is a published dashboard of the generated seed mission; CI invokes the existing publish boot on PR open/update and the existing stack-delete on PR close, one dashboard per PR.
Rationale: reuses proven infrastructure instead of building per-PR instance plumbing; static hosting is cheap and the teardown is already solved; CI owns the single per-PR lifecycle, so nothing is left untracked. Rejected: an ephemeral lean instance per PR (higher fidelity but new infra — Fargate/ALB/Traefik — plus per-PR running cost and a teardown story to build from scratch).
Consequences: previews run the static dashboard runtime (reduced API), so backend-dependent tool behavior isn't fully exercised; the publish flow needs a CI entry point and a way to publish from the committed seed without a full admin DB; previews depend on the generated seed.
Implementation sketch — rough guide, written as of 42b9e0ad on 2026-06-17; re-verify against latest
The publish boot to reuse:
scripts/publish-static.js — bakes the mission config into src/pre/staticConfig.js, runs the theme build + webpack with SERVER=static, then CloudFormation CreateStack via scripts/lib/cfn-template.js (per-dashboard S3 bucket + CloudFront distribution + password-gate CloudFront Function + OAC), uploads the bundle, and records the CloudFront URL. Driven by MMGIS_DEPLOYMENT_ID + MMGIS_DEPLOYMENT_ACTION (publish/update).
- Packaged as
infrastructure/ecs/publish-task.json (entrypoint node scripts/publish-static.js). Required env per sample.env: MMGIS_DASHBOARDS_PASSWORD, AWS_REGION, MMGIS_PUBLISH_*.
The teardown to reuse:
- The existing delete path empties the bucket and
DeleteStacks (today behind DELETE /api/deployments/:id). Factor the empty-bucket + DeleteStack step into something CI can call directly on PR close.
Integration gotcha — config source:
publish-static.js currently reads the mission config from Postgres by deployment id. For CI we want to publish the committed generated seed without standing up a full admin DB. Cleanest: adapt publish-static.js to accept a config file/path (the seed JSON) as an alternative to the DB lookup. Alternative: seed a throwaway DB + deployment row and run it unchanged (heavier).
Per-PR identity + lifecycle:
- Name each stack
mmgis-dashboard-pr-<N> (the publish IAM is already scoped to mmgis-dashboard-*). CreateStack is idempotent on re-run (publish reuses the existing stack for updates).
- CI: a
pull_request workflow — opened/synchronize → publish/update, closed → delete. OIDC deploy role (same pattern as deploy-lean.yml), runs in the smce-veda staging account. Per-PR concurrency so re-pushes don't race.
Auth comes for free:
- Published dashboards are already gated by the publish flow's password-checking CloudFront Function, so previews are access-controlled without extra work.
Depends on the generated seed:
- The dashboard is published from the generated seed mission (companion issue). Land that first so previews reflect the current tool set.
Implementation questions & gotchas (code investigation, 42b9e0a / 2026-06-18)
A full read of the publish/teardown code confirmed the primitives are reusable but the orchestration is welded to Postgres + the Express admin route. The findings:
publish-static.js is bound to the DB on two axes. It requires MMGIS_DEPLOYMENT_ID, loads a deployments row (mission name, stack name, final URL, status all come off it), and reads mission config live from the configs + generaloptions tables. The baked shape it produces is small and well-defined ({get, missions, get_generaloptions} → staticConfig.js). The key fork: (a) stand up the admin app + DB, insert a deployments row, and drive the supported flow; or (b) fork main() into a file-driven script that builds baked from the committed seed JSON, derives the stack name from the PR number, and calls the existing provision.* helpers directly — this drops the DB, the ECS task, and the admin role entirely. (b) is the lighter, cleaner path and resolves the "config source" gotcha above.
- The build is heavy. Publish runs a full webpack production build with
SERVER=static (plus build:themes, which needs rsync + npx sass, if dist/ isn't prebaked). The publish ECS task is sized 2 vCPU / 8 GB for this; a default GitHub runner (2/7) is near that ceiling — budget 3–8 min and watch for OOM on the webpack step.
- CloudFront create time dominates, not the build.
waitForStack polls with a 30-minute timeout; a fresh CloudFront distribution realistically reaches CREATE_COMPLETE in ~5–20 min. So first-publish per PR is slow; subsequent pushes should use the update action.
update is cheap but never touches CloudFormation. Re-publish re-bakes, re-uploads, and invalidates /* on the same stack/bucket/URL — no UpdateStack exists in the code. Consequence: a changed template or the dashboard password won't reach an existing preview without delete+recreate. Also: each update fires a full /* invalidation (1000 free/month, then billed; minutes to propagate).
- Teardown primitives are clean but not standalone.
provision.emptyBucket + provision.deleteStack are reusable, but the only caller is the lean-gated DELETE /api/deployments/:id route, which takes a Sequelize row and reads the bucket name off it (or the stack output). CI needs its own ~10-line teardown that calls provision.* with a self-tracked stack name. Stacks are tagged mmgis:deployment — useful for a TTL sweep.
- Naming + the password. Only the stack name is deterministic (
mmgis-dashboard-<id>); the S3 bucket name is CloudFormation-generated and read back as an output. CI must supply its own unique id (the PR number). The dashboard password is a single shared value baked into the CloudFront Function at create time (not a CFN param) — so all previews share one password unless we template per-PR passwords, and rotating means recreate.
- IAM / OIDC blast radius. Running this outside the admin task role means the GitHub OIDC role needs CloudFormation + CloudFront + S3 +
iam:PassRole (+ ECS if reusing RunTask). CloudFront create/distribution actions don't support resource-level ARNs, so that grant is effectively account-wide — least-privilege is not fully achievable here.
- Concurrency + failure debris. No per-PR locking today; two rapid pushes race on bucket uploads (last-writer-wins, possibly stale) — add a per-PR concurrency group with cancel-in-progress (the existing
deploy-lean.yml concurrency is global). OnFailure: DO_NOTHING leaves CREATE_FAILED/ROLLBACK_COMPLETE stacks behind; a ROLLBACK_COMPLETE stack can't be updated and must be deleted before retry.
- Cost / limits. Each open PR = 1 CloudFront distribution + 1 S3 bucket until close. The account default cap is 200 distributions; 40+ open PRs warrants a tag-based TTL sweeper as a backstop.
- Fidelity gaps to expect (from
staticHandlers.js). A published dashboard renders but the following are dead: auth/login; Draw (read-only, no create/edit/persist); file manager; raster pixel queries (getbands/getprofile/getminmax) → Measure's elevation profile and Identifier's true pixel values don't work; SPICE computes; datasets/geodatasets (any geodataset-backed Query); the time-slider histogram. Dynamic data works only via external service URLs — there's no /titiler proxy in static mode, so seed layers must point at reachable public service URLs or they silently won't tile. proj42wkt is the one compute that still works (in-browser).
- Dual config-fetch footgun. The static dashboard answers
get from the baked config but also directly fetches Missions/<mission>/config.json (LandingPage's static branch), which is why publish uploads the baked config to that key too. If missionFolderName ≠ registry name, this dual path bites — the bake normalizes it.
Open questions to resolve in this issue:
- File-driven fork of
publish-static.js (no DB) vs driving the supported DB+API flow — pick the integration path. (File-driven recommended.)
- Accept the ~5–20 min first-publish latency, or warm/reuse stacks somehow.
- One shared preview password vs per-PR passwords.
- Scope the CI OIDC role given CloudFront can't be resource-scoped; decide the TTL-sweep backstop.
PR previews: publish the seed mission as a dashboard per pull request
Summary
When a PR is opened, we want a live, clickable preview of the work. Rather than standing up a full MMGIS instance per PR, the preview is the deployed dashboard — the same artifact MMGIS's publish flow already produces — driven by CI: on PR open/update, CI publishes the seed mission as a dashboard and links it from the PR; on PR close, CI tears that dashboard down using the existing delete path. We reuse the publish boot and teardown MMGIS already has, instead of building new per-PR instance infrastructure.
Motivation
Reviewing tool/plugin work from a diff is slow. A published dashboard per PR lets a reviewer open the branch's rendered app and see the tools. MMGIS already has a publish pipeline that bakes a mission into a static, password-gated dashboard and a teardown that deletes it. Reusing that — instead of inventing per-PR instance plumbing — is far less to build and maintain, much cheaper (static hosting, no running compute per PR), and the teardown story is already solved.
What the preview is (and isn't)
Decisions baked into this issue
Behavior / acceptance
Decision record to ship with this PR
This PR should add a short standalone ADR at
docs/adr/20260617-pr-preview-published-dashboard.md:Implementation sketch — rough guide, written as of
42b9e0adon 2026-06-17; re-verify against latestThe publish boot to reuse:
scripts/publish-static.js— bakes the mission config intosrc/pre/staticConfig.js, runs the theme build + webpack withSERVER=static, thenCloudFormation CreateStackviascripts/lib/cfn-template.js(per-dashboard S3 bucket + CloudFront distribution + password-gate CloudFront Function + OAC), uploads the bundle, and records the CloudFront URL. Driven byMMGIS_DEPLOYMENT_ID+MMGIS_DEPLOYMENT_ACTION(publish/update).infrastructure/ecs/publish-task.json(entrypointnode scripts/publish-static.js). Required env persample.env:MMGIS_DASHBOARDS_PASSWORD,AWS_REGION,MMGIS_PUBLISH_*.The teardown to reuse:
DeleteStacks (today behindDELETE /api/deployments/:id). Factor the empty-bucket + DeleteStack step into something CI can call directly on PR close.Integration gotcha — config source:
publish-static.jscurrently reads the mission config from Postgres by deployment id. For CI we want to publish the committed generated seed without standing up a full admin DB. Cleanest: adaptpublish-static.jsto accept a config file/path (the seed JSON) as an alternative to the DB lookup. Alternative: seed a throwaway DB + deployment row and run it unchanged (heavier).Per-PR identity + lifecycle:
mmgis-dashboard-pr-<N>(the publish IAM is already scoped tommgis-dashboard-*). CreateStack is idempotent on re-run (publish reuses the existing stack for updates).pull_requestworkflow —opened/synchronize→ publish/update,closed→ delete. OIDC deploy role (same pattern asdeploy-lean.yml), runs in the smce-veda staging account. Per-PR concurrency so re-pushes don't race.Auth comes for free:
Depends on the generated seed:
Implementation questions & gotchas (code investigation, 42b9e0a / 2026-06-18)
A full read of the publish/teardown code confirmed the primitives are reusable but the orchestration is welded to Postgres + the Express admin route. The findings:
publish-static.jsis bound to the DB on two axes. It requiresMMGIS_DEPLOYMENT_ID, loads adeploymentsrow (mission name, stack name, final URL, status all come off it), and reads mission config live from theconfigs+generaloptionstables. The baked shape it produces is small and well-defined ({get, missions, get_generaloptions}→staticConfig.js). The key fork: (a) stand up the admin app + DB, insert adeploymentsrow, and drive the supported flow; or (b) forkmain()into a file-driven script that buildsbakedfrom the committed seed JSON, derives the stack name from the PR number, and calls the existingprovision.*helpers directly — this drops the DB, the ECS task, and the admin role entirely. (b) is the lighter, cleaner path and resolves the "config source" gotcha above.SERVER=static(plusbuild:themes, which needsrsync+npx sass, ifdist/isn't prebaked). The publish ECS task is sized 2 vCPU / 8 GB for this; a default GitHub runner (2/7) is near that ceiling — budget 3–8 min and watch for OOM on the webpack step.waitForStackpolls with a 30-minute timeout; a fresh CloudFront distribution realistically reachesCREATE_COMPLETEin ~5–20 min. So first-publish per PR is slow; subsequent pushes should use theupdateaction.updateis cheap but never touches CloudFormation. Re-publish re-bakes, re-uploads, and invalidates/*on the same stack/bucket/URL — noUpdateStackexists in the code. Consequence: a changed template or the dashboard password won't reach an existing preview without delete+recreate. Also: each update fires a full/*invalidation (1000 free/month, then billed; minutes to propagate).provision.emptyBucket+provision.deleteStackare reusable, but the only caller is the lean-gatedDELETE /api/deployments/:idroute, which takes a Sequelize row and reads the bucket name off it (or the stack output). CI needs its own ~10-line teardown that callsprovision.*with a self-tracked stack name. Stacks are taggedmmgis:deployment— useful for a TTL sweep.mmgis-dashboard-<id>); the S3 bucket name is CloudFormation-generated and read back as an output. CI must supply its own unique id (the PR number). The dashboard password is a single shared value baked into the CloudFront Function at create time (not a CFN param) — so all previews share one password unless we template per-PR passwords, and rotating means recreate.iam:PassRole(+ ECS if reusingRunTask). CloudFront create/distribution actions don't support resource-level ARNs, so that grant is effectively account-wide — least-privilege is not fully achievable here.deploy-lean.ymlconcurrency is global).OnFailure: DO_NOTHINGleavesCREATE_FAILED/ROLLBACK_COMPLETEstacks behind; aROLLBACK_COMPLETEstack can't be updated and must be deleted before retry.staticHandlers.js). A published dashboard renders but the following are dead: auth/login; Draw (read-only, no create/edit/persist); file manager; raster pixel queries (getbands/getprofile/getminmax) → Measure's elevation profile and Identifier's true pixel values don't work; SPICE computes; datasets/geodatasets (any geodataset-backed Query); the time-slider histogram. Dynamic data works only via external service URLs — there's no/titilerproxy in static mode, so seed layers must point at reachable public service URLs or they silently won't tile.proj42wktis the one compute that still works (in-browser).getfrom the baked config but also directly fetchesMissions/<mission>/config.json(LandingPage's static branch), which is why publish uploads the baked config to that key too. IfmissionFolderName≠ registry name, this dual path bites — the bake normalizes it.Open questions to resolve in this issue:
publish-static.js(no DB) vs driving the supported DB+API flow — pick the integration path. (File-driven recommended.)