Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f910368
feat(preview): per-PR deployment previews (chart + build pipeline)
vibe-dex Aug 14, 2026
f34ebc4
fix(ci): make the preview validation step survive the runner's `bash -e`
vibe-dex Aug 14, 2026
b9f2096
fix(preview): drop the merge-ref assertion, which cannot work on a sh…
vibe-dex Aug 14, 2026
eb86e59
fix(preview): handle a 403 from the org packages API in preview GC
vibe-dex Aug 14, 2026
cea2fd8
fix(preview): match the real target infra (R2, ACM/NLB, composed DATA…
vibe-dex Aug 14, 2026
e982ff3
feat(chart): let externalSecret reference a ClusterSecretStore
vibe-dex Aug 14, 2026
284d92e
fix(preview): compose the ESO DATABASE_URL chart-side, not caller-side
vibe-dex Aug 14, 2026
126db8c
refactor(preview): give each preview its own Postgres instead of a sh…
vibe-dex Aug 14, 2026
2bfaf0f
feat(preview): expire previews 48h after their last deploy
vibe-dex Aug 14, 2026
ae2deba
refactor(preview): own the ApplicationSet, fix the tag and host contract
nicacioliveira Aug 18, 2026
8ea370c
fix(preview): separate host prefix from release prefix, compare hosts…
nicacioliveira Aug 18, 2026
92283b8
docs(chart): Chart.yaml 0.14.0 note described the superseded design
nicacioliveira Aug 18, 2026
9923081
feat(preview): pin every preview pod to the dedicated spot node pool
nicacioliveira Aug 18, 2026
902132c
refactor(preview): node pool is studio-preview, not preview
nicacioliveira Aug 18, 2026
a6ad8d1
fix(ci): leak check flagged label keys as deployment identifiers
nicacioliveira Aug 18, 2026
028b4d7
fix(ci): give the negative render cases what the ApplicationSet supplies
nicacioliveira Aug 18, 2026
111bac6
feat(preview): object storage lives in the namespace, like the database
nicacioliveira Aug 18, 2026
31c00a3
feat(previews): sync the generator token from a secret store
nicacioliveira Aug 18, 2026
108d3ea
fix(ci): collect the previews chart's required values in one place
nicacioliveira Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions .github/workflows/helm-test.yml

Large diffs are not rendered by default.

212 changes: 212 additions & 0 deletions .github/workflows/preview-build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
name: Preview build

# Builds the two images for a per-PR preview environment and posts (or edits)
# the sticky PR comment carrying the URL.
#
# Deliberately a separate workflow rather than a workflow_call refactor of
# release-studio.yaml: that file is release-consistency logic (npm version
# probing, GHCR existence checks, provenance, the deco-apps-cd dispatch gate,
# the docs webhook), and parameterising it would thread ~8 booleans through
# every `if:` in the PRODUCTION release path. The genuinely shared surface is
# one command (`bun run build:studio`) and two Dockerfiles, already reusable
# as-is — so previews cannot destabilise shipping.
#
# What deploys the images is Argo CD, in decocms/deco-apps-cd: an ApplicationSet
# with a GitHub PR generator, also gated on the `preview` label. Nothing here
# holds a cluster credential.

on:
pull_request:
types: [opened, synchronize, reopened, labeled]

concurrency:
group: preview-build-${{ github.event.pull_request.number }}
cancel-in-progress: true

env:
REGISTRY: ghcr.io
# Separate GHCR packages from the release ones, so the preview GC can be
# aggressive with zero chance of deleting a release tag.
IMAGE_NAME: ${{ github.repository }}/studio-preview
NGINX_IMAGE_NAME: ${{ github.repository }}/studio-nginx-preview
PREVIEW_DOMAIN: pr.studio.decocms.com

jobs:
build:
name: Build preview images
# Opt-in. `labeled` is in the trigger list so adding the label to an
# already-open PR starts a build; the ApplicationSet enforces the same
# label independently, so neither half alone can create an orphan.
if: contains(github.event.pull_request.labels.*.name, 'preview')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
pull-requests: write
steps:
# This workflow triggers on `pull_request` ONLY, so the checkout is
# refs/pull/N/merge — GitHub constructs it by merging the PR into main,
# meaning the image always contains main ∪ PR migrations. That invariant
# is load-bearing (Kysely's strict missing-migration check hard-fails a
# boot against a database that knows a migration the image lacks) but it
# holds by construction, so there is nothing to assert. If a
# workflow_dispatch trigger is ever added, it breaks and must be
# re-established — a shallow checkout cannot prove ancestry, so that
# would need `fetch-depth: 0`.
#
# Note the split between CONTENT and NAME: the image content comes from
# the merge ref, but the tag names the PR head sha, because that is the
# only commit both this workflow and the Argo pullRequest generator can
# refer to.
- uses: actions/checkout@v4

- id: meta
run: |
# The 7-char prefix of the PR HEAD sha, NOT `git rev-parse HEAD`.
# The checkout below lands on refs/pull/N/merge, whose HEAD is an
# ephemeral merge commit that the Argo pullRequest generator has no
# parameter for — it can only name `.head_sha`. Tagging from the merge
# commit produces an image the ApplicationSet can never ask for, and
# every preview sits in ImagePullBackOff. Asserted in helm-test.yml.
echo "tag=pr-${{ github.event.pull_request.number }}-$(echo '${{ github.event.pull_request.head.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT"
echo "host=pr-${{ github.event.pull_request.number }}.${PREVIEW_DOMAIN}" >> "$GITHUB_OUTPUT"

# The hidden marker is what makes this comment sticky: find it first, then
# edit in place. Without the lookup every push would append a new comment.
- name: Find existing preview comment
id: find-comment
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: <!-- studio-preview -->

# Posted before the build so a reviewer sees "building" at t+15s rather
# than nothing for seven minutes. Edited in place when the images land.
- name: Comment (building)
uses: peter-evans/create-or-update-comment@v4
continue-on-error: true
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
<!-- studio-preview -->
🔨 **Building preview** for `${{ steps.meta.outputs.tag }}`…

- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun and install dependencies
uses: ./.github/actions/setup-bun

# No sourcemaps (nothing un-minifies preview stacks), no npm publish, no
# provenance, no tarball smoke test. Same `build:studio` and same
# `bun add /tmp/decocms.tgz` install as a release, so preview packaging
# cannot drift from production packaging.
- name: Build combined Studio distribution
run: bun run build:studio
env:
BUILD_SOURCEMAPS: "0"

- name: Strip sourcemaps and pack
run: |
find apps/web/dist apps/api/dist -name '*.map' -delete 2>/dev/null || true
cd apps/api && npm pack

- name: Stage the tarball for both build contexts
run: |
mkdir -p docker-context
mv apps/api/decocms-*.tgz docker-context/decocms.tgz
# apps/web/Dockerfile builds from the repo root because it also COPYs
# deploy/helm/studio/files/api-nginx.conf.
cp docker-context/decocms.tgz decocms.tgz

- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3

# amd64 only — the preview cluster is amd64, and the release workflow's
# own comments record that emulated arm64 costs ~64s vs ~13s native for
# the tarball-install layer. `cache-from` also reads the release scopes so
# the apt / useradd / duckdb layers are hits on a cold preview.
- name: Build and push API image
uses: docker/build-push-action@v5
with:
context: ./docker-context
file: ./apps/api/Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
cache-from: |
type=gha,scope=studio-preview-amd64
type=gha,scope=studio-api-amd64
cache-to: type=gha,mode=max,scope=studio-preview-amd64

- name: Build and push nginx image
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: ${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
cache-from: |
type=gha,scope=studio-preview-web-amd64
type=gha,scope=studio-web-amd64
cache-to: type=gha,mode=max,scope=studio-preview-web-amd64

# Re-find: on the first run the "building" step created the comment, so
# the id from before the build is empty.
- name: Re-find preview comment
id: find-comment-ready
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: <!-- studio-preview -->

- name: Comment (ready)
uses: peter-evans/create-or-update-comment@v4
continue-on-error: true
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment-ready.outputs.comment-id }}
edit-mode: replace
body: |
<!-- studio-preview -->
### 🔍 Preview: https://${{ steps.meta.outputs.host }}

