Skip to content

Commit eb3e5bf

Browse files
authored
Merge pull request #36 from archdex-art/fix/drop-syntax-directive
fix(deploy): drop the `# syntax=` directive — it is what fails on Render
2 parents dc818d8 + a0598c7 commit eb3e5bf

7 files changed

Lines changed: 245 additions & 11 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Publish image
2+
3+
# Build the production image ONCE, in the place that already proves it works, and publish it.
4+
#
5+
# WHY THIS EXISTS. Every deploy failure this project has had was a build-input problem on the
6+
# platform, not a code problem: a stale Root Directory, a Dockerfile Path pointing at a moved
7+
# file, and an external BuildKit frontend that could not re-resolve the Dockerfile. None of
8+
# them was reproducible by `docker build .`, and none of them could happen at all if the
9+
# platform were not building.
10+
#
11+
# A service that PULLS this image has no Dockerfile path, no build context, no root directory
12+
# and no frontend — the entire class of failure is gone, and the image running in production
13+
# is bit-for-bit the one CI built, indexed two real repositories with, and gated at 85% of the
14+
# 512MB budget. See apps/web/DEPLOY.md, "Deploying the prebuilt image".
15+
#
16+
# linux/amd64 only: that is what Render runs. Building the arm64 variant as well would double
17+
# the job time to publish an image nothing pulls.
18+
19+
on:
20+
push:
21+
branches: [main]
22+
workflow_dispatch:
23+
24+
concurrency:
25+
# A newer commit's image supersedes an older one; there is no value in publishing both.
26+
group: publish-image
27+
cancel-in-progress: true
28+
29+
jobs:
30+
publish:
31+
name: Build and push to GHCR
32+
runs-on: ubuntu-latest
33+
permissions:
34+
contents: read
35+
packages: write
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
# Path resolution first, exactly as the smoke-test job does. It costs five seconds and
40+
# it is the check that four failed deploys were missing.
41+
- name: Verify every dockerfile path resolves
42+
run: ./scripts/verify-docker.sh --paths
43+
44+
- name: Log in to GHCR
45+
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
46+
47+
# Plain buildx rather than a third-party action: this workflow can push to the org's
48+
# package registry, so the fewer external actions in its supply chain the better.
49+
#
50+
# Two tags, and both matter. `:latest` is what a service pulls; the commit SHA is what
51+
# makes a rollback possible and what tells you which image is actually running — Render
52+
# caches mutable tags, so a SHA tag is also the only way to force a specific image.
53+
- name: Build and push
54+
run: |
55+
set -euo pipefail
56+
REPO="ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
57+
docker buildx build \
58+
--platform linux/amd64 \
59+
--tag "$REPO:latest" \
60+
--tag "$REPO:${{ github.sha }}" \
61+
--label "org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \
62+
--label "org.opencontainers.image.revision=${{ github.sha }}" \
63+
--push \
64+
.
65+
echo "published $REPO:latest and $REPO:${{ github.sha }}"
66+
67+
- name: Summary
68+
run: |
69+
REPO="ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
70+
{
71+
echo "### Image published"
72+
echo
73+
echo '```'
74+
echo "$REPO:latest"
75+
echo "$REPO:${{ github.sha }}"
76+
echo '```'
77+
echo
78+
echo "A Render service using **Existing Image** with the URL above has no Dockerfile"
79+
echo "path, no build context and no root directory to get wrong."
80+
} >> "$GITHUB_STEP_SUMMARY"

