feat(preview): per-PR deployment previews (chart + build pipeline) - #6125
Merged
Conversation
Contributor
🔍 Preview: https://pr-6125.pr.studio.decocms.comBuilt from Sign in: the database is empty and yours — sign up with any email What does not work in a preview
Remove the |
Adds the in-repo half of per-PR preview environments: label a PR `preview` and get a throwaway Studio at pr-<n>.preview.studio.decocms.com with its own namespace, database and bucket, destroyed when the PR closes. Today the only way to see a change running is to merge it — staging is a shared, serialized, post-merge resource, and a non-author has no path short of cloning the monorepo. Chart (0.13.2 -> 0.14.0), everything gated on preview.enabled: - HTTPRoute attaching to a shared wildcard Gateway (one DNS record, one cert; per-host ACME would be an order per PR). - Three Argo sync-hook Jobs: PreSync -10 provision (CREATE DATABASE pr_<n> + mc mb), PreSync 0 migrate, PostDelete teardown (DROP DATABASE ... WITH (FORCE) + mc rb). Hooks rather than an initContainer because only hooks have a PostDelete phase — otherwise databases leak whenever a teardown job does not run. - validatePreview: five render-time guards, each covering a case that would otherwise produce a healthy-looking object that silently does nothing. - values-preview.yaml, rendered and asserted in CI so it cannot rot even though nothing in this repo deploys it. Migrations run in a Job, never in a pod. The default topology is three processes that would race migrateToLatest on a fresh database. Pods run with --skip-migrations and validatePreview fails the render without it. That flag alone is not sufficient: it only skips studio's own migrations, and DBOS still migrates its `dbos` schema on launch, so parallel boots crash on dbos.dbos_migrations unique-constraint violations (see tests/multi-pod/docker-compose.yml). Hence the new migrate-dbos entry point, bundled alongside migrate.js and run by the same Job. Pod topology is deliberately identical to production — nginx front door plus two API containers. Skipping the nginx tier would have been cheaper but needed new chart keys, and a preview running a different topology than prod can miss exactly the class of bug it exists to catch. Verification: - Default render is unchanged: 0 non-`helm.sh/chart` diff lines vs the pre-change baseline. - CI assertions mutation-tested — removing the HTTPRoute, downgrading the PostDelete hook, reverting DATABASE_POOL_MAX, and misindenting the SQL heredoc each go red. - Hook scripts executed against a stub psql; GC script executed against fixtures; bundled migrate-dbos.js runs and fails only on connection refusal. Deploy side (ApplicationSet, Gateway, cert, oauth2-proxy, shared Postgres and MinIO, TTL/orphan sweepers) lands separately in decocms/deco-apps-cd. Prerequisites are documented in deploy/preview/README.md. Nothing here holds a cluster credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Render (preview validations must fail)` step asserts that five bad
configurations FAIL to render. It read the exit status via
`out=$(helm ...); if [ $? -eq 0 ]`, which works under a plain shell but not
under GitHub's default `bash -e {0}`: the first (expected) helm failure aborted
the step before a single assertion ran.
Use `if out=$(helm ...); then` instead — an `if` condition is exempt from
`set -e`. Same reason the neighbouring `grep -c` now has `|| true`: grep exits 1
on zero matches, which would abort before the explanatory message.
Verified by re-running both new steps under `bash -e`, and by re-running the
mutation tests under it: deleting a guard reports "the guard is missing" and
changing a guard's message reports "failed for the wrong reason", both exiting 1.
CI had already proven the templates render under the pinned helm 3.16.4 — the
preceding render step passed; only this step's shell handling was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…allow checkout `actions/checkout` fetches depth 1, so `git merge-base --is-ancestor origin/main HEAD` cannot prove ancestry across the graft boundary and failed every run. The check was speculative to begin with. This workflow triggers on `pull_request` only, where the checkout is refs/pull/N/merge — GitHub builds that ref by merging the PR into main, so "the image contains main ∪ PR migrations" holds by construction. The assertion guarded a workflow_dispatch path that does not exist. Replaced with a comment recording the invariant and what would have to change (fetch-depth: 0) if a non-pull_request trigger is ever added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Confirmed against the real API that `orgs/<org>/packages/container/...` is frequently out of reach for GITHUB_TOKEN — it needs read:packages, which the default token does not carry for org-owned packages. Left unhandled, the nightly janitor either fails every night (and gets muted) or, worse, appears to succeed while pruning nothing. Emit a ::warning:: naming exactly what is not happening and how to fix it (a PAT with read:packages + delete:packages), then exit clean. Verified under `bash -e` with stubs for all three paths: 403 warns and exits 0, 404 (package never pushed) exits 0, happy path still prunes merged/closed PRs and keeps open/unknown ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…BASE_URL) Reading decocms/deco-apps-cd and infra_applications showed four assumptions in the first pass were wrong for the cluster previews will actually run on. 1. DATABASE_URL had no safe home. values-preview.yaml set secret.secretName, which makes the chart render no Secret at all — so DATABASE_URL had to come from that shared Secret, while it is necessarily per-PR (…/pr_<n>). The chart's ExternalSecret only supported dataFrom.extract, so the only way to vary it per preview was configMap.meshConfig, i.e. a database password in git (CONTRIBUTING/CLAUDE gotcha #8). Adds optional `externalSecret.template`, passed through to ESO's target template so DATABASE_URL is COMPOSED from admin parts that stay in Secrets Manager. mergePolicy defaults to Merge, not ESO's Replace default — Replace would silently discard every dataFrom key. The ESO `{{ }}` are not Helm's; values are data, so Helm passes them through untouched. Verified by rendering. 2. That Secret is read by the PreSync provision/migrate Jobs, so it must exist before the Sync phase — a sync-wave cannot express that. In preview mode the ExternalSecret now joins PreSync at weight -20, below both Jobs. 3. Object storage is Cloudflare R2, not MinIO. Points at R2 with all four S3_* set (miss one and the app downloads its own MinIO at boot). Also drops bucket-per-PR for one shared bucket: keys are scoped by ORG id, and an empty-database preview generates fresh org UUIDs, so collisions are impossible. Bucket-per-PR was only mandatory under the golden-template design where previews shared a seeded org id. 4. TLS terminates at the NLB with an ACM cert, not cert-manager DNS-01 — the model the sandbox preview gateway already uses. README corrected, along with the prerequisite list (dedicated preview Postgres, not staging's). preview.dbAdminSecret.name now defaults to the release's own Secret; the required-value check moved to .key, and the CI guard test with it. Default render is still byte-identical to origin/main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chart always created a namespaced SecretStore and referenced it by that kind. A namespaced store needs IRSA wired up per namespace, which a deploy that creates namespaces on the fly — per-PR previews — cannot do. Adds `externalSecret.secretStoreKind` (default SecretStore, unchanged) and `externalSecret.createSecretStore` (default true, unchanged), so a release can point at a pre-existing ClusterSecretStore instead. This is also how the existing deco-studio / deco-studio-stg deploys reference ESO. values-preview.yaml uses it. Default render is still byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit expected the Argo ApplicationSet to pass an ESO template
string containing `{{ .PREVIEW_PG_PASSWORD }}`. That cannot work: the
ApplicationSet is ITSELF a Go template, so Argo evaluates the string before ESO
ever sees it and fails outright under goTemplateOptions missingkey=error.
Escaping it from Helm was not enough — it also had to survive Argo.
The chart now builds the placeholders itself (`preview.databaseUrl`), so the
caller passes only `preview.prNumber` — a plain integer that no templating
layer can choke on. Verified end to end: the rendered ExternalSecret carries
literal `{{ .PREVIEW_PG_* }}` alongside this release's own pr_42 database name,
and the deco-apps-cd ApplicationSet renders with no brace conflict.
Adds CI assertions with teeth (both mutation-tested): DATABASE_URL must be an
ESO template rather than a resolved string — the rendered manifest lands in a
GitOps repo, so a materialised password would be a credential in git — it must
name this PR's database, mergePolicy must be Merge, and the store must be a
ClusterSecretStore with no namespaced SecretStore created.
Default render is still byte-identical to origin/main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ared server Per Nicácio: run a small Postgres pod alongside the preview so it dies with it, rather than provisioning an RDS. This is a strictly better trade and it deletes more than it adds. Gone: the provision Job (CREATE DATABASE + bucket), the teardown Job (DROP DATABASE ... WITH (FORCE) + bucket removal), preview.dbAdminSecret, preview.databaseUrl and its ESO composition helpers, preview.objectStorage bucket lifecycle, and the mc image. With them go the problems they existed to solve — a server paid for while idle, an admin credential distributed to every preview namespace, a connection budget shared across previews, one preview's runaway migration locking the others, and databases outliving their PR. Deleting the namespace now deletes the database. DATABASE_URL stops being a secret as a result: it addresses an ephemeral pod reachable only from inside its own namespace, so the chart derives it and publishes it via the ConfigMap. That also fixes a latent bug in the previous approach — with externalSecret enabled the chart renders no Secret at all, so a Secret-only DATABASE_URL would simply have gone missing. Ordering moves from hooks to sync-waves, because Postgres is a Sync-phase resource and a PreSync Job would run before it exists: ExternalSecret (-30) → Postgres (-20) → migrate Job (-10, now a Sync hook) → app Deployments (0). Argo holds each wave until healthy, and for a Job that means completed. Deliberately a template, not a subchart dependency. The chart already vendors four, each of which earns it; a single-replica throwaway Postgres does not need an upstream chart's replication/backup/PDB surface, and adding one would mean a vendored .tgz, a Chart.lock entry and another image supply chain. Say the word and I will swap it for bitnami/postgresql. CI now asserts the wave ordering numerically (postgres < migrate < app) — a wrong wave surfaces as a CrashLoopBackOff on first sync, not a render error — and that DATABASE_URL points at this preview's own Postgres. Default render is still byte-identical to origin/main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Long-lived PRs accumulating forgotten previews is the failure mode this closes. A 72h TTL was documented from the start but never actually implemented; this implements it, at 48h. preview-ttl.yaml (every 2h) removes the `preview` label from PRs whose preview has not been redeployed within the TTL. The ApplicationSet generates from labelled open PRs, so dropping the label makes the preview disappear on the next poll — namespace, Postgres pod and all. Removing the LABEL rather than deleting the Application is the whole trick: a directly-deleted Application is regenerated within ~60s, because the generator's source of truth is the GitHub API and not the cluster. Age is measured from the sticky preview comment, which preview-build.yaml rewrites on every deploy. That is the only signal that means "when was this preview last actually built": the PR's updatedAt moves on any comment, and the head-commit date would instantly expire a months-old PR that someone labelled a minute ago to review. A preview with no comment is never reaped — the same never-reap-on-ambiguity rule the image GC uses for PRs it cannot resolve. Verified by extracting the step and running it under `bash -e` against stubbed gh/date: fresh kept, stale expired with the label removed and the comment rewritten in place (so a dead URL is not left advertised), missing-comment kept, 47h kept / 48h and 49h expired, dry-run mutating nothing, empty PR list exiting clean. Confirmed preview-build does NOT trigger on `unlabeled`, so expiry cannot cause a rebuild loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview control plane lived in decocms/deco-apps-cd while the workflow that
builds the images lived here. Two halves, two CI suites, neither able to see the
other — so the two strings they must agree on silently disagreed:
- the build tagged `git rev-parse HEAD` on a `pull_request` checkout, which is
the ephemeral refs/pull/N/merge commit. The generator can only name
`.head_sha`. The tags never matched, so every preview would have sat in
ImagePullBackOff with both repositories green.
- the build advertised preview.studio.decocms.com while the generator served
pr.studio.decocms.com, so the bot comment linked to a host that never existed.
Move the ApplicationSet and the Gateway here as `deploy/helm/studio-previews`
and assert both contracts in helm-test.yml. A separate chart, not a block in
chart-deco-studio: the application chart must not carry an argoproj.io CRD a
plain `helm install` cannot resolve, a preview must not be able to render the
ApplicationSet that generates previews, and publish-chart.yml dispatches a chart
bump downstream — so preview policy would otherwise roll production Studio.
This repository is public and the chart is published, so the previews chart
ships no deployment-specific defaults (validations.yaml fails the render on any
missing one), values-preview.yaml no longer names an account, bucket or secret
store, and a .helmignore keeps it out of the packaged tarball. helm-test.yml
greps the file for identifiers so it cannot creep back.
Document the capability where self-hosters read, in both languages, including
that it is off by default and that nothing authenticates the URL.
Default render is unchanged: `helm template` against origin/main differs by zero
lines outside helm.sh/chart.
… in CI The release and namespace read as studio-pr-<n>, but the URL is pr-<n>.<domain> — reusing one prefix for both produced studio-pr-<n>.pr.studio.decocms.com. Also strengthen the cross-half assertion: render the ApplicationSet with the domain the build workflow advertises and compare the resulting host string for the same PR number, instead of only checking that each side is non-empty.
It still promised three sync-hook Jobs creating and dropping a per-PR database on a shared server. That was replaced by an ephemeral Postgres per namespace and a single migration Job.
Previews run un-merged code from any labelled PR, so they get their own Karpenter node pool (terraform-eks-cluster, karpenter_ec2_preview) rather than sharing capacity with sites, the Studio control plane or sandboxes. Spot-only is safe here and nowhere else on that cluster: the database is an emptyDir Postgres the PreSync Job re-migrates, and a preview holds no PVC. preview-postgres and preview-migrate-job did not honour nodeSelector or tolerations at all, and the NATS subchart inherits neither from the parent — so three of the five pods would have kept landing on shared nodes while the app moved, which is worse than not separating at all. CI now asserts all five.
The taint key would otherwise read as the same thing as the unrelated `decocms.com/preview` namespace label that the Gateway selects routes by, and this cluster already has a "preview" in the sandbox gateway sense.
decocms.com/nodepool and decocms.com/studio-preview are domain-prefixed Kubernetes keys — structure, not identity. Match the domains only where they are a hostname (not followed by /), and print the offending lines on failure so the next false positive is one glance instead of a bisect.
secretPath and secretStoreName left values-preview.yaml when it stopped naming an account, so validateExternalSecret now fires before validatePreview and every expect_fail case failed for the wrong reason. The positive render step already passed them; the negative one did not.
A shared bucket means a credential distributed to every preview namespace, a bucket or prefix to create and destroy per PR, and a sweeper for whatever outlives its PR — the same set of problems that moved the database in-namespace earlier in this PR. A MinIO pod per preview has none of them: deleting the namespace deletes the storage. Deliberately not the application's own auto-provisioned MinIO. Studio downloads a MinIO binary when S3_* is unset, which is a `deco dev` convenience and wrong three times here: it re-downloads on every pod start (this pool is spot, with 1m consolidation), it puts dl.min.io in the boot path of every preview, and it assumes one process — the preview pod runs two API containers that would race for the same port and data directory. The bucket is created by an initContainer running mkdir: MinIO in filesystem mode treats each top-level directory as a bucket, so that avoids a second image, a second sync hook, and running a client before the server accepts connections. Credentials are ConfigMap and not Secret for the same reason DATABASE_URL is: they address an ephemeral pod reachable only from this namespace. Drops objectStorage from the previews chart entirely — no endpoint, no bucket, no credential crosses a namespace boundary now. CI asserts all six S3_* keys render together, because a partial config is what makes the app self-provision.
The pullRequest generator authenticates with a Secret in the Argo CD namespace. Creating it by hand contradicts the convention that secrets come from a store, and leaves nothing recording where the value came from. Note the expiry trap documented on the template: a lapsed token does not tear anything down. The generator stops listing PRs, so the ApplicationSet can no longer see that a PR closed, and every open preview keeps running unattended.
Second time a new guard broke a render step that had its own hand-written list of --set flags. The chart ships no deployment-specific defaults on purpose, so every guard adds a mandatory value; keeping the list in one array means the next one does not fail a test about something it never meant to exercise. Also drops two --set flags for studioValues.objectStorage, which stopped existing when storage moved in-namespace.
nicacioliveira
force-pushed
the
plan-pr-deploy-previews
branch
from
August 18, 2026 18:49
637ef1a to
108d3ea
Compare
decocms Bot
pushed a commit
that referenced
this pull request
Aug 18, 2026
PR: #6125 feat(preview): per-PR deployment previews (chart + build pipeline) Bump type: minor - decocms (apps/api/package.json): 4.227.8 -> 4.228.0 - @decocms/native (apps/native/package.json): 4.227.8 -> 4.228.0 Deploy-Scope: server
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this contribution about?
Adds the in-repo half of per-PR preview environments. Label a PR
previewand get a throwaway Studio athttps://pr-<n>.preview.studio.decocms.com— its own namespace, database and bucket, all destroyed when the PR closes.Today the only way to see a change running is to merge it. Staging is a shared, serialized, post-merge resource, so two people landing changes the same afternoon share one environment and one blast radius; a non-author (design, PM, another engineer) has no path at all short of cloning the monorepo.
Zero change to the release path —
release-tagging.yamland the release jobs are untouched apart from one addedtest -flayout assertion.Chart (
0.13.2→0.14.0), everything gated onpreview.enabledhttproute.yaml— attaches to a shared wildcard Gateway. Per-host cert-managerCertificates would mean an ACME order per PR: rate-limit risk plus a 10–60s ACME dance in front of the reviewer's first click.postgres:16-alpine, emptyDir) that lives and dies with the namespace, plus a single migration Job. Ordered by sync-waves: ExternalSecret (-30) → Postgres (-20) → migrate (-10) → app Deployments (0).validatePreview— five render-time guards, each covering a case that otherwise renders a healthy-looking object that silently does nothing.values-preview.yaml— rendered and asserted in CI so it can't rot even though nothing in this repo deploys it.Each preview brings its own database
Per review feedback (thanks Nicácio), previews don't carve a database out of a shared server — each runs its own Postgres pod. That deleted more than it added: gone are the provision/teardown Jobs,
dbAdminSecret, the ESODATABASE_URLcomposition, and the bucket lifecycle — and with them a server paid for while idle, an admin credential in every preview namespace, a shared connection budget, cross-preview blast radius, and orphaned databases.It also makes
DATABASE_URLnot-a-secret (an ephemeral pod reachable only from inside its namespace), which fixes a latent bug in the previous approach: with ESO enabled the chart renders no Secret at all, so a Secret-onlyDATABASE_URLwould have gone missing entirely.Previews are short-lived
preview-ttl.yaml(every 2h) removes thepreviewlabel from PRs whose preview hasn't been redeployed in 48h, so a forgotten long-lived PR can't leave an environment running. Pushing, or re-adding the label, resets the clock.It removes the label, not the Application — a deleted Application is regenerated within ~60s, since the generator reads the GitHub API, not the cluster. Age comes from the sticky comment (rewritten on every deploy), because the PR's
updatedAtmoves on any comment and the head-commit date would instantly expire a months-old PR someone just labelled to review.Migrations run in a Job, never in a pod
The default topology is 2 API containers + a worker: three processes racing
migrateToLateston a fresh DB. Pods run--skip-migrations, andvalidatePreviewfails the render without it.That flag alone is not sufficient. It only skips studio's own migrations — DBOS still migrates its
dbosschema onDBOS.launch(), and parallel boots crash ondbos.dbos_migrationsunique-constraint violations, exactly astests/multi-pod/docker-compose.ymldocuments. Hence the newmigrate-dbosentry point, bundled alongsidemigrate.jsand run by the same Job.One decision worth flagging
Pod topology is deliberately identical to production — nginx front door + two API containers. The cheaper path was adding
nginx.enabled/apiContainersPerPodkeys and running a slimmer preview. I chose not to: building the nginx image costs ~1 extra minute, and a preview running a different topology than prod can miss exactly the class of bug it exists to catch. This also removed two invasive changes to a shared production chart.How did you verify your code works?
Everything below was executed, not inspected.
Default render is unchanged —
helm templateof this branch vsorigin/maingives 0 non-helm.sh/chartdiff lines. That label is deliberately kept off pod templates (seechart-deco-studio.podLabels), so the version bump rolls nothing.The new CI assertions were mutation-tested — each of these turns the build red, and the clean tree is green:
preview render has no HTTPRoutePostDelete→Syncteardown Job is not a PostDelete hook — databases would leakDATABASE_POOL_MAXback to20DATABASE_POOL_MAX is not 5--skip-migrationsdropped from valuesvalidatePreviewfails the rendersh -ncheckpostgres < migrate < appcheckOther checks:
validatePreviewguards fail with the correct message (asserted on message text, not just exit code — mirroring the existingenvName is requiredpattern in this workflow).sh -n'd, then executed against a stubpsql— confirms the heredoc terminates and emits exactly the intended\gexecSQL.gh: merged/closed pruned, open kept, unknown kept (never delete an image someone may be looking at), malformed tags skipped. Rewritten withoutdeclare -Aso it's portable and locally testable.bun run build:serveremitsmigrate-dbos.js; the bundled artifact runs, resolves all externals, reachesensureSystemDatabase, and exits 1 on connection refusal — so it's a real connection failure, not a module-resolution failure.bun run check,bun run lint(0 errors),bun run fmt,helm lint, and the pre-existing helm-test studio steps all pass after rebasing onto current main.How to Test
This PR alone does not produce a preview — see Migration Notes. To review it:
helm template deco-studio deploy/helm/studio --set database.url=postgresql://ci:ci@x:5432/sand diff against the same onmain. Onlyhelm.sh/chartshould differ.--skip-migrationson all three studio containers, and no PVCs.--skip-migrations, flip the teardown hook) and re-run theRender (per-PR preview)step fromhelm-test.yml— it should go red.Migration Notes
Not deployable on its own. The other half lives in
decocms/deco-apps-cdand must land before any preview works:labels: ["preview"]) — includingsyncPolicy.managedNamespaceMetadatastudio-previewGateway + wildcardCertificatefor*.preview.studio.decocms.com(DNS-01), on a different domain from the sandbox GatewayAuthorizationPolicygating ondecocmsorg membershipstudio-preview-sharedSecret, TTL + orphan-sweeper CronJobsFull prerequisites and troubleshooting:
deploy/preview/README.md.Two things that will bite whoever does that half:
managedNamespaceMetadatais the easiest thing to miss. The Gateway'sallowedRoutesselects on namespace labels, and Argo'sCreateNamespace=truedoesn't set them. Without it every preview HTTPRoute attaches to nothing and reports no error anywhere.ENCRYPTION_KEYandBETTER_AUTH_SECRETmust never rotate for the life of the environment — a rotating auth secret is a login loop, a rotating encryption key makes every vaulted credential undecryptable.No database migrations. Nothing in this repo gains a cluster credential.
Follow-ups (not in this PR)
apps/api/src/auth/org.ts:72bakesgetBaseUrl()intomcp_connections.connection_urlat write time, so any productionBASE_URLchange strands every org's self connection permanently — migrations027/028/036exist to chase exactly this. The fix is read-time resolution, mirroringcreateDevAssetsConnectionEntityintools/connection/{get,list}.ts. Worth doing on its own merits, and a prerequisite for a golden-DB template.ENCRYPTION_KEY, and the fix above.Review Checklist
🤖 Generated with Claude Code
Summary by cubic
Adds per-PR deployment previews so reviewers can visit pr-. before merge. Previously changes were only visible after merging to shared staging; now adding the
previewlabel deploys an isolated namespace with its own Postgres and MinIO and runs migrations once.chart-deco-studio-previews: renders an Argo CDApplicationSet(GitHub PR generator gated bypreview), a shared wildcardGateway, and optionally syncs a GitHub token viaExternalSecret. If the token expires, previews stop updating but are not torn down.chart-deco-studio0.14.0 (gated bypreview.*): adds a per-PRHTTPRoute, in-namespace Postgres and MinIO, and a single migrationJobrunningmigrate.js+ newmigrate-dbos.js; app pods run with--skip-migrations.DATABASE_URLandS3_*are derived from the ephemeral services. Validations enforce required flags and sync‑wave ordering (ExternalSecret −30 → Postgres −20 → migrate −10 → app 0).nodeSelector/tolerations; the taint/selector key isstudio-preview.preview-buildpublishes images taggedpr-<n>-<sha>and posts a sticky URL;preview-ttlremoves the label after 48h of inactivity;preview-gcprunes closed‑PR tags and handles 403s; helm tests validate previews and the tag/host contract; release assertsmigrate-dbos.jsis bundled. Docs updated;values-preview.yamlis excluded from packaged charts.Rollout
chart-deco-studio-previewsin Argo CD and configure domain, image repositories, and a secret store for the generator token; ensuremanagedNamespaceMetadatalabels namespaces so previewHTTPRoutes attach to the Gateway.studio-preview.Written for commit 108d3ea. Summary will update on new commits.