Built from `${{ steps.meta.outputs.tag }}`. 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.

<details><summary>What does <b>not</b> work in a preview</summary>

- **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.

</details>

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.
113 changes: 113 additions & 0 deletions .github/workflows/preview-gc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Preview GC

# GHCR has no TTL, so preview image tags accumulate forever without this.
# Deletes every `pr-<n>-<sha>` tag whose pull request is closed, then applies a
# retention floor to whatever survives.
#
# Scoped to the *-preview packages only. Release tags live in different
# packages (studio/studio, studio/studio-nginx) and are never reachable from
# here, which is the whole reason previews got their own packages.

on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:

permissions:
contents: read
packages: write
pull-requests: read

jobs:
gc:
name: Prune preview image tags
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: [studio/studio-preview, studio/studio-nginx-preview]
steps:
- name: Delete tags for closed PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORG: ${{ github.repository_owner }}
PACKAGE: ${{ matrix.package }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# %2F — the package name contains a slash and must be path-escaped.
ENCODED=$(printf '%s' "$PACKAGE" | sed 's#/#%2F#g')

if ! gh api "orgs/${ORG}/packages/container/${ENCODED}/versions?per_page=100" \
--paginate > /tmp/versions.json 2>/tmp/api-err; then
# A package that has never been pushed 404s. That is the expected
# state before the first preview, not a failure.
if grep -q "Not Found" /tmp/api-err; then
echo "Package ${PACKAGE} does not exist yet — nothing to prune."
exit 0
fi
# The org-level packages API is frequently out of reach for
# GITHUB_TOKEN. Warn loudly and exit clean: a janitor that pages
# nightly gets muted, and a silent skip would read as "pruned".
if grep -q "read:packages scope\|Forbidden\|403" /tmp/api-err; then
echo "::warning::Cannot list ${PACKAGE}: GITHUB_TOKEN lacks org package read."
echo "::warning::Preview image tags are NOT being pruned. Grant a PAT with"
echo "::warning::read:packages + delete:packages as secrets.PREVIEW_GC_TOKEN"
echo "::warning::and set GH_TOKEN to it, or prune manually."
exit 0
fi
cat /tmp/api-err >&2
exit 1
fi

jq -r '.[] | . as $v | ($v.metadata.container.tags // [])[] | "\($v.id) \(.)"' \
/tmp/versions.json > /tmp/tags.txt

# Resolve each PR's state ONCE, not once per tag: a PR routinely has
# a dozen tags and the API is rate-limited.
awk '{ n = $2; sub(/^pr-/, "", n); sub(/-.*$/, "", n);
if (n ~ /^[0-9]+$/) print n }' /tmp/tags.txt | sort -u > /tmp/prs.txt

