Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6

- name: Test container build configuration
run: python3 -m unittest discover -s scripts -p container_build_config_test.py

- name: Setup pnpm
uses: pnpm/action-setup@v6

Expand Down
27 changes: 4 additions & 23 deletions .github/workflows/container-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ concurrency:

jobs:
image:
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
timeout-minutes: 45
environment: container-production
environment: ${{ github.ref == 'refs/heads/master' && 'container-production' || 'container-staging' }}
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/emuready_build
DATABASE_DIRECT_URL: postgresql://postgres:postgres@127.0.0.1:5432/emuready_build
Expand Down Expand Up @@ -56,26 +56,7 @@ jobs:
- name: Validate public build configuration
env:
PUBLIC_BUILD_ENV: ${{ vars.CONTAINER_PUBLIC_BUILD_ENV }}
run: |
python3 - <<'PY'
import json, os, pathlib, re

config = json.loads(os.environ['PUBLIC_BUILD_ENV'])
if not isinstance(config, dict):
raise SystemExit('CONTAINER_PUBLIC_BUILD_ENV must be a JSON object')
declared = set(re.findall(r'^ARG ([A-Z0-9_]+)', pathlib.Path('Dockerfile').read_text(), re.M))
allowed = {key for key in declared if key.startswith('NEXT_PUBLIC_') or key == 'NEXT_IMAGE_UNOPTIMIZED'}
if set(config) - allowed:
raise SystemExit('Only public arguments declared in Dockerfile are accepted')
if any(not isinstance(value, str) or any(c in value for c in '\r\n\0') for value in config.values()):
raise SystemExit('Build values must be single-line strings')
if config.get('NEXT_PUBLIC_APP_URL') != 'https://www.emuready.com' or config.get('NEXT_PUBLIC_APP_ENV') != 'production':
raise SystemExit('Expected final production URL and environment')
if not config.get('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', '').startswith('pk_live_'):
raise SystemExit('Expected the production Clerk publishable key')
args = [item for key, value in sorted(config.items()) for item in ('--build-arg', f'{key}={value}')]
pathlib.Path(os.environ['RUNNER_TEMP'], 'container-build-args').write_bytes(('\0'.join(args) + '\0').encode())
PY
run: python3 scripts/container_build_config.py

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand All @@ -88,7 +69,7 @@ jobs:
- name: Run unit tests
run: pnpm test:ci

- name: Build production image
- name: Build application image
id: image
run: |
image="ghcr.io/${GITHUB_REPOSITORY,,}"
Expand Down
11 changes: 6 additions & 5 deletions docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ EmuReady runs as a standalone Next.js container behind Coolify and Cloudflare. S
- Supply all `NEXT_PUBLIC_*` values while building. Runtime values cannot change the browser bundle.
- Set `BUILD_DATABASE_URL` to a migrated, disposable Postgres database because Prisma TypedSQL generation introspects the schema. Never use production or a restored production backup for this. Apply the existing migrations to an empty database; seeds are unnecessary.
- Optionally set `BUILD_DATABASE_DIRECT_URL` for that same disposable database. It defaults to `BUILD_DATABASE_URL`. The builder maps these names to Prisma's `DATABASE_URL` and `DATABASE_DIRECT_URL` only for `pnpm build`, and fails if `BUILD_DATABASE_URL` is missing.
- In Coolify, make `BUILD_DATABASE_URL` and `BUILD_DATABASE_DIRECT_URL` build-only variables and enable **Use Docker Build Secrets**. Ordinary Docker build arguments expose their values in image metadata.
- Keep the real `DATABASE_URL`, `DATABASE_DIRECT_URL`, and other runtime-only secrets, such as `CLERK_SECRET_KEY`, out of the build phase. Backups and environment files must remain excluded from the Docker context.
- For a release containing migrations, build the `migrator` target from the same commit and run it with `DATABASE_DIRECT_URL` before deploying the `app` image.

The VPS currently builds from source in Coolify. Staging uses the GitHub App webhook to deploy its configured branch; production is configured for manual deployments. Publishing prebuilt immutable images remains deferred.
Use the manual [Container image workflow](container-image.md) to build and publish

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

