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 <[email protected]>
This commit is contained in:
1 parent
97ce2be569
commit
e3770ceb57
3 files changed
+146
-2
No files matched your search
@@ -0,0 +1,97 @@
|
|||||||
|
"""Deploy a build with Komodo. Run by ci.yml after the images are pushed.
|
||||||
|
|
||||||
|
deploy.py <stack> <infra dir> <version variable>
|
||||||
|
|
||||||
|
A vX.Y.Z tag deploys X.Y.Z to prod: the version is committed to t5de/t5de-infra (stacks/<infra dir>/prod.env), then
|
||||||
|
Komodo deploys <stack>-prod from it. Pushes to main deploy <stack>-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 <variable>=<version> 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()
|
||||||
+46
-1
@@ -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
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -32,3 +34,46 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
# Images for Komodo, then the deploy: main → :main and :sha-<commit>, 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
|
||||||
+3
-1
@@ -5,7 +5,9 @@ COPY package.json bun.lock ./
|
|||||||
COPY apps/api/package.json apps/api/
|
COPY apps/api/package.json apps/api/
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY packages/db/package.json packages/db/
|
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
|
FROM oven/bun:1.4.4-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
Reference in new issue
Block a user