Dockerfile

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1-
# syntax=docker/dockerfile:1
2-
#
31
# THE IMAGE FOR apps/web, BUILT FROM THE MONOREPO ROOT.
2+
#
3+
# NO `# syntax=` DIRECTIVE, DELIBERATELY, AND IT MUST NOT COME BACK WITHOUT READING THIS.
4+
#
5+
# That directive tells BuildKit to fetch an EXTERNAL frontend image and hand the build to it,
6+
# and that frontend resolves the Dockerfile ITSELF rather than using the definition BuildKit
7+
# already loaded. On Render that second resolution is what failed, every time, for five
8+
# deploys — the logs show step #1 succeeding and then the solve dying:
9+
#
10+
# #1 [internal] load build definition from Dockerfile
11+
# #1 transferring dockerfile: 9.55kB done <- this file, read correctly
12+
# #1 DONE 0.0s
13+
# error: failed to solve: failed to read dockerfile: open Dockerfile : no such file or directory
14+
#
15+
# The same invocation locally (`docker build -f apps/web/Dockerfile .`) prints the identical
16+
# step name and the identical 9.55kB and then BUILDS, because the local builder resolves the
17+
# frontend differently. That divergence is the whole bug, and it is not reachable from the
18+
# Dockerfile's own content — only from whether an external frontend is involved at all.
19+
#
20+
# The directive bought this file NOTHING: it uses no BuildKit-frontend feature — no
21+
# `RUN --mount`, no heredocs, no `COPY --link`, no `COPY --chmod`. Checked before removing it,
22+
# and `scripts/verify-docker.sh` re-checks every path that can invoke this build.
423
# docker build -t codegraph .
524
#
625
# It lives HERE, beside the lockfile, rather than in apps/web, because the repo root is the

apps/web/DEPLOY.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,47 @@ The other Free-tier consequence is speed: 0.1 CPU against the 0.5 the smoke test
118118
holds; indexing simply takes proportionally longer, and the worker's shutdown drain is why
119119
`maxShutdownDelaySeconds` is raised.
120120

121+
#### Deploying the prebuilt image (the way that cannot break on paths)
122+
123+
Every deploy failure this project has had was a **build-input** problem on the platform, not a
124+
code problem: a stale Root Directory, a Dockerfile Path pointing at a moved file, and an
125+
external BuildKit frontend that could not re-resolve the Dockerfile. None of them was
126+
reproducible by `docker build .`, and **none of them can happen if the platform is not
127+
building.**
128+
129+
`.github/workflows/publish-image.yml` builds the image on every push to `main` — in the same
130+
CI that already indexes two real repositories with it and gates peak memory at 85% of the
131+
512 MB budget — and pushes it to GHCR:
132+
133+
```
134+
ghcr.io/archdex-art/codegraph:latest
135+
ghcr.io/archdex-art/codegraph:<commit-sha>
136+
```
137+
138+
A service that PULLS that image has no Dockerfile path, no build context, no root directory
139+
and no frontend. The image running in production is bit-for-bit the one CI verified, which is
140+
not true today — Render currently rebuilds from source and can produce a different image from
141+
the one that passed.
142+
143+
**One-time setup:**
144+
145+
1. Push to `main` once so the workflow publishes the first image.
146+
2. GitHub → the repo → *Packages*`codegraph`*Package settings***change visibility to
147+
Public**. Packages pushed with `GITHUB_TOKEN` are private by default even in a public repo,
148+
and a private image needs a Render *Registry Credential* instead.
149+
3. Render → *New**Web Service***Existing Image** → image URL
150+
`ghcr.io/archdex-art/codegraph:latest`.
151+
4. Set the environment variables from the table below, the health check path `/api/health`,
152+
and — if you want persistence — a Starter instance with a disk at `/app/data`.
153+
5. Delete the old repo-backed service once the new one serves traffic.
154+
155+
Deploy by clicking *Manual Deploy* (or hitting the Deploy Hook from CI) after a publish. Pin
156+
the SHA tag rather than `latest` when you need certainty about which image is running: Render
157+
caches mutable tags, so `latest` can serve a stale image.
158+
159+
The Dockerfile-based path stays fully supported and is what `docker compose` and CI use; this
160+
is about which of the two the *production service* depends on.
161+
121162
#### Before you push a Docker change
122163