This new paragraph makes the GitHub-runner/GHCR/digest deployment flow the current procedure, but the Deferred follow-ups section still says that exact work is deferred. Remove or rewrite that stale bullet so the self-hosting instructions are not contradictory.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/SELF_HOSTING.md, line 15:

<comment>This new paragraph makes the GitHub-runner/GHCR/digest deployment flow the current procedure, but the `Deferred follow-ups` section still says that exact work is deferred. Remove or rewrite that stale bullet so the self-hosting instructions are not contradictory.</comment>

<file context>
@@ -12,7 +12,10 @@ EmuReady runs as a standalone Next.js container behind Coolify and Cloudflare. S
 - For a release containing migrations, build the `migrator` target from the same commit and run it with `DATABASE_DIRECT_URL` before deploying the `app` image.
 
-The VPS currently builds from source in Coolify. Staging uses the GitHub App webhook to deploy its configured branch; production is configured for manual deployments. Publishing prebuilt immutable images remains deferred.
+Use the manual [Container image workflow](container-image.md) to build and publish
+images on GitHub runners, then deploy their immutable digests through Coolify's
+Docker Image build pack. Disable source auto-deployment to keep builds off the
</file context>

images on GitHub runners, then deploy their immutable digests through Coolify's
Docker Image build pack. Disable source auto-deployment to keep builds off the
Comment thread
coderabbitai[bot] marked this conversation as resolved.
application VPS. Image publication does not deploy or apply hosted migrations.

## Coolify application

- Use the Dockerfile build pack, target `app`, and exposed port `3000`.
- Set `NEXT_BUILD_ID=$SOURCE_COMMIT` and enable **Include Source Commit in Build**.
- Use the Docker Image build pack with the verified immutable digest and exposed port `3000`.
- Set runtime `APP_VERSION` to the exact source commit. The image workflow sets `NEXT_BUILD_ID` while building.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This instruction is redundant with the image build: the Dockerfile's app stage already bakes APP_VERSION=${NEXT_BUILD_ID} as an ENV (Dockerfile line 92), and the workflow passes --build-arg NEXT_BUILD_ID=$GITHUB_SHA (.github/workflows/container-image.yml line 83). The image therefore already reports the exact source commit through /api/health. The sentence "The image workflow sets NEXT_BUILD_ID while building" reads as if APP_VERSION is not already in the image, and a stale or mistyped runtime override would silently replace the correct baked value. Drop the runtime instruction or present it as an optional override only.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/SELF_HOSTING.md, line 22:

<comment>This instruction is redundant with the image build: the Dockerfile's `app` stage already bakes `APP_VERSION=${NEXT_BUILD_ID}` as an `ENV` (Dockerfile line 92), and the workflow passes `--build-arg NEXT_BUILD_ID=$GITHUB_SHA` (`.github/workflows/container-image.yml` line 83). The image therefore already reports the exact source commit through `/api/health`. The sentence "The image workflow sets NEXT_BUILD_ID while building" reads as if APP_VERSION is not already in the image, and a stale or mistyped runtime override would silently replace the correct baked value. Drop the runtime instruction or present it as an optional override only.</comment>

<file context>
@@ -19,8 +18,8 @@ application VPS. Image publication does not deploy or apply hosted migrations.
-- Use the Dockerfile build pack, target `app`, and exposed port `3000`.
-- Set `NEXT_BUILD_ID=$SOURCE_COMMIT` and enable **Include Source Commit in Build**.
+- Use the Docker Image build pack with the verified immutable digest and exposed port `3000`.
+- Set runtime `APP_VERSION` to the exact source commit. The image workflow sets `NEXT_BUILD_ID` while building.
 - Use `/api/health/ready` for deployment health checks and `/api/health/live` for process liveness.
 
</file context>
Suggested change
- Set runtime `APP_VERSION` to the exact source commit. The image workflow sets `NEXT_BUILD_ID` while building.
- `APP_VERSION` is baked from `NEXT_BUILD_ID` at build time; set it only to override that value at runtime.

- Use `/api/health/ready` for deployment health checks and `/api/health/live` for process liveness.

## Production configuration
Expand All @@ -30,7 +32,6 @@ The VPS currently builds from source in Coolify. Staging uses the GitHub App web

- Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging.
- Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL.
- Move builds to GitHub-hosted Actions, publish immutable images to GHCR, and have Coolify deploy them by digest. Do not run the build runner on the application VPS.
- Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow.
- Consolidate the duplicate mobile tRPC paths and remove the unused transport.
- Define how authored handheld and PC Compatibility Reports are retained when a Clerk user is deleted; their required author relations currently block deletion (observed as `Listing_authorId_fkey`).
Expand Down
45 changes: 45 additions & 0 deletions docs/container-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Container images

The manual **Container image** workflow builds on GitHub runners and optionally
publishes to private GHCR. It does not deploy or change DNS.

Create a GitHub Actions environment **variable** named `CONTAINER_PUBLIC_BUILD_ENV`
containing a JSON object in each environment below. The workflow reads it through
`vars`; configuring an environment secret instead will leave this value empty.

| Branch | GitHub environment | Public URL | App environment | Clerk key prefix |
| --- | --- | --- | --- | --- |
| `master` | `container-production` | `https://www.emuready.com` | `production` | `pk_live_` |
| `staging` | `container-staging` | `https://staging.emuready.com` | `staging` | `pk_test_` |

Restrict each environment to its corresponding branch. Only public Dockerfile
arguments are accepted. Supply the full Clerk publishable key in the JSON; the
table shows only the required prefix. Never supply hosted database credentials or
application secrets: the workflow migrates an empty PostgreSQL 15 database on the runner for
Prisma generation. Public settings are baked into the image and require a rebuild
when changed.