: > /tmp/closed.txt
while read -r pr_number; do
[ -n "$pr_number" ] || continue
state=$(gh pr view "$pr_number" --repo "$REPO" --json state --jq .state 2>/dev/null || echo UNKNOWN)
echo "PR #${pr_number}: ${state}"
# UNKNOWN (deleted or inaccessible PR) is deliberately kept — this
# job must never be the thing that removes an image someone is
# still looking at.
if [ "$state" = "CLOSED" ] || [ "$state" = "MERGED" ]; then
echo "$pr_number" >> /tmp/closed.txt
fi
done < /tmp/prs.txt

deleted=0
while read -r version_id tag; do
[ -n "$tag" ] || continue
case "$tag" in pr-*) ;; *) continue ;; esac
pr_number="${tag#pr-}"
pr_number="${pr_number%%-*}"
case "$pr_number" in ''|*[!0-9]*) echo "skip malformed tag: $tag"; continue ;; esac

if grep -qx "$pr_number" /tmp/closed.txt; then
echo "deleting ${PACKAGE}:${tag} (PR #${pr_number})"
gh api --method DELETE \
"orgs/${ORG}/packages/container/${ENCODED}/versions/${version_id}" || true
deleted=$((deleted + 1))
fi
done < /tmp/tags.txt

echo "deleted ${deleted} tag(s) from ${PACKAGE}"

# Backstop for untagged layers orphaned by tag deletion and for tags whose
# PR lookup kept failing. The floor is generous on purpose: this job is
# about bounding growth, not reclaiming every byte.
- name: Retention floor
uses: actions/delete-package-versions@v5
continue-on-error: true
with:
owner: ${{ github.repository_owner }}
package-name: ${{ matrix.package }}
package-type: container
min-versions-to-keep: 50
delete-only-untagged-versions: true
Loading
Loading