Skip to content

Commit 42d5427

Browse files
docs(ci): make the preview notes say what the code does (#1573)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2b04494 commit 42d5427

10 files changed

Lines changed: 61 additions & 82 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
---
3+
4+
Comment and documentation corrections in the preview deployment tooling; no deployed behaviour
5+
changes.

.github/workflows/cleanup-preview.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_PREVIEW_WEBHOOK_SECRET }}
6262
run: bun scripts/coolify-preview.ts close
6363

64-
- name: Keep a verified cleanup tombstone
64+
- name: Record that teardown was requested
6565
id: tombstone
6666
if: steps.close.outcome == 'success'
6767
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0

docker/preview/README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ Coolify runs every preview of this application under one Compose project named a
5151
UUID, so a network defined here would be `<uuid>_backend` for all of them at once, private-looking
5252
and shared. `staging-shared` is joined by name, which makes it a decision rather than a side effect.
5353

54-
The application server runs without Linux capabilities and with `no-new-privileges`. PostgreSQL
55-
keeps a PR-specific volume.
54+
PostgreSQL keeps a PR-specific volume; nothing else in the stack keeps state.
5655

5756
Deployment authority is deliberately the same as push authority;
5857
[ADR 0035](../../docs/decisions/0035-pull-request-previews-are-label-gated.md) records why, and what
@@ -72,10 +71,9 @@ memory limit, a network every preview would share, or a flipped integration swit
7271

7372
## Host capacity
7473

75-
Each stack is capped at about 2.8 GiB once running (2 GiB application server, 512 MiB PostgreSQL,
76-
256 MiB webapp); the seed loader adds 512 MiB while it runs and then exits. Its CPU ceilings total
77-
2.75 across the four services, so several previews oversubscribe a small host on paper. That is intended: a preview is idle almost all the time, and a
78-
ceiling stops one stack from taking the box rather than reserving capacity for it.
74+
Memory and CPU ceilings are set per service in `compose.app.yaml`; budget roughly 3 GiB per slot.
75+
The CPU ceilings deliberately oversubscribe a small host: a preview is idle almost all the time, and
76+
a ceiling stops one stack taking the box rather than reserving capacity for it.
7977

8078
`PREVIEW_MAX_ACTIVE` caps concurrent previews and defaults to 3. It has to leave roughly 3 GiB of
8179
memory per slot in whatever staging is not already using — check `free -g` on the host before raising
@@ -168,10 +166,9 @@ draft sends Coolify a signed close event, and the GitHub deployment is then mark
168166
slot is free. Coolify queues that teardown and answers immediately, so the workflow reports that the
169167
request was accepted, not that the containers are gone.
170168

171-
Nothing here checks the host. That is a deliberate scope choice, not an oversight: no leaked preview
172-
stack has been observed, and verifying it would mean a standing SSH credential and a root-owned
173-
binary installed out of band. If leaks do turn up, the nightly `Preview reconcile` workflow is where
174-
that check belongs — it already walks every retained preview and re-sends the close event.
169+
Nothing here checks the host. That is a deliberate scope choice: verifying teardown would mean a
170+
standing credential on the deployment host. If leaks turn up, the nightly `Preview reconcile`
171+
workflow is where that check belongs — it already walks every retained preview and re-sends the close
172+
event.
175173

176-
Watch the host's disk and container list for the first few weeks. Preview images are pulled per
177-
commit and are never removed by this system.
174+
Preview images are pulled per commit and are never removed by this system.