123164
```bash

apps/web/Dockerfile

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1-
# syntax=docker/dockerfile:1
2-
#
31
# THE IMAGE FOR apps/web, BUILT FROM THE MONOREPO ROOT.
2+
#
3+
# NO `# syntax=` DIRECTIVE, DELIBERATELY, AND IT MUST NOT COME BACK WITHOUT READING THIS.
4+
#
5+
# That directive tells BuildKit to fetch an EXTERNAL frontend image and hand the build to it,
6+
# and that frontend resolves the Dockerfile ITSELF rather than using the definition BuildKit
7+
# already loaded. On Render that second resolution is what failed, every time, for five
8+
# deploys — the logs show step #1 succeeding and then the solve dying:
9+
#
10+
# #1 [internal] load build definition from Dockerfile
11+
# #1 transferring dockerfile: 9.55kB done <- this file, read correctly
12+
# #1 DONE 0.0s
13+
# error: failed to solve: failed to read dockerfile: open Dockerfile : no such file or directory
14+
#
15+
# The same invocation locally (`docker build -f apps/web/Dockerfile .`) prints the identical
16+
# step name and the identical 9.55kB and then BUILDS, because the local builder resolves the
17+
# frontend differently. That divergence is the whole bug, and it is not reachable from the
18+
# Dockerfile's own content — only from whether an external frontend is involved at all.
19+
#
20+
# The directive bought this file NOTHING: it uses no BuildKit-frontend feature — no
21+
# `RUN --mount`, no heredocs, no `COPY --link`, no `COPY --chmod`. Checked before removing it,
22+
# and `scripts/verify-docker.sh` re-checks every path that can invoke this build.
423
# docker build -t codegraph .
524
#
625
# It lives HERE, beside the lockfile, rather than in apps/web, because the repo root is the

