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
13 changes: 13 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@
":separatePatchReleases",
],
suppressNotifications: ["prIgnoreNotification"],
customManagers: [
{
// Bump GHOST_VERSION in .env.example when the user pins an explicit tag.
// The line is commented by default; this only fires once uncommented.
customType: "regex",
fileMatch: ["^\\.env\\.example$"],
matchStrings: [
"^GHOST_VERSION=(?<currentValue>\\S+)\\s*$",
],
datasourceTemplate: "docker",
depNameTemplate: "ghost",
},
],
packageRules: [
{
description: "Group ActivityPub containers together",
Expand Down
242 changes: 226 additions & 16 deletions .github/scripts/patch.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,234 @@
#!/usr/bin/env python3
"""Coolify compatibility patch for TryGhost/ghost-docker upstream.

Runs after `git reset --hard upstream/main` in the daily sync workflow and
rewrites the upstream compose.yml to deploy cleanly on Coolify:

- Removes the Caddy reverse proxy (Coolify's Traefik handles ingress)
- Switches Ghost URL / MySQL credentials to Coolify SERVICE_* magic vars
- Declares SERVICE_URL_<NAME>_<PORT> so Traefik discovers the right port
- Adds a Ghost healthcheck for Coolify's UI indicator
- Deletes caddy/ and strips Caddy refs from .env.example
- Overwrites README.md with the Coolify-specific README.coolify.md

All edits are idempotent: re-running on already-patched input is a no-op.
Each substitution fails loudly when the anchor is missing, so upstream
restructuring surfaces as a nonzero exit rather than silent breakage.
"""

from __future__ import annotations

import pathlib
import re
import shutil
import sys


def fail(msg: str) -> None:
print(f"patch.py: {msg}", file=sys.stderr)
sys.exit(1)


def swap(content: str, old: str, new: str, label: str) -> str:
"""Single-occurrence replacement with idempotency and loud failure.
Checks `new` before `old` so that superset replacements (where `old`
is a substring of `new`) don't double-apply."""
if new in content:
return content
if old in content:
n = content.count(old)
if n != 1:
fail(f"{label}: expected 1 occurrence of old anchor, found {n}")
return content.replace(old, new, 1)
fail(f"{label}: neither old nor new anchor present — upstream changed?")


def swap_all(content: str, old: str, new: str, label: str, expected: int) -> str:
"""Multi-occurrence replacement with idempotency and loud failure."""
n_new = content.count(new)
if n_new == expected and old not in content:
return content
if old in content:
n = content.count(old)
if n != expected:
fail(f"{label}: expected {expected} occurrences of old anchor, found {n}")
return content.replace(old, new)
fail(f"{label}: found {n_new} of new anchor, expected {expected} — upstream changed?")


def patch_compose() -> None:
path = pathlib.Path("compose.yml")
if not path.exists():
fail("compose.yml not found — run from repo root")
c = path.read_text()

# Caddy service block (Coolify's Traefik replaces it)
c = re.sub(r"^ caddy:.*?(?=^ [a-z])", "", c, flags=re.MULTILINE | re.DOTALL)
c = c.replace(" caddy_data:\n", "")
c = c.replace(" caddy_config:\n", "")
c = re.sub(r"^\s+- caddy\n", "", c, flags=re.MULTILINE)

# Ghost URL → Coolify magic
c = swap(
c,
"url: https://${DOMAIN:?DOMAIN environment variable is required}\n",
"url: $SERVICE_URL_GHOST\n",
"ghost.url",
)

# MySQL credentials → Coolify magic
c = swap(
c,
"MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required}",
"MYSQL_ROOT_PASSWORD: $SERVICE_PASSWORD_MYSQLROOT",
"MYSQL_ROOT_PASSWORD",
)
c = swap_all(
c,
"MYSQL_USER: ${DATABASE_USER:-ghost}",
"MYSQL_USER: $SERVICE_USER_MYSQL",
"MYSQL_USER",
expected=2,
)
c = swap(
c,
"database__connection__user: ${DATABASE_USER:-ghost}",
"database__connection__user: $SERVICE_USER_MYSQL",
"database__connection__user",
)
c = swap_all(
c,
"MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}",
"MYSQL_PASSWORD: $SERVICE_PASSWORD_MYSQL",
"MYSQL_PASSWORD",
expected=2,
)
c = swap(
c,
"database__connection__password: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}",
"database__connection__password: $SERVICE_PASSWORD_MYSQL",
"database__connection__password",
)
c = swap(
c,
"MYSQL_DB: mysql://${DATABASE_USER:-ghost}:${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub",
"MYSQL_DB: mysql://$SERVICE_USER_MYSQL:$SERVICE_PASSWORD_MYSQL@tcp(db:3306)/activitypub",
"activitypub-migrate.MYSQL_DB",
)

# Tinybird tracker endpoint → analytics FQDN (optional profile)
c = swap(
c,
"tinybird__tracker__endpoint: https://${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit",
"tinybird__tracker__endpoint: $SERVICE_URL_ANALYTICS/api/v1/page_hit",
"tinybird__tracker__endpoint",
)

# ActivityPub image storage URL → Ghost origin (images must stay on primary domain)
c = swap(
c,
"LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub",
"LOCAL_STORAGE_HOSTING_URL: $SERVICE_URL_GHOST/content/images/activitypub",
"activitypub.LOCAL_STORAGE_HOSTING_URL",
)

# Ghost healthcheck (inserted between env_file and environment)
ghost_env_anchor = (
" # This is required to import current config when migrating\n"
" env_file:\n"
" - .env\n"
" environment:\n"
)
ghost_with_hc = (
" # This is required to import current config when migrating\n"
" env_file:\n"
" - .env\n"
" healthcheck:\n"
' test: ["CMD", "wget", "-qO-", "http://localhost:2368/ghost/api/admin/site/"]\n'
" interval: 30s\n"
" timeout: 5s\n"
" retries: 5\n"
" start_period: 60s\n"
" environment:\n"
)
c = swap(c, ghost_env_anchor, ghost_with_hc, "ghost.healthcheck")

# SERVICE_URL_<NAME>_<PORT> declarations — Coolify uses these to wire Traefik
c = swap(
c,
" url: $SERVICE_URL_GHOST\n",
' url: $SERVICE_URL_GHOST\n SERVICE_URL_GHOST_2368: ""\n',
"ghost.SERVICE_URL_GHOST_2368",
)
c = swap(
c,
" environment:\n NODE_ENV: production\n PROXY_TARGET:",
' environment:\n NODE_ENV: production\n SERVICE_URL_ANALYTICS_3000: ""\n PROXY_TARGET:',
"traffic-analytics.SERVICE_URL_ANALYTICS_3000",
)
c = swap(
c,
" NODE_ENV: production\n MYSQL_HOST: db\n",
' NODE_ENV: production\n SERVICE_URL_ACTIVITYPUB_8080: ""\n MYSQL_HOST: db\n',
"activitypub.SERVICE_URL_ACTIVITYPUB_8080",
)

path.write_text(c)


def patch_env_example() -> None:
path = pathlib.Path(".env.example")
if not path.exists():
return
e = path.read_text()

# DOMAIN is supplied by Coolify's SERVICE_URL_GHOST — no need to set here
e = re.sub(
r"# Ghost domain\n# Custom public domain Ghost will run on\nDOMAIN=example\.com\n\n",
"",
e,
)
# Drop the Caddyfile reference in the ADMIN_DOMAIN comment
e = e.replace(
"# If you have Ghost Admin setup on a separate domain uncomment the line below and add the domain\n"
"# You also need to uncomment the corresponding block in your Caddyfile\n",
"# If Ghost Admin lives on its own domain, add it as a second FQDN on the\n"
"# `ghost` service in the Coolify UI, or uncomment the line below for local runs.\n",
)
# Coolify owns ingress ports
e = re.sub(
r"# Ghost ports\n# Ports where Ghost will listen for HTTP traffic\.\n"
r"# Change these if the default ports are in use, or if Ghost is behind a reverse proxy\.\n"
r"HTTP_PORT=80\nHTTPS_PORT=443\n\n",
"",
e,
)
# Coolify auto-generates DB credentials via SERVICE_USER_MYSQL / SERVICE_PASSWORD_MYSQL
e = re.sub(
r"# Database settings\n# All database settings must not be changed once the database is initialised\n"
r"DATABASE_ROOT_PASSWORD=reallysecurerootpassword\n"
r"# DATABASE_USER=optionalusername\n"
r"DATABASE_PASSWORD=ghostpassword\n\n",
"",
e,
)

with open("compose.yml", "r") as f:
content = f.read()
path.write_text(e)

# Fix Coolify variable parsing
content = content.replace("${ADMIN_DOMAIN:-}", "${ADMIN_DOMAIN}")
content = content.replace("${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}", "${ADMIN_DOMAIN}")

# Remove caddy service block entirely (Traefik replaces it)
content = re.sub(r"^ caddy:.*?(?=^ [a-z])", "", content, flags=re.MULTILINE | re.DOTALL)
def overwrite_readme() -> None:
src = pathlib.Path("README.coolify.md")
if src.exists():
pathlib.Path("README.md").write_text(src.read_text())

# Remove caddy volumes
content = content.replace(" caddy_data:\n", "")
content = content.replace(" caddy_config:\n", "")

# Remove caddy from any depends_on
content = re.sub(r"^\s+caddy:\s*\n\s+condition:.*\n", "", content, flags=re.MULTILINE)
content = re.sub(r"^\s+- caddy\n", "", content, flags=re.MULTILINE)
def main() -> None:
patch_compose()
shutil.rmtree("caddy", ignore_errors=True)
patch_env_example()
overwrite_readme()
print("Patched compose.yml successfully")

with open("compose.yml", "w") as f:
f.write(content)

print("Patched compose.yml successfully")
if __name__ == "__main__":
main()
71 changes: 71 additions & 0 deletions .github/scripts/test-patch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Test patch.py against a fresh upstream checkout.
#
# Mimics what the nightly sync workflow does: fetch upstream compose.yml +
# .env.example, apply patch.py, validate the output with `docker compose
# config`, then re-apply to assert idempotency.
#
# Run locally (from repo root) or in CI. Exits non-zero on any failure.

set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
SCRATCH="$(mktemp -d)"
trap 'rm -rf "$SCRATCH"' EXIT

cd "$SCRATCH"

echo "→ fetching upstream"
curl -fsSL https://raw.githubusercontent.com/TryGhost/ghost-docker/main/compose.yml -o compose.yml
curl -fsSL https://raw.githubusercontent.com/TryGhost/ghost-docker/main/.env.example -o .env.example
mkdir -p caddy/snippets
touch caddy/Caddyfile.example caddy/snippets/Logging .env
cp "$REPO_ROOT/README.coolify.md" .
cp "$REPO_ROOT/.github/scripts/patch.py" .

echo "→ run 1: apply patch"
python3 patch.py

echo "→ validate: docker compose config"
docker compose -f compose.yml config --quiet

echo "→ assert SERVICE_URL declarations present"
grep -q 'SERVICE_URL_GHOST_2368: ""' compose.yml
grep -q 'SERVICE_URL_ANALYTICS_3000: ""' compose.yml
grep -q 'SERVICE_URL_ACTIVITYPUB_8080: ""' compose.yml

echo "→ assert Ghost healthcheck injected"
grep -q 'localhost:2368/ghost/api/admin/site' compose.yml

echo "→ assert ADMIN_DOMAIN kept upstream :+ conditional"
# shellcheck disable=SC2016 # literal compose syntax, no shell expansion wanted
grep -qF '${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}' compose.yml

assert_absent() {
if grep -qE "$1" compose.yml; then
echo "unexpected match for /$1/ in compose.yml" >&2
exit 1
fi
}

echo "→ assert DOMAIN and DATABASE_* refs gone from compose.yml"
assert_absent '\$\{DOMAIN(:[^}]*)?\}'
assert_absent 'DATABASE_PASSWORD'
assert_absent 'DATABASE_ROOT_PASSWORD'
assert_absent 'DATABASE_USER'

echo "→ assert caddy/ deleted"
[ ! -e caddy ]

echo "→ assert README.md overwritten with Coolify content"
head -1 README.md | grep -q 'Ghost on Coolify'

echo "→ run 2: idempotency"
cp compose.yml compose.r1.yml
cp .env.example env.r1
python3 patch.py
diff -u compose.r1.yml compose.yml
diff -u env.r1 .env.example

echo ""
echo "✓ all checks passed"
15 changes: 15 additions & 0 deletions .github/workflows/patch-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Test patch.py

on:
pull_request:
push:
branches:
- main

jobs:
test-patch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Apply patch to upstream + validate + check idempotency
run: bash .github/scripts/test-patch.sh
8 changes: 7 additions & 1 deletion .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ jobs:
- name: Patch compose.yml
run: python3 .github/scripts/patch.py

- name: Validate patched compose.yml
run: |
touch .env
docker compose -f compose.yml config --quiet
rm -f .env

- name: Commit and push
run: |
git config user.name "GitHub Action"
git config user.email "action@github.com"
git add -A
git diff --staged --quiet || git commit -m "Auto-patch compose.yml for Coolify compatibility"
git push --force
git push --force-with-lease
Loading
Loading