docker/preview/compose.app.yaml

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,16 @@
22
#
33
# Coolify receives the opted-in PR head through its signed manual webhook and re-reads this file from
44
# that commit, so a pull request's own copy defines its stack. The controller refuses to deploy a pull
5-
# request that edits this file, and scripts/check-preview-stack.ts asserts the sandbox properties
6-
# below on every pull request.
5+
# request that changes anything under docker/preview/, and scripts/check-preview-stack.ts asserts the
6+
# sandbox properties below on every pull request.
77
#
88
# Every service runs the image CI published for this exact commit — the same artifacts staging and
99
# production run, buildpack-built application server included. Nothing is built here, so a preview
1010
# exercises the shipped runtime rather than a second recipe that could pass where the real one fails.
1111
#
12-
# This restates the reference stack rather than including it. Compose merges per key, so inheriting
12+
# This restates the reference stack rather than including it: Compose merges per key, so inheriting
1313
# would arrive with sync, notifications and the agent digest requirement switched on, and with the
14-
# Docker socket mounted — ADR 0035 option 6 has the rendered evidence. scripts/check-preview-stack.ts
15-
# fails when the reference gains a variable this file neither sets nor records as omitted.
16-
#
17-
# This file declares no networks. Coolify runs every preview of this application under one Compose
18-
# project — the project name is the application UUID, with no pull request in it — so a network named
19-
# here would be `<uuid>_backend`, shared by every preview at once. Coolify instead gives each preview
20-
# its own network and attaches its proxy to that, which is what keeps the stacks apart.
14+
# Docker socket mounted. ADR 0035 option 6 has the rendered evidence.
2115

2216
services:
2317
postgres:
@@ -34,7 +28,7 @@ services:
3428
cap_drop:
3529
- ALL
3630
# The entrypoint chowns its data directory and drops to the postgres user; without these it exits
37-
# with "failed switching to 'postgres'". Verified by running the stack, not inferred.
31+
# with "failed switching to 'postgres'".
3832
cap_add:
3933
- CHOWN
4034
- DAC_OVERRIDE
@@ -58,20 +52,15 @@ services:
5852
memory: 512M
5953
pids: 256
6054