apps/web/tests/readme-claims.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,36 @@ describe("the deploy's two dockerfile paths both resolve", () => {
200200
// that the P1 monorepo move broke by leaving the dockerfile at `apps/web/Dockerfile` while
201201
// the context became the repo root.
202202
const rootDockerfile = readFileSync(path.join(root, "Dockerfile"), "utf8");
203-
expect(rootDockerfile).toMatch(/^# syntax=docker\/dockerfile:1/);
204203
expect(rootDockerfile).toMatch(/ENV CG_USE_WORKER=true/);
205204
});
206205

206+
it("carries no `# syntax=` directive, so no external frontend re-reads the dockerfile", () => {
207+
// This assertion was written the other way round — requiring the directive — and this
208+
// suite failed the moment it was removed. Kept, inverted, because the removal is the fix
209+
// for the fifth failed deploy and nothing in the file's content would reveal that:
210+
//
211+
// #1 [internal] load build definition from Dockerfile
212+
// #1 transferring dockerfile: 9.55kB done <- read correctly, right size
213+
// #1 DONE 0.0s
214+
// error: failed to solve: failed to read dockerfile: open Dockerfile : no such file
215+
//
216+
// `# syntax=` hands the build to an EXTERNAL frontend image which resolves the dockerfile
217+
// itself instead of using the definition BuildKit already loaded, and on Render that
218+
// second resolution failed. The directive bought this file nothing: no `RUN --mount`, no
219+
// heredocs, no `COPY --link`, no `COPY --chmod`. Adding it back means re-introducing the
220+
// failure, so it fails here first.
221+
const rootDockerfile = readFileSync(path.join(root, "Dockerfile"), "utf8");
222+
expect(rootDockerfile).not.toMatch(/^#\s*syntax\s*=/m);
223+
// ...and the features that would justify bringing it back are absent, so the removal
224+
// stays safe. Comment lines are stripped first: the header above NAMES those features in
225+
// prose, and the first version of this assertion matched its own explanation.
226+
const instructions = rootDockerfile
227+
.split("\n")
228+
.filter((line) => !line.trim().startsWith("#"))
229+
.join("\n");
230+
expect(instructions).not.toMatch(/RUN\s+--mount|COPY\s+--link|COPY\s+--chmod|<<[A-Z]/);
231+
});
232+
207233
it("keeps apps/web/Dockerfile byte-identical to it", () => {
208234
// THE failure mode of a duplicate is drift: two build definitions, one of them edited, and
209235
// a deploy built from whichever the platform happened to read. Byte equality is the whole

render.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ services:
4141
dockerfilePath: ./Dockerfile
4242
dockerContext: .
4343
#
44+
# THE WAY THAT CANNOT BREAK ON PATHS. Every failed deploy here was a build-input problem,
45+
# and none of them is reachable by a service that does not build. CI publishes the exact
46+
# image it smoke-tests to ghcr.io on every push to main
47+
# (.github/workflows/publish-image.yml), so this whole block can be replaced by:
48+
#
49+
# runtime: image
50+
# image:
51+
# url: ghcr.io/archdex-art/codegraph:latest
52+
#
53+
# and then there is no dockerfilePath, no dockerContext and no rootDir to get wrong.
54+
# Setup steps are in apps/web/DEPLOY.md, "Deploying the prebuilt image".
55+
#
4456
# `apps/web/Dockerfile` is a byte-identical COPY of that file, kept in step by a test, so
4557
# a service whose dashboard still carries the old Dockerfile Path reads real content
4658
# instead of the two bytes an absent file produces. Remove the copy once the dashboard

scripts/verify-docker.sh

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,56 @@ pass "image builds"
113113
docker run -d --name "$NAME" --memory=512m --memory-swap=512m --cpus=0.5 -p 4599:4000 "$TAG" >/dev/null \
114114
|| fail "container did not start"
115115

116+
# NO `... | grep -q` ANYWHERE BELOW, and that is not style. Under `set -o pipefail` — set at
117+
# the top of this file, correctly — `grep -q` exits the instant it matches, the writer upstream
118+
# gets SIGPIPE, and the PIPELINE reports 141. So the check fails exactly when the string is
119+
# found EARLY with output still to come, and passes when the output is short enough to finish
120+
# writing first. Measured here: three consecutive runs of the same green image went pass,
121+
# fail, fail, each failure dumping logs that visibly contained the string it had just failed
122+
# to find. Capturing first and matching in the shell has no pipe and no race.
123+
logs_of() { docker logs "$1" 2>&1 || true; }
124+
116125
for _ in $(seq 1 40); do
117126
if curl -fsS -m 3 http://127.0.0.1:4599/api/health >/dev/null 2>&1; then break; fi
118127
sleep 1
119128
done
120-
curl -fsS -m 5 http://127.0.0.1:4599/api/health | grep -q '"status":"ok"' \
121-
|| { docker logs "$NAME" 2>&1 | tail -20 >&2; fail "/api/health did not report ok under 512MB/0.5cpu"; }
122-
pass "/api/health reports ok under --memory=512m --cpus=0.5"
129+
health=$(curl -fsS -m 5 http://127.0.0.1:4599/api/health 2>/dev/null || true)
130+
case "$health" in
131+
*'"status":"ok"'*) pass "/api/health reports ok under --memory=512m --cpus=0.5" ;;
132+
*) logs_of "$NAME" | tail -20 >&2; fail "/api/health did not report ok under 512MB/0.5cpu (got: ${health:-no response})" ;;
133+
esac
123134

124135
# The worker is what claims queued jobs. With CG_USE_WORKER=true baked into the image and no
125136
# worker running, every index would sit in the queue forever while the app looked healthy —
126137
# the failure mode the entrypoint refuses to boot into, asserted here too.
127-
docker logs "$NAME" 2>&1 | grep -q "worker started" \
128-
|| { docker logs "$NAME" 2>&1 | tail -20 >&2; fail "the analysis worker did not start"; }
129-
pass "analysis worker started"
138+
#
139+
# TWO SIGNALS, because they fail differently and a gate that cannot tell them apart is a gate
140+
# people re-run instead of read:
141+
#
142+
# · `entrypoint: starting analysis worker` is printed synchronously by the entrypoint before
143+
# the web server is launched at all. Absent => the worker was never launched, which is a
144+
# real defect in the image.
145+
# · `worker started` is the worker's own line, emitted only after it opens SQLite and runs
146+
# migrations — which can land well AFTER /api/health is already answering. Absent while
147+
# the first line is present => slow start, not a broken image.
148+
worker_wait=0
149+
while :; do
150+
container_logs=$(logs_of "$NAME")
151+
case "$container_logs" in *"worker started"*) break ;; esac
152+
[ "$worker_wait" -ge 60 ] && break
153+
worker_wait=$((worker_wait + 1))
154+
sleep 1
155+
done
156+
157+
case "$container_logs" in
158+
*"entrypoint: starting analysis worker"*) : ;;
159+
*) printf '%s\n' "$container_logs" | tail -20 >&2
160+
fail "the entrypoint never launched the worker (is CG_USE_WORKER=true in the image?)" ;;
161+
esac
162+
case "$container_logs" in
163+
*"worker started"*) pass "analysis worker started (after ${worker_wait}s)" ;;
164+
*) printf '%s\n' "$container_logs" | tail -20 >&2
165+
fail "the worker was launched but never reported ready (waited ${worker_wait}s)" ;;
166+
esac
130167

131168
printf '\n%sall docker paths resolve and the image serves%s\n' "$GREEN" "$OFF"

0 commit comments

Comments
 (0)