From e3770ceb575dd988e64385b252d79fb734743df3 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Fri, 30 Oct 2026 07:57:14 -0700 Subject: [PATCH] Deploy with Komodo: main to staging, vX.Y.Z to prod CI now builds the API and website images after the tests, pushes them to git.t5de.dev, and deploys them through Komodo (.gitea/scripts/deploy.py): pushes to main as :main to hub.staging.t5de.dev, vX.Y.Z tags as :X.Y.Z to hub.t5de.dev, with the version pinned in t5de/t5de-infra. The API image installs with --linker hoisted: Bun's isolated layout keeps the workspace links (@t5de/db) in each package's own node_modules, which the image didn't copy, so the API couldn't start. Co-Authored-By: Claude Opus 5.5 --- .gitea/scripts/deploy.py | 97 ++++++++++++++++++++++++++++++++++++++++ .gitea/workflows/ci.yml | 47 ++++++++++++++++++- apps/api/Dockerfile | 4 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .gitea/scripts/deploy.py diff --git a/.gitea/scripts/deploy.py b/.gitea/scripts/deploy.py new file mode 100644 index 0000000..f8eeebd --- /dev/null +++ b/.gitea/scripts/deploy.py @@ -0,0 +1,97 @@ +"""Deploy a build with Komodo. Run by ci.yml after the images are pushed. + + deploy.py + +A vX.Y.Z tag deploys X.Y.Z to prod: the version is committed to t5de/t5de-infra (stacks//prod.env), then +Komodo deploys -prod from it. Pushes to main deploy -staging, which runs the :main image. + +Env: KOMODO_API_KEY and KOMODO_API_SECRET (a Komodo service user that can only deploy), INFRA_TOKEN (writes +t5de/t5de-infra; releases only), KOMODO_URL (default http://10.10.10.239:9120). +""" + +import base64 +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + +KOMODO_URL = os.environ.get("KOMODO_URL") or "http://10.10.10.239:9120" +GITEA_URL = os.environ.get("GITHUB_SERVER_URL", "https://git.t5de.dev").rstrip("/") +INFRA_REPO = "t5de/t5de-infra" +RELEASE_TAG = re.compile(r"v(\d+\.\d+\.\d+)") + + +def call(method: str, url: str, body: dict | None = None, headers: dict | None = None) -> dict: + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", **(headers or {})}) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read() or b"{}") + except urllib.error.HTTPError as e: + sys.exit(f"{method} {url}: {e.code} {e.read().decode(errors='replace')}") + + +def komodo(kind: str, type_: str, params: dict) -> dict: + keys = {"X-Api-Key": os.environ["KOMODO_API_KEY"], "X-Api-Secret": os.environ["KOMODO_API_SECRET"]} + return call("POST", f"{KOMODO_URL}/{kind}", {"type": type_, "params": params}, keys) + + +def pin_version(infra_dir: str, variable: str, version: str) -> None: + """Set = in the infra repo's prod.env, committing only if it changed.""" + path = f"stacks/{infra_dir}/prod.env" + url = f"{GITEA_URL}/api/v1/repos/{INFRA_REPO}/contents/{path}" + auth = {"Authorization": f"token {os.environ['INFRA_TOKEN']}"} + current = call("GET", f"{url}?ref=main", headers=auth) + text = base64.b64decode(current["content"]).decode() + updated, n = re.subn(rf"(?m)^{variable}=.*$", f"{variable}={version}", text) + if n != 1: + sys.exit(f"{path}: expected one {variable}= line, found {n}") + if updated == text: + print(f"{path} already has {variable}={version}") + return + release_url = f"{GITEA_URL}/{os.environ.get('GITHUB_REPOSITORY', '')}/releases/tag/v{version}" + call("PUT", url, { + "branch": "main", + "sha": current["sha"], + "content": base64.b64encode(updated.encode()).decode(), + "message": f"{infra_dir} {version} to prod\n\nFrom {release_url}", + }, auth) + print(f"Committed {variable}={version} to {INFRA_REPO}/{path}") + + +def deploy(stack: str) -> None: + update = komodo("execute", "DeployStack", {"stack": stack}) + update_id = update["_id"]["$oid"] + print(f"Deploying {stack} (Komodo update {update_id})") + deadline = time.time() + 15 * 60 + while time.time() < deadline: + update = komodo("read", "GetUpdate", {"id": update_id}) + if update.get("status") == "Complete": + for log in update.get("logs", []): + print(f"--- {log.get('stage')}: {'ok' if log.get('success') else 'FAILED'}") + if not log.get("success"): + print(log.get("stdout", ""), log.get("stderr", ""), sep="\n") + if not update.get("success"): + sys.exit(f"{stack}: deploy failed") + print(f"{stack}: deployed") + return + time.sleep(5) + sys.exit(f"{stack}: deploy didn't finish in 15 minutes") + + +def main() -> None: + stack, infra_dir, variable = sys.argv[1:4] + ref_name = os.environ.get("GITHUB_REF_NAME", "") + release = os.environ.get("GITHUB_REF_TYPE") == "tag" and RELEASE_TAG.fullmatch(ref_name) + if release: + pin_version(infra_dir, variable, release.group(1)) + deploy(f"{stack}-prod") + else: + deploy(f"{stack}-staging") + + +if __name__ == "__main__": + main() diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a25d450..3126720 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,9 +1,11 @@ -# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub. +# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub, then deploys it: main to +# staging, vX.Y.Z tags to prod. name: CI on: push: branches: [main] + tags: ["v*"] pull_request: workflow_dispatch: @@ -32,3 +34,46 @@ jobs: - name: Build run: bun run build + + # Images for Komodo, then the deploy: main → :main and :sha-, deployed to staging; vX.Y.Z → :X.Y.Z and + # :latest, pinned in t5de/t5de-infra and deployed to prod (.gitea/scripts/deploy.py). + deploy: + needs: test + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Image tags + id: tags + run: | + if [ "$GITHUB_REF_TYPE" = tag ]; then + version=$(echo "$GITHUB_REF_NAME" | sed -nE 's/^v([0-9]+\.[0-9]+\.[0-9]+)$/\1/p') + [ -n "$version" ] || { echo "Not a vX.Y.Z tag: $GITHUB_REF_NAME"; exit 1; } + echo "tags=$version latest" >> "$GITHUB_OUTPUT" + else + echo "tags=main sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push + env: + TAGS: ${{ steps.tags.outputs.tags }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u "$GITHUB_ACTOR" --password-stdin + for app in api web; do + image=git.t5de.dev/t5de/t5de-hub-$app + args=(-f apps/$app/Dockerfile + --label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + --label "org.opencontainers.image.revision=$GITHUB_SHA") + for tag in $TAGS; do args+=(-t "$image:$tag"); done + docker build "${args[@]}" . + for tag in $TAGS; do docker push "$image:$tag"; done + done + + - name: Deploy + env: + KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }} + KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }} + INFRA_TOKEN: ${{ secrets.INFRA_TOKEN }} + run: python3 .gitea/scripts/deploy.py hub t5de-hub HUB_VERSION diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1b04dde..4261977 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -5,7 +5,9 @@ COPY package.json bun.lock ./ COPY apps/api/package.json apps/api/ COPY apps/web/package.json apps/web/ COPY packages/db/package.json packages/db/ -RUN bun install --frozen-lockfile --production --filter @t5de/api +# Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated +# layout keeps links in each package's own node_modules. +RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted FROM oven/bun:1.4.4-slim WORKDIR /app