8. CI/CD wiring
Stage 8 writes .github/workflows/deploy.yml, sets the three repository secrets it reads, pushes the workflow, and asks GitHub’s Actions API whether the pipeline is active.
Deploy first, wire second
Section titled “Deploy first, wire second”A workflow file does nothing until its secrets exist and the service is known. So the first deployment goes through the Render API in stage 5, and only then is the pipeline wired with what that returned.
The secrets
Section titled “The secrets”| Secret | Source |
|---|---|
RENDER_API_KEY |
The key stored by prodpilot setup |
RENDER_SERVICE_ID |
What stage 5 stored in .env.prodpilot |
APP_URL |
What stage 5 stored in .env.prodpilot |
Each value is encrypted on your machine as a libsodium sealed box against the repository’s public key, exactly as GitHub documents, before it is sent. A sealed box is one way: nothing in ProdPilot can decrypt what it sends.
Why not a deploy hook
Section titled “Why not a deploy hook”Render publishes a service’s deploy hook only in its dashboard; its API has no way to read it. A pipeline built on the hook would need someone to copy it by hand. So the workflow calls Render’s Trigger Deploy endpoint with the API key and the service id, which ProdPilot already holds.
The cost is stated rather than hidden: a Render API key is account wide, broader than a per service hook. If you want a narrower blast radius, give ProdPilot a key created for this purpose and revoke it when you like. See Security model.
The generated workflow
Section titled “The generated workflow”Real output of ProdPilot for an Express service, whose health path is /health. 1.0.0 and 1.1.0 write the same file:
name: Deploy
on: push: branches: [main] workflow_dispatch:
jobs: deploy: runs-on: ubuntu-latest env: RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }} steps: - name: Trigger the Render deploy id: trigger run: | since=$(date -u +%s) code=$(curl --silent --show-error -o deploy.json -w "%{http_code}" -X POST -H "Authorization: Bearer $RENDER_API_KEY" -H "Accept: application/json" "https://api.render.com/v1/services/$SERVICE_ID/deploys") if [ "$code" = "201" ]; then deploy=$(python3 -c 'import json, sys; print(json.load(sys.stdin).get("id") or "")' < deploy.json) elif [ "$code" = "202" ]; then curl --fail --silent --show-error -H "Authorization: Bearer $RENDER_API_KEY" -H "Accept: application/json" "https://api.render.com/v1/services/$SERVICE_ID/deploys?limit=100" > deploys.json deploy=$(python3 -c 'import json, sys; from datetime import datetime; when = lambda d: datetime.fromisoformat(d["createdAt"].replace("Z", "+00:00")).timestamp(); made = sorted((i["deploy"] for i in json.load(sys.stdin) if when(i["deploy"]) >= int(sys.argv[1]) - 30), key=when); print(made[-1]["id"] if made else "")' "$since" < deploys.json) else echo "Render refused the deploy with HTTP $code" cat deploy.json exit 1 fi if [ -z "$deploy" ]; then echo "Render accepted the deploy, but no deploy created since the trigger was found" exit 1 fi echo "deploy=$deploy" >> "$GITHUB_OUTPUT"
- name: Wait for that deploy to go live env: DEPLOY_ID: ${{ steps.trigger.outputs.deploy }} run: | for attempt in $(seq 1 40); do status=$(curl --fail --silent --show-error -H "Authorization: Bearer $RENDER_API_KEY" -H "Accept: application/json" "https://api.render.com/v1/services/$SERVICE_ID/deploys/$DEPLOY_ID" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("status") or "")' || true) case "$status" in live) echo "deploy $DEPLOY_ID is live" exit 0 ;; build_failed|canceled|deactivated|pre_deploy_failed|update_failed) echo "deploy $DEPLOY_ID ended as $status" exit 1 ;; esac echo "deploy $DEPLOY_ID is ${status:-not known yet}, waiting" sleep 15 done echo "deploy $DEPLOY_ID did not go live within 10 minutes" exit 1
- name: Check the deployed service answers on /health run: | for attempt in 1 2 3 4 5 6 7 8; do if curl --fail --silent --show-error "${{ secrets.APP_URL }}/health" > /dev/null; then echo "the service answered on /health" exit 0 fi echo "no answer yet, waiting" sleep 15 done echo "the service did not answer on /health" exit 1It waits for the very deploy it started, polled as stage 6 polls, before checking the service, because after a fixed wait the check could pass against the previous instance. For a React static site the check asks for the root instead of /health.
Observed on a live service
Section titled “Observed on a live service”An earlier version of this workflow redeployed the live demo from GitHub Actions. On 11 September 2026, ProdPilot deployed the demo, sudais-khalid/prodpilot-demo, and wired this stage; two later pushes to its main branch each ran the workflow, triggered a Render deploy and found the service answering on /health:
| Run | Commit | Result |
|---|---|---|
| 34652839494 | 53b8a14, chore: prodpilot production setup |
success |
| 34654340484 | 3057cc4, Describe the service at its root |
success |
That version triggered the deploy, waited a fixed 90 seconds, then checked /health, so its check could pass against the previous instance while the new deploy was still building. On 15 September it was changed to wait for the very deploy it started, the workflow shown above, and every release from 1.0.0rc1 on writes that version.
On 10 October 2026 the workflow shown above ran against the live demo for the first time, regenerated by ProdPilot and pushed as commit 1231dd2:
| Run | Deploy it started | Result |
|---|---|---|
| 38033962767 | dep-db4udenlot8c73d2915g, live 39 seconds after it was created |
success, /health answered on the first attempt |
From the run’s log:
deploy dep-db4udenlot8c73d2915g is build_in_progress, waitingdeploy dep-db4udenlot8c73d2915g is update_in_progress, waitingdeploy dep-db4udenlot8c73d2915g is livethe service answered on /healthRender’s own automatic deploy was on during the run, and Render listed one deploy for the pushed commit, the one the workflow started. In general a push can start a second deploy beside the workflow’s; the workflow waits for the newer one. See Known limitations.