Repository navigation
fix(ci): build staging images with isolated public settings #506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
| 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 | ||||||
|
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. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This instruction is redundant with the image build: the Dockerfile's Prompt for AI agents
Suggested change
|
||||||
| - Use `/api/health/ready` for deployment health checks and `/api/health/live` for process liveness. | ||||||
|
|
||||||
| ## Production configuration | ||||||
|
|
@@ -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`). | ||||||
|
|
||||||
| 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. |
| 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()) |
| 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)) | ||
|
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() | ||
There was a problem hiding this comment.
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-upssection 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