From 9cd5962f4fba6bf0adcc61e563d31ebea40be283 Mon Sep 17 00:00:00 2001 From: WiderDev Date: Wed, 22 Jul 2026 13:43:27 +0300 Subject: [PATCH 1/2] Add Render deploy step for server and nlp-service images Picks Render "deploy an existing image" as the hosting target for both GHCR-published images (server on :3000, nlp-service on :8000) since it natively supports pulling from a private registry and exposes a deploy-hook API that CI can trigger with a specific image tag. Fly.io and AWS were ruled out as heavier fits for this project's current stage (see docs/deployment.md for the full comparison). The docker-publish workflow gains deploy-server/deploy-nlp-service jobs that POST the commit-SHA-tagged image to each service's Render deploy hook. Both jobs no-op until RENDER_DEPLOY_HOOK_SERVER / RENDER_DEPLOY_HOOK_NLP_SERVICE secrets exist, so this is safe to merge ahead of the one-time Render setup, which is documented in docs/deployment.md along with the required env vars and secrets. --- .github/workflows/docker-publish.yml | 34 +++++++++++ docs/deployment.md | 89 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 docs/deployment.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 02aeed8..c1ef87c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -63,3 +63,37 @@ jobs: tags: | ${{ steps.image.outputs.name }}:latest ${{ steps.image.outputs.name }}:${{ github.sha }} + + # Deploy jobs trigger a Render deploy hook pinned to the image tag that was + # just published (see docs/deployment.md for the one-time Render setup). + # They no-op until the corresponding secret is configured, so this workflow + # is safe to merge ahead of that setup. + deploy-server: + name: Deploy server to Render + needs: build-server + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && secrets.RENDER_DEPLOY_HOOK_SERVER != '' + steps: + - name: Trigger Render deploy hook + env: + REGISTRY: ${{ env.REGISTRY }} + DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_SERVER }} + IMAGE_TAG: ${{ github.sha }} + run: | + curl -fsS -X POST \ + "${DEPLOY_HOOK_URL}&imgURL=${REGISTRY}/${GITHUB_REPOSITORY,,}/server:${IMAGE_TAG}" + + deploy-nlp-service: + name: Deploy nlp-service to Render + needs: build-nlp-service + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && secrets.RENDER_DEPLOY_HOOK_NLP_SERVICE != '' + steps: + - name: Trigger Render deploy hook + env: + REGISTRY: ${{ env.REGISTRY }} + DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_NLP_SERVICE }} + IMAGE_TAG: ${{ github.sha }} + run: | + curl -fsS -X POST \ + "${DEPLOY_HOOK_URL}&imgURL=${REGISTRY}/${GITHUB_REPOSITORY,,}/nlp-service:${IMAGE_TAG}" diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a80ec58 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,89 @@ +# Deployment + +## Target: Render, "Deploy an existing image" (Web Services) + +`server` (Express, port 3000) and `nlp-service` (FastAPI, port 8000) are both +stateless HTTP services with no local database — `server.ts` talks to an +external data platform (`MUJARRAD_*` env vars) and both services call the +Gemini API (`GEMINI_API_KEY`). Neither has a persistence requirement that +would push this toward a heavier platform. + +Options considered, and why they were ruled out: + +- **Vercel** — built for serverless/edge functions and static frontends, not + a great fit for two always-on containers with their own process/port. +- **Fly.io** — good for plain Docker apps, but it has no native way to pull + from a third-party private registry like GHCR. The supported workaround is + to `docker pull` the GHCR image in CI and re-push it into Fly's own + registry before `flyctl deploy` — an extra hop that duplicates image + storage for no benefit here. +- **AWS (ECS/Fargate)** — can pull directly from a private registry, but + needs a VPC, cluster, task definitions, and an ALB provisioned first. That + is more standing infrastructure than this repo's current stage justifies. +- **Render** — supports "Deploy an existing image" directly from a private + GHCR image via a stored registry credential, and gives each service a + deploy-hook URL that CI can call with a specific image tag. This is the + smallest amount of new infrastructure that satisfies "pull the published + GHCR image and run it." + +Both services deploy to Render as separate Web Services running the +prebuilt images published by `.github/workflows/docker-publish.yml` +(`ghcr.io/wider-community/resolve-ai/server` and `.../nlp-service`). + +## One-time setup (needs a human with Render account access) + +This repo's CI cannot create Render resources or hold Render/GHCR +credentials, so the following is a manual setup, done once, by whoever owns +(or is granted) the Wider-Community Render account: + +1. **Registry credential** — in the Render workspace, go to + Settings → Container Registry Credentials → add a GHCR credential using a + GitHub Personal Access Token with `read:packages` scope (a token from a + dedicated bot/service account is preferable to a personal one — the + built-in per-workflow `GITHUB_TOKEN` can't be used here since it's + ephemeral and can't be typed into the Render dashboard). + +2. **`resolve-ai-server` Web Service** — "Deploy an existing image": + - Image: `ghcr.io/wider-community/resolve-ai/server:latest`, using the + credential from step 1. + - Port: `3000` + - Env vars: `GEMINI_API_KEY`, `MUJARRAD_API_PUBLIC_KEY`, + `MUJARRAD_API_SECRET_KEY`, `MUJARRAD_SPACE_SLUG` — values come from + whoever owns those credentials; they don't live in this repo or CI. + +3. **`resolve-ai-nlp-service` Web Service** — "Deploy an existing image": + - Image: `ghcr.io/wider-community/resolve-ai/nlp-service:latest`, same + credential. + - Port: `8000` + - Env vars: `GEMINI_API_KEY` + +4. **Deploy hooks** — on each service, Settings → Deploy Hook, copy the URL, + and add it as a GitHub Actions repository secret: + - `RENDER_DEPLOY_HOOK_SERVER` + - `RENDER_DEPLOY_HOOK_NLP_SERVICE` + +Until these secrets exist, the deploy jobs added in this PR skip themselves +(see below) — merging this PR does not require the Render services to exist +yet, but nothing will actually deploy until someone completes the steps +above. + +## CI/CD wiring + +`.github/workflows/docker-publish.yml` gets a `deploy-server` and a +`deploy-nlp-service` job, each running after its image finishes publishing +on a push to `main`. Each job POSTs to its Render deploy hook with an +`imgURL` query param pinned to the commit SHA that was just built, so the +service that comes up always matches the exact commit that triggered the +build rather than a possibly-stale `:latest`. + +Each deploy job is skipped (not failed) when its secret isn't set yet, so +this workflow change is safe to merge ahead of the Render setup above. + +## Follow-ups intentionally out of scope here + +- Render's free tier spins services down after inactivity (slow cold + start on the next request). Fine for now; revisit if this becomes + user-facing with latency requirements. +- No staging environment — both services deploy straight to what Render + calls production on every push to `main`, matching the rest of this + repo's CI/CD (no branch protection yet either — see WID-216). From ff1dc67413d8c195a4a4a3722f74f9d1fcccc941 Mon Sep 17 00:00:00 2001 From: WiderDev Date: Wed, 22 Jul 2026 20:02:19 +0300 Subject: [PATCH 2/2] Fix invalid secrets context in deploy job if-conditions GitHub Actions does not expose the `secrets` context to job-level `if:` conditions, so `if: ... && secrets.RENDER_DEPLOY_HOOK_* != ''` made the whole docker-publish workflow invalid (actionlint: "context 'secrets' is not allowed here"). That would have broken all image publishing on main, not just skipped the deploy jobs. Gate the deploy jobs on `github.ref == 'refs/heads/main'` only, and move the empty-secret check into the step's shell (secrets are valid in step `env:`), exiting 0 as a no-op when the deploy hook secret isn't set. This preserves the "safe to merge ahead of Render setup" behavior with a mechanism GitHub actually supports. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docker-publish.yml | 18 ++++++++++++++---- docs/deployment.md | 11 +++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c1ef87c..e03bb1a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -66,13 +66,15 @@ jobs: # Deploy jobs trigger a Render deploy hook pinned to the image tag that was # just published (see docs/deployment.md for the one-time Render setup). - # They no-op until the corresponding secret is configured, so this workflow - # is safe to merge ahead of that setup. + # The hook URL is a secret; when it isn't configured the step no-ops (exits + # 0 without deploying), so this workflow is safe to merge ahead of that + # setup. Note: the `secrets` context is NOT available in a job-level `if:`, + # so the presence check is done in the step's shell, not the `if:`. deploy-server: name: Deploy server to Render needs: build-server runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && secrets.RENDER_DEPLOY_HOOK_SERVER != '' + if: github.ref == 'refs/heads/main' steps: - name: Trigger Render deploy hook env: @@ -80,6 +82,10 @@ jobs: DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_SERVER }} IMAGE_TAG: ${{ github.sha }} run: | + if [ -z "$DEPLOY_HOOK_URL" ]; then + echo "RENDER_DEPLOY_HOOK_SERVER not set; skipping deploy (see docs/deployment.md)." + exit 0 + fi curl -fsS -X POST \ "${DEPLOY_HOOK_URL}&imgURL=${REGISTRY}/${GITHUB_REPOSITORY,,}/server:${IMAGE_TAG}" @@ -87,7 +93,7 @@ jobs: name: Deploy nlp-service to Render needs: build-nlp-service runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && secrets.RENDER_DEPLOY_HOOK_NLP_SERVICE != '' + if: github.ref == 'refs/heads/main' steps: - name: Trigger Render deploy hook env: @@ -95,5 +101,9 @@ jobs: DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_NLP_SERVICE }} IMAGE_TAG: ${{ github.sha }} run: | + if [ -z "$DEPLOY_HOOK_URL" ]; then + echo "RENDER_DEPLOY_HOOK_NLP_SERVICE not set; skipping deploy (see docs/deployment.md)." + exit 0 + fi curl -fsS -X POST \ "${DEPLOY_HOOK_URL}&imgURL=${REGISTRY}/${GITHUB_REPOSITORY,,}/nlp-service:${IMAGE_TAG}" diff --git a/docs/deployment.md b/docs/deployment.md index a80ec58..d9579ba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -62,8 +62,8 @@ credentials, so the following is a manual setup, done once, by whoever owns - `RENDER_DEPLOY_HOOK_SERVER` - `RENDER_DEPLOY_HOOK_NLP_SERVICE` -Until these secrets exist, the deploy jobs added in this PR skip themselves -(see below) — merging this PR does not require the Render services to exist +Until these secrets exist, the deploy jobs added in this PR no-op (see +below) — merging this PR does not require the Render services to exist yet, but nothing will actually deploy until someone completes the steps above. @@ -76,8 +76,11 @@ on a push to `main`. Each job POSTs to its Render deploy hook with an service that comes up always matches the exact commit that triggered the build rather than a possibly-stale `:latest`. -Each deploy job is skipped (not failed) when its secret isn't set yet, so -this workflow change is safe to merge ahead of the Render setup above. +Each deploy job no-ops (exits 0 without deploying, not failing) when its +deploy-hook secret isn't set yet, so this workflow change is safe to merge +ahead of the Render setup above. The empty-secret check runs in the step's +shell rather than a job-level `if:`, because GitHub Actions does not expose +the `secrets` context to job-level `if:` conditions. ## Follow-ups intentionally out of scope here