After the exact commit passes CI, dispatch the workflow on the corresponding
branch with `publish` enabled. Before deployment, check the target database's
applied migrations against that commit. This workflow migrates only its disposable
database and publishes only the `app` target; the application does not migrate on
startup. If migrations are pending, stop until a fresh backup is verified and the
user explicitly approves applying them. Build the same-commit `migrator` target
off the application VPS and apply the migrations as described in
[Self-hosting](SELF_HOSTING.md#build-and-release-contract) before deploying the app.

Before deployment, configure GHCR authentication for the Docker account used by
Coolify on every deployment server. Use a pull-only credential with `read:packages`
and access to the private EmuReady package; track its expiration and rotate it
before it expires. Verify that the server can pull the exact published digest.
The workflow's GitHub token authenticates only the publishing runner.

Deploy that immutable digest through Coolify's Docker Image build pack.
Preserve runtime credentials, domains, labels, port 3000, health checks,
certificate mounts and persistent storage. Disable
source auto-deployment so pushes cannot start builds on the application VPS.
Set runtime `APP_VERSION` to the image's exact source commit.

Verify image identity, readiness, TLS, canonical URLs, authentication and static
assets before switching public DNS. Keep the existing Vercel deployments and
aliases intact for rollback until the user explicitly approves retirement.
36 changes: 36 additions & 0 deletions scripts/container_build_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import json
import os
from pathlib import Path
import re


def build_arguments(config, ref, dockerfile):
if not isinstance(config, dict):
raise ValueError('CONTAINER_PUBLIC_BUILD_ENV must be a JSON object')
declared = set(re.findall(r'^ARG ([A-Z0-9_]+)', dockerfile, re.M))
allowed = {key for key in declared if key.startswith('NEXT_PUBLIC_') or key == 'NEXT_IMAGE_UNOPTIMIZED'}
if set(config) - allowed:
raise ValueError('Only public arguments declared in Dockerfile are accepted')
if any(not isinstance(value, str) or value != value.strip() or any(c in value for c in '\r\n\0') for value in config.values()):
raise ValueError('Build values must be trimmed single-line strings')
expected = {
'refs/heads/master': ('https://www.emuready.com', 'production', 'pk_live_'),
'refs/heads/staging': ('https://staging.emuready.com', 'staging', 'pk_test_'),
}
if ref not in expected:
raise ValueError('Container images may only be built from master or staging')
url, environment, clerk_prefix = expected[ref]
if config.get('NEXT_PUBLIC_APP_URL') != url or config.get('NEXT_PUBLIC_APP_ENV') != environment:
raise ValueError('Public URL and environment must match the selected branch')
clerk_key = config.get('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', '')
if not clerk_key.startswith(clerk_prefix) or len(clerk_key) == len(clerk_prefix):
raise ValueError('Supply a full Clerk publishable key for the selected environment')
return [item for key, value in sorted(config.items()) for item in ('--build-arg', f'{key}={value}')]


if __name__ == '__main__':
try:
arguments = build_arguments(json.loads(os.environ['PUBLIC_BUILD_ENV']), os.environ['GITHUB_REF'], Path('Dockerfile').read_text())
except ValueError as error:
raise SystemExit(str(error)) from error
Path(os.environ['RUNNER_TEMP'], 'container-build-args').write_bytes(('\0'.join(arguments) + '\0').encode())
72 changes: 72 additions & 0 deletions scripts/container_build_config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pathlib import Path
import unittest

from container_build_config import build_arguments


DOCKERFILE = (Path(__file__).resolve().parent.parent / 'Dockerfile').read_text()
STAGING = {
'NEXT_PUBLIC_APP_URL': 'https://staging.emuready.com',
'NEXT_PUBLIC_APP_ENV': 'staging',
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY': 'pk_test_c3RhZ2luZy5leGFtcGxlLmNvbSQ=',
}
PRODUCTION = {
'NEXT_PUBLIC_APP_URL': 'https://www.emuready.com',
'NEXT_PUBLIC_APP_ENV': 'production',
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY': 'pk_live_cHJvZHVjdGlvbi5leGFtcGxlLmNvbSQ=',
}


class ContainerBuildConfigTest(unittest.TestCase):
def test_accepts_each_branch_configuration(self):
for branch, config in [('master', PRODUCTION), ('staging', STAGING)]:
with self.subTest(branch=branch):
args = build_arguments(config, 'refs/heads/' + branch, DOCKERFILE)
self.assertEqual(set(args[1::2]), {f'{key}={value}' for key, value in config.items()})
self.assertEqual(len(args), 2 * len(config))
self.assertEqual(args.count('--build-arg'), len(config))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

def test_rejects_individual_cross_environment_values(self):
for branch, expected, other in [('master', PRODUCTION, STAGING), ('staging', STAGING, PRODUCTION)]:
for key in expected:
with self.subTest(branch=branch, key=key):
with self.assertRaises(ValueError):
build_arguments({**expected, key: other[key]}, 'refs/heads/' + branch, DOCKERFILE)

def test_rejects_missing_or_prefix_only_key(self):
for key in ('', 'pk_test_'):
with self.subTest(key=key):
with self.assertRaises(ValueError):
build_arguments({**STAGING, 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY': key}, 'refs/heads/staging', DOCKERFILE)

def test_rejects_secret_and_undeclared_arguments(self):
for key in ('DATABASE_URL', 'BUILD_DATABASE_URL', 'CLERK_SECRET_KEY', 'NEXT_PUBLIC_UNKNOWN'):
with self.subTest(key=key):
with self.assertRaises(ValueError):
build_arguments({**STAGING, key: 'value'}, 'refs/heads/staging', DOCKERFILE)

def test_rejects_surrounding_whitespace_in_clerk_key(self):
for prefix, suffix in [(' ', ''), ('', ' '), ('\t', ''), ('', '\t')]:
with self.subTest(prefix=prefix, suffix=suffix):
with self.assertRaises(ValueError):
build_arguments({**STAGING, 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY': prefix + STAGING['NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY'] + suffix}, 'refs/heads/staging', DOCKERFILE)

def test_rejects_non_object_and_unsafe_values(self):
for config in ([], None, {**STAGING, 'NEXT_PUBLIC_APP_URL': False},
*({**STAGING, 'NEXT_PUBLIC_APP_URL': STAGING['NEXT_PUBLIC_APP_URL'] + char} for char in '\r\n\0')):
with self.subTest(config=config):
with self.assertRaises(ValueError):
build_arguments(config, 'refs/heads/staging', DOCKERFILE)

def test_rejects_other_branches(self):
with self.assertRaises(ValueError):
build_arguments(STAGING, 'refs/heads/feature', DOCKERFILE)

def test_keeps_shell_metacharacters_in_a_single_argument(self):
value = 'spaces $(echo test); *'
args = build_arguments({**STAGING, 'NEXT_PUBLIC_DISCORD_LINK': value}, 'refs/heads/staging', DOCKERFILE)
self.assertIn('NEXT_PUBLIC_DISCORD_LINK=' + value, args)


if __name__ == '__main__':
unittest.main()
Loading