Skip to content

Promoting from staging in CI

Goal: every merge to main goes live on staging by itself, is checked with a real request, and reaches production only after somebody approves it. The same commit is deployed to both, so production gets exactly what staging was tested with.

GraphPilot has no promotion between services: a deployment builds from the files in front of it. So “promote” means deploying the same files from the same commit to a second service, and the approval lives in your CI. The pieces are on deploy from CI and staging and rollback; this recipe puts them into one workflow.

The running example is the service pair shop-api-staging and shop-api, deployed from one repository that holds schema.graphql, gpilot.toml, and the client’s persisted query manifest at dist/persisted-documents.json.

  • Both services, created as on staging and rollback.
  • An organization API key as the repository secret GPILOT_TOKEN, with the permissions to deploy and to push persisted queries to both services. See jobs.
  • A GitHub environment called production with required reviewers. That is the approval gate.
.github/workflows/graphpilot.yml
name: GraphPilot
on:
pull_request:
push:
branches: [main]
concurrency:
group: graphpilot-shop-api
cancel-in-progress: false
env:
GPILOT_TOKEN: ${{ secrets.GPILOT_TOKEN }}
jobs:
check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install the gpilot CLI
run: |
curl --proto '=https' --tlsv1.2 -fsSL https://get.graphpilot.io | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Check the persisted query manifest
run: gpilot pq push ./dist/persisted-documents.json --dry-run --service shop-api
staging:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install the gpilot CLI
run: |
curl --proto '=https' --tlsv1.2 -fsSL https://get.graphpilot.io | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Deploy staging
run: gpilot deploy --service shop-api-staging --yes
- name: Push persisted queries
run: gpilot pq push ./dist/persisted-documents.json --service shop-api-staging
- name: Smoke test
run: ./scripts/smoke.sh https://shop-api-staging.k7m2qp.graphpilot.cloud
production:
needs: staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v5
- name: Install the gpilot CLI
run: |
curl --proto '=https' --tlsv1.2 -fsSL https://get.graphpilot.io | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Deploy production
run: gpilot deploy --service shop-api --yes
- name: Push persisted queries
run: gpilot pq push ./dist/persisted-documents.json --service shop-api
- name: Smoke test
run: ./scripts/smoke.sh https://shop-api.k7m2qp.graphpilot.cloud

Replace k7m2qp with your organization’s hostname label.

  • check runs on every pull request. --dry-run parses and verifies the manifest and writes nothing, so a broken manifest fails the pull request instead of the release.
  • staging deploys on every merge. gpilot deploy waits for the deployment and exits 0 only when it completed, so a failed build stops the workflow before the smoke test. The exit codes say why.
  • production waits for staging, then for a reviewer of the production environment. It checks out the same commit, so it deploys the same files staging was tested with.
  • Persisted queries are pushed to each service before your client release, so no client ever sends a hash the registry cannot resolve. pq push only adds, so pushing a manifest twice is harmless. Release the client after the production job, never before it.
scripts/smoke.sh
#!/usr/bin/env bash
set -euo pipefail
host="$1"
body=$(curl -sS --fail-with-body "$host/graphql" \
-H 'content-type: application/json' \
-H "GP-Correlation-ID: smoke-$GITHUB_SHA" \
-d '{"query":"query Smoke { products(first: 1) { id name } }"}')
echo "$body" | jq -e '.errors == null and (.data.products | length) == 1'

--fail-with-body fails on an HTTP error status, and jq -e fails on a GraphQL error, which arrives with 200. Pick a query that exercises your origin and your schema together: a broken origin fails it, and so does a schema whose new field your origin does not know yet.

The GP-Correlation-ID header labels the request. On Airliner (Business) and above, add a step that waits for it in telemetry, which proves the request was recorded on the service you meant:

Terminal window
gpilot requests --service shop-api-staging --correlation-id "smoke-$GITHUB_SHA" \
--wait --timeout 60s -o json | jq -e '.[0].status == 200'
  • In GitHub, a merge shows staging green and production waiting for review. Approving it runs the production deployment.
  • In the portal, each service’s Deployments list shows the new deployment with the API key’s name as who made it, and its findings. Name the key after the pipeline so the list reads well.
  • In Insights, the smoke request is in each service’s request list with its correlation ID on the Overview tab of the request detail.
  • From a terminal, gpilot status --service shop-api lists the newest deployment as completed.
  • One key for both services is one key that can deploy production. If the approval must not be bypassable, use two keys, and make the production key an environment secret of production rather than a repository secret.
  • The cache survives the deployment. A HIT right after the deploy is an entry stored before it. If the change made cached entries wrong, purge them in the production job; see purge after deploying.
  • Staging’s cache says nothing about production’s. The two services share nothing: cache, keys and telemetry are separate.
  • Deploy the schema before the origin. If the same workflow releases your origin, run the GraphPilot deployment first; see deploy the schema before the origin.