61-
# Clones the running staging database into this preview so it starts with real data. Reads over
62-
# the network with a role that can only read: staging's data volume is never mounted, and no
63-
# container here holds the Docker daemon. Every review entry point is paused, and the instance
64-
# identity dropped, before the application server is allowed to boot. Runs once — a redeploy finds
65-
# the marker table and exits.
66-
#
67-
# This is the image staging itself runs, so pg_dump always matches the server it reads.
55+
# Clones staging into this preview over the network with a role that can only read; staging's data
56+
# volume is never mounted. This is the image staging itself runs, so pg_dump always matches the
57+
# server it reads.
6858
seed-loader:
6959
image: ghcr.io/ls1intum/hephaestus/postgres:${SOURCE_COMMIT:?Coolify supplies the deployed commit}
7060
restart: "no"
7161
depends_on:
7262
postgres:
7363
condition: service_healthy
74-
# Reaches staging's Postgres by name; its own database it reaches on Coolify's preview network.
7564
networks:
7665
- staging-shared
7766
environment:
@@ -188,8 +177,8 @@ services:
188177
DELETE FROM login_provider;
189178
SQL
190179
191-
# The marker promises the policy is in force, so verify it against the database rather than
192-
# trusting psql's exit status.
180+
# Every statement above succeeds when it matches nothing, so the marker is earned by
181+
# counting rather than by psql's exit status.
193182
echo "Verifying the preview policy took effect"
194183
LIVE=$$(psql -h "$$TARGET" -U hephaestus -d hephaestus -tAX -v ON_ERROR_STOP=1 -c "
195184
SELECT (SELECT count(*) FROM workspace
@@ -260,17 +249,15 @@ services:
260249
DATABASE_USERNAME: hephaestus
261250
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
262251
# The integration consumer reads staging's JetStream, so a preview sees the events a shared
263-
# GitHub App delivers there rather than an empty broker. The agent job queue runs on
264-
# PostgreSQL, not NATS, and stays local to this stack's own database.
252+
# GitHub App delivers there rather than an empty broker.
265253
NATS_SERVER: ${NATS_SERVER:-nats://nats-server:4222}
266254
NATS_ENABLED: "true"
267255
# Per-deploy durable name, so previews get their own JetStream consumers instead of competing
268256
# for one and stealing each other's messages.
269257
NATS_DURABLE_CONSUMER_NAME: ${SERVICE_NAME_APPSERVER:-appserver}-consumer
270-
# A preview is deleted, not shut down, so it never removes the durables it created on the
271-
# shared stream. Far shorter than the 30d a long-lived deployment uses, because a preview is
272-
# abandoned the day its pull request merges. Still long enough that a redeploy, or a testing
273-
# session paused overnight, resumes on its own cursor instead of skipping to the head.
258+
# A preview is deleted, not shut down, so it never removes its durables from the shared
259+
# stream. Shorter than the shipped 30d because a preview is abandoned at merge, and long
260+
# enough that a redeploy resumes on its own cursor.
274261
HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD: 72h
275262
HEPHAESTUS_AUTH_ISSUER: https://${SERVICE_FQDN_WEBAPP}
276263
HEPHAESTUS_AUTH_STATE_COOKIE_KEY: ${HEPHAESTUS_AUTH_STATE_COOKIE_KEY}
@@ -294,8 +281,8 @@ services:
294281
SENTRY_DSN: ""
295282
WEBHOOK_SECRET: ${WEBHOOK_SECRET}
296283
WEBHOOK_EXTERNAL_URL: ""
297-
# Only the application server joins staging's network, and only to reach its broker. The
298-
# database and the SPA stay on Coolify's per-preview network.
284+
# Joined to reach staging's broker; this stack's own database and SPA stay on Coolify's
285+
# per-preview network.
299286
networks:
300287
- staging-shared
301288
depends_on:
@@ -312,8 +299,8 @@ services:
312299
test: ["CMD", "/workspace/health-check"]
313300
interval: 15s
314301
timeout: 5s
315-
# A first boot runs Liquibase against an empty database on a shared host, and the webapp waits
316-
# on this. Nothing here benefits from failing fast, and failing fast fails the whole deploy.
302+
# First boot runs Liquibase over a freshly restored staging dump, on a host shared with
303+
# staging.
317304
retries: 10
318305
start_period: 300s
319306
logging:

docs/contributor/ci-cd.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ This is more efficient than a custom workflow and doesn't consume CI minutes.
8484

8585
| Environment | Eligibility | Deploys on |
8686
| ----------------- | ----------- | ---------- |
87-
| Preview (Coolify) | Same-repository PR with the `preview` label | Every push; never waits for CI |
87+
| Preview (Coolify) | Same-repository PR with the `preview` label | Every push; waits for its images, never for tests |
8888
| Staging | `main` | Verified signed release |
8989
| Production | Required reviewer | The same verified signed release |
9090

docs/decisions/0035-pull-request-previews-are-label-gated.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ reviewer wants *in order to* review.
4949
silently deploy it, which surprises the approver.
5050
3. **A GitHub Environment with required reviewers.** Rejected *for previews*. It is the correct
5151
primitive and it is what production uses, but it gates every individual deployment behind a click
52-
— the same per-commit friction as option 2, with a nicer button. A required-reviewer environment gates every
53-
individual deployment behind a click. Deleting such an environment also deletes its secrets and protection rules
52+
— the same per-commit friction as option 2, with a nicer button.
53+
Deleting such an environment also deletes its secrets and protection rules
5454
and fails any job waiting on them, and `GITHUB_TOKEN` cannot do it at all — which is why cleanup
5555
here marks deployments inactive and never deletes environments.
5656
4. **The `preview` label as opt-in state, push access as the authority.** Chosen — see below.

scripts/check-preview-stack.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,10 @@ const REFERENCE_FILE = "docker/compose.app.yaml";
1818
const OWN_IMAGE_PREFIX = "ghcr.io/ls1intum/hephaestus/";
1919

2020
/**
21-
* Reference variables a preview deliberately does not set. Inheriting the reference stack instead of
22-
* restating it was tried and rejected: Compose merges per key, so the sandbox would arrive with
23-
* `LEADERBOARD_NOTIFICATION_ENABLED`, `MONITORING_BACKFILL_ENABLED` and an hourly `MONITORING_SYNC_CRON`
24-
* switched on, and a *new* integration would arrive on too. Restating is the safe direction; this list
25-
* is what stops it from being a silent one — a variable added to the reference and not considered here
26-
* fails the build.
21+
* Reference variables a preview deliberately does not set. Inheriting the reference instead of
22+
* restating it was tried and rejected — Compose merges per key, so integrations arrive switched on
23+
* (ADR 0035, option 6). This list is what keeps restating from being a silent omission: a variable
24+
* added to the reference and not considered here fails the build.
2725
*/
2826
const DELIBERATELY_OMITTED = new Set([
2927
// Integrations a preview never reaches: no GitLab, Outline, Slack or PostHog credentials exist.
@@ -70,11 +68,6 @@ const DELIBERATELY_OMITTED = new Set([
7068
"HEPHAESTUS_AUTH_BOOTSTRAP_TOKEN",
7169
]);
7270

73-
/**
74-
* A preview restates the reference stack rather than including it, so a variable added to the
75-
* reference reaches previews only if someone puts it there. This turns that from a silent omission
76-
* into a red check naming the variable.
77-
*/
7871
export function findEnvDrift(referenceText: string, previewText: string): string[] {
7972
const reference = readComposeServices(referenceText).get("application-server");
8073
const preview = readComposeServices(previewText).get("appserver");
@@ -113,18 +106,17 @@ const RENDER_ENV: Record<string, string> = {
113106
SERVICE_FQDN_APPSERVER: "pr1.api.example.com",
114107
};
115108

116-
/** Values the server refuses to start without: it validates them before the context is built, so an
117-
* empty one here is a preview that restart-loops rather than a preview that misbehaves quietly. */
118109
/**
119-
* Capabilities a service may add back after `cap_drop: ALL`. Both entries are servers whose root
120-
* process prepares a directory and then runs its real work as another user; each set is the minimum
121-
* found by running the image with less. The application server needs none.
110+
* Capabilities a service may add back after `cap_drop: ALL`. Each set is the minimum found by running
111+
* the image with less; the application server needs none.
122112
*/
123113
const ALLOWED_CAPABILITIES: Record<string, readonly string[]> = {
124114
postgres: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"],
125115
webapp: ["CHOWN", "SETGID", "SETUID"],
126116
};
127117

118+
/** Values the server refuses to start without: it validates them before the context is built, so an
119+
* empty one here is a preview that restart-loops rather than a preview that misbehaves quietly. */
128120
const REQUIRED_NON_EMPTY = ["HEPHAESTUS_TRUSTED_PROXIES", "WEBHOOK_SECRET"];
129121

130122
/**
@@ -233,11 +225,8 @@ export function findViolations(stack: unknown): string[] {
233225
}
234226
// Coolify runs every preview of this application under one Compose project, named after the
235227
// application UUID with no pull request in it. A network defined here is therefore `<uuid>_<name>`
236-
// for all of them at once, and one preview reaches another's database over it — a shared network
237-
// that reads as private. An external one is the opposite: it names a network that already exists,
238-
// so joining it is a decision someone made rather than a side effect of the project name.
239-
// `default` is Compose's own and carries the project name too, but nothing joins it once no
240-
// service names a network.
228+
// for all of them at once — a shared network that reads as private. An external one names a
229+
// network that already exists, so joining it is a decision rather than a side effect.
241230
for (const [name, network] of records(stack.networks)) {
242231
if (name === "default") continue;
243232
if (network.external === true && typeof network.name === "string" && network.name !== "") {

scripts/coolify-preview.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ const DEPLOYMENT_STATES = new Set([
7777
"in_progress",
7878
"queued",
7979
]);
80-
// Coolify pulls three published images and starts them; the images are already built by the time
81-
// this runs, so the budget covers pulls and startup rather than a build.
80+
// The images are already built by the time this runs, so this budget covers Coolify pulling and
81+
// starting them rather than a build.
8282
const BUILD_BUDGET_MS = 1_200_000;
8383
// Coolify reports `finished` once the containers are up; the preview URL only answers once the
8484
// application server clears its own healthcheck start period. This is additive on purpose: clamping

scripts/preview-controller.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from "node:test";
33

44
import {
55
assess,
6-
CLEANUP_VERIFIED_DESCRIPTION,
6+
TEARDOWN_REQUESTED_DESCRIPTION,
77
create,
88
finalize,
99
inactivate,
@@ -280,7 +280,7 @@ void describe("preview host capacity", () => {
280280
github: makeGitHub({
281281
deployments: occupants(3),
282282
statuses: {
283-
102: [{ description: CLEANUP_VERIFIED_DESCRIPTION, state: "inactive" }],
283+
102: [{ description: TEARDOWN_REQUESTED_DESCRIPTION, state: "inactive" }],
284284
},
285285
}),
286286
context: makeContext(),
@@ -556,7 +556,7 @@ void it("keeps the verified tombstone record when cleanup inactivates a preview"
556556

557557
await inactivate({ github, context: makeContext(), core: makeCore() });
558558

559-
assert.deepEqual(descriptions, [CLEANUP_VERIFIED_DESCRIPTION]);
559+
assert.deepEqual(descriptions, [TEARDOWN_REQUESTED_DESCRIPTION]);
560560
assert.equal(deletions, 0);
561561
});
562562

scripts/preview-controller.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ const TRUSTED_ASSOCIATIONS = new Set(["COLLABORATOR", "MEMBER", "OWNER"]);
7979
const COMPARE_FILE_LIMIT = 300;
8080
const DEFAULT_MAX_ACTIVE = 3;
8181
const LIVE_STATES = new Set(["in_progress", "pending", "queued", "success"]);
82-
const CLEANUP_VERIFIED_DESCRIPTION = "Preview teardown requested; awaiting Coolify reconciliation.";
82+
const TEARDOWN_REQUESTED_DESCRIPTION =
83+
"Preview teardown requested; awaiting Coolify reconciliation.";
8384

8485
const hasPreviewLabel = (pull: PullRequest): boolean =>
8586
pull.labels.some((label) => label.name === PREVIEW_LABEL);
@@ -90,9 +91,9 @@ const maxActivePreviews = (): number =>
9091
: DEFAULT_MAX_ACTIVE;
9192

9293
/**
93-
* Environments holding a slot on the shared preview host. A verified cleanup tombstone is retained so
94-
* the nightly reconcile can repeat Coolify cleanup, but its host resources are gone, so it holds
95-
* nothing.
94+
* Environments still holding a slot. A pull request whose teardown has been requested does not hold
95+
* one: the request is the same close event Coolify acts on, and the nightly reconcile re-sends it if
96+
* that was missed. Nothing here reads the host, so a slot is freed on the request, not on proof.
9697
*/
9798
const occupiedEnvironments = async (
9899
github: GitHubApi,
@@ -115,10 +116,10 @@ const occupiedEnvironments = async (
115116
per_page: 1,
116117
});
117118
const latest = statuses.data[0]?.state;
118-
// A verified tombstone has had its resources removed, and a failed deploy never took the
119-
// host — counting either would let a run of failures report a full host that is empty.
119+
// A requested teardown has been handed to Coolify and a failed deploy never reached the host,
120+
// so neither holds anything — counting them would report a full host that is empty.
120121
if (latest === "failure" || latest === "error") continue;
121-
if (latest === "inactive" && statuses.data[0]?.description === CLEANUP_VERIFIED_DESCRIPTION) {
122+
if (latest === "inactive" && statuses.data[0]?.description === TEARDOWN_REQUESTED_DESCRIPTION) {
122123
continue;
123124
}
124125
occupied.add(deployment.environment);
@@ -389,7 +390,7 @@ async function retireDeployments(
389390
const inactivate = async ({ github, context }: ControllerInput): Promise<void> => {
390391
const { owner, repo } = context.repo;
391392
await retireDeployments(github, owner, repo, requiredEnv(process.env, "ENVIRONMENT"), {
392-
description: CLEANUP_VERIFIED_DESCRIPTION,
393+
description: TEARDOWN_REQUESTED_DESCRIPTION,
393394
forceStatus: true,
394395
keepRecords: true,
395396
});
@@ -420,7 +421,7 @@ const retire = async ({ github, context }: ControllerInput): Promise<void> => {
420421
};
421422

422423
export {
423-
CLEANUP_VERIFIED_DESCRIPTION,
424+
TEARDOWN_REQUESTED_DESCRIPTION,
424425
PREVIEW_LABEL,
425426
assess,
426427
create,

0 commit comments

Comments
 (0)