Skip to content

feat(preview): per-PR deployment previews (chart + build pipeline) - #6125

Merged
nicacioliveira merged 19 commits into
mainfrom
plan-pr-deploy-previews
Aug 18, 2026
Merged

feat(preview): per-PR deployment previews (chart + build pipeline)#6125
nicacioliveira merged 19 commits into
mainfrom
plan-pr-deploy-previews

Conversation

@vibe-dex

@vibe-dex vibe-dex commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What is this contribution about?

Adds the in-repo half of per-PR preview environments. Label a PR preview and get a throwaway Studio at https://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 pathrelease-tagging.yaml and the release jobs are untouched apart from one added test -f layout assertion.

Chart (0.13.20.14.0), everything gated on preview.enabled

  • httproute.yaml — attaches to a shared wildcard Gateway. Per-host cert-manager Certificates would mean an ACME order per PR: rate-limit risk plus a 10–60s ACME dance in front of the reviewer's first click.
  • A Postgres per preview (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 ESO DATABASE_URL composition, 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_URL not-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-only DATABASE_URL would have gone missing entirely.

Previews are short-lived

preview-ttl.yaml (every 2h) removes the preview label 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 updatedAt moves 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 migrateToLatest on a fresh DB. Pods run --skip-migrations, and validatePreview fails the render without it.

That flag alone is not sufficient. It only skips studio's own migrations — DBOS still migrates its dbos schema on DBOS.launch(), and parallel boots crash on dbos.dbos_migrations unique-constraint violations, exactly as tests/multi-pod/docker-compose.yml documents. Hence the new migrate-dbos entry point, bundled alongside migrate.js and 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 / apiContainersPerPod keys 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 unchangedhelm template of this branch vs origin/main gives 0 non-helm.sh/chart diff lines. That label is deliberately kept off pod templates (see chart-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:

Mutation Caught by
HTTPRoute removed preview render has no HTTPRoute
Teardown downgraded PostDeleteSync teardown Job is not a PostDelete hook — databases would leak
DATABASE_POOL_MAX back to 20 DATABASE_POOL_MAX is not 5
--skip-migrations dropped from values validatePreview fails the render
SQL heredoc terminator misindented embedded-script sh -n check
Sync-waves reordered numeric postgres < migrate < app check

Other checks:

  • All five validatePreview guards fail with the correct message (asserted on message text, not just exit code — mirroring the existing envName is required pattern in this workflow).
  • Hook shell scripts extracted from the render, sh -n'd, then executed against a stub psql — confirms the heredoc terminates and emits exactly the intended \gexec SQL.
  • GC script extracted from the workflow and executed against fixtures with a stub gh: merged/closed pruned, open kept, unknown kept (never delete an image someone may be looking at), malformed tags skipped. Rewritten without declare -A so it's portable and locally testable.
  • bun run build:server emits migrate-dbos.js; the bundled artifact runs, resolves all externals, reaches ensureSystemDatabase, 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:

  1. helm template deco-studio deploy/helm/studio --set database.url=postgresql://ci:ci@x:5432/s and diff against the same on main. Only helm.sh/chart should differ.
  2. Render a preview:
    helm template studio-pr-42 deploy/helm/studio \
      -f deploy/helm/studio/values-preview.yaml \
      --set preview.prNumber=42 --set preview.host=pr-42.preview.studio.decocms.com \
      --set image.tag=pr-42-abc1234 --set nginx.image.tag=pr-42-abc1234 \
      --set database.url=postgresql://ci:ci@pg:5432/pr_42 \
      --api-versions gateway.networking.k8s.io/v1
    Expect an HTTPRoute, three Jobs with the right hooks, --skip-migrations on all three studio containers, and no PVCs.
  3. Break something (drop --skip-migrations, flip the teardown hook) and re-run the Render (per-PR preview) step from helm-test.yml — it should go red.

Migration Notes

Not deployable on its own. The other half lives in decocms/deco-apps-cd and must land before any preview works:

  • ApplicationSet with the GitHub PR generator (labels: ["preview"]) — including syncPolicy.managedNamespaceMetadata
  • studio-preview Gateway + wildcard Certificate for *.preview.studio.decocms.com (DNS-01), on a different domain from the sandbox Gateway
  • oauth2-proxy / Istio AuthorizationPolicy gating on decocms org membership
  • Shared preview Postgres + MinIO, the studio-preview-shared Secret, TTL + orphan-sweeper CronJobs

Full prerequisites and troubleshooting: deploy/preview/README.md.

Two things that will bite whoever does that half:

  1. managedNamespaceMetadata is the easiest thing to miss. The Gateway's allowedRoutes selects on namespace labels, and Argo's CreateNamespace=true doesn't set them. Without it every preview HTTPRoute attaches to nothing and reports no error anywhere.
  2. ENCRYPTION_KEY and BETTER_AUTH_SECRET must 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)

  • Latent prod bug found on the way: apps/api/src/auth/org.ts:72 bakes getBaseUrl() into mcp_connections.connection_url at write time, so any production BASE_URL change strands every org's self connection permanently — migrations 027/028/036 exist to chase exactly this. The fix is read-time resolution, mirroring createDevAssetsConnectionEntity in tools/connection/{get,list}.ts. Worth doing on its own merits, and a prerequisite for a golden-DB template.
  • Previews start with an empty database (reviewer signs up). A golden template would be nicer but needs a nightly rebuild job, a fixed shared ENCRYPTION_KEY, and the fix above.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working
  • Documentation is updated (if needed)
  • No breaking changes

🤖 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 preview label deploys an isolated namespace with its own Postgres and MinIO and runs migrations once.

  • New control-plane chart chart-deco-studio-previews: renders an Argo CD ApplicationSet (GitHub PR generator gated by preview), a shared wildcard Gateway, and optionally syncs a GitHub token via ExternalSecret. If the token expires, previews stop updating but are not torn down.
  • App chart chart-deco-studio 0.14.0 (gated by preview.*): adds a per-PR HTTPRoute, in-namespace Postgres and MinIO, and a single migration Job running migrate.js + new migrate-dbos.js; app pods run with --skip-migrations. DATABASE_URL and S3_* are derived from the ephemeral services. Validations enforce required flags and sync‑wave ordering (ExternalSecret −30 → Postgres −20 → migrate −10 → app 0).
  • Scheduling: all preview pods (API, nginx, worker, NATS, Postgres, MinIO, migration job) pin to a dedicated spot node pool via nodeSelector/tolerations; the taint/selector key is studio-preview.
  • CI/workflows: preview-build publishes images tagged pr-<n>-<sha> and posts a sticky URL; preview-ttl removes the label after 48h of inactivity; preview-gc prunes closed‑PR tags and handles 403s; helm tests validate previews and the tag/host contract; release asserts migrate-dbos.js is bundled. Docs updated; values-preview.yaml is excluded from packaged charts.

Rollout

  • No change for existing installs; previews are off by default.
  • To enable, install chart-deco-studio-previews in Argo CD and configure domain, image repositories, and a secret store for the generator token; ensure managedNamespaceMetadata labels namespaces so preview HTTPRoutes attach to the Gateway.
  • If you previously used a “preview” taint key, rename it to studio-preview.

Written for commit 108d3ea. Summary will update on new commits.

Review in cubic

@github-actions github-actions Bot added the claude PR authored by a coding agent label Aug 14, 2026
@vibe-dex vibe-dex added the preview Deploy a per-PR preview environment label Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🔍 Preview: https://pr-6125.pr.studio.decocms.com

Built from pr-6125-108d3ea. Argo CD picks the image up
within ~60s; the first sync also creates and migrates the database, so
allow another minute or two on a brand-new preview.

Sign in: the database is empty and yours — sign up with any email
and password. It is thrown away when this PR closes.

What does not work in a preview
  • Agent tool execution against a hosted sandbox — previews run
    STUDIO_SANDBOX_PROVIDER=user-desktop with no daemon attached, so
    dispatch returns 409 link_offline. Sandbox previews and the
    sandbox lifecycle UI are equally out.
  • AI features until you add your own provider key in org settings.
    Previews ship no key, so preview LLM spend is zero by construction.
  • Google / GitHub sign-in — OAuth callbacks cannot be registered
    for a per-PR hostname. The buttons are hidden.
  • Monitoring dashboard (no ClickHouse), billing (no Stripe),
    outbound email (no mail provider).
  • Multi-pod behaviour — a preview is one pod. Do not conclude
    "it worked in preview" about a distributed-systems change; that is
    what tests/multi-pod/ is for.

Remove the preview label to tear this down now. Previews expire
48h after their last deploy — push, or re-add the label, to
reset the clock.

vibe-dex and others added 19 commits August 18, 2026 15:48
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
nicacioliveira force-pushed the plan-pr-deploy-previews branch from 637ef1a to 108d3ea Compare August 18, 2026 18:49
@nicacioliveira
nicacioliveira merged commit 42cc928 into main Aug 18, 2026
31 checks passed
@nicacioliveira
nicacioliveira deleted the plan-pr-deploy-previews branch August 18, 2026 19:27
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent preview Deploy a per-PR preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants