A payments platform team ships forty times a week, but every production deploy still stalls at the same wall: a human opens a ServiceNow change request by hand, pastes a commit URL and a rollback plan into the form, waits for a CAB approver to click Approve, runs the deploy, then comes back hours later to close the record — and half the time forgets, so the change backlog and the actual production state drift apart and the next audit turns into archaeology. The mandate from the CIO is blunt: keep the change-control discipline auditors require, but make it a property of the pipeline, not a chore a tired engineer remembers at 2 a.m. This guide builds exactly that — a GitHub Actions deploy workflow that creates a ServiceNow change record from the commit, gates the deploy on the change reaching an approved state, executes, and then auto-closes the record with the deploy outcome — using ServiceNow’s Change Management API, an OAuth-authenticated integration account, and a credential leased from HashiCorp Vault. When you are done, the change request is the deploy, and the two can never disagree.
Prerequisites
- A ServiceNow instance (Vancouver release or later) with the Change Management plugin active and a Standard Change Template you are allowed to use, or rights to raise Normal changes via the Change API.
- A ServiceNow integration user (
svc.cicd.github) with thesn_change_writeanditilroles, plus an OAuth API endpoint (an OAuth application registry record) for token-based auth. No interactive ServiceNow account is used by the pipeline. - A GitHub repository with GitHub Actions enabled and permission to define environments and environment protection rules.
- HashiCorp Vault reachable from the runner (we lease the ServiceNow OAuth client secret from a KV mount), with the GitHub OIDC auth method or AppRole configured. If you do not run Vault, GitHub Encrypted Secrets is the fallback and is called out where it differs.
- Okta (or Microsoft Entra ID) as the workforce IdP behind ServiceNow SSO, so the human CAB approvers authenticate with corporate identity and MFA — the pipeline never logs in as a person.
curl,jq, and a Bash shell on the runner (the stockubuntu-latestimage has all three).
Target topology
The flow is a single deploy pipeline split into three jobs that share one identifier — the change sys_id — through GitHub Actions outputs:
open-changeauthenticates to ServiceNow over OAuth (secret leased from Vault), calls the Change API to create a change record stamped with the Git commit, PR link, and a generated rollback plan, and emits the newsys_idandnumber(e.g.CHG0031245) as job outputs.await-approvalpolls the change record’sstateandapprovalfields until ServiceNow reports it approved — Standard changes are pre-approved and clear in seconds; Normal changes wait on a human CAB approver who reviews and clicks Approve in the ServiceNow UI (authenticated through Okta/Entra). The deploy is hard-gated here.deployruns only after approval, performs the actual rollout (your Terraform/Ansible/Argo CD step), then transitions the change to Implement → Review → Closed with a close code and notes carrying the deploy result. On failure it closes the change as unsuccessful and surfaces the rollback.
Observability tools (Dynatrace or Datadog) and the deploy mechanics live inside the deploy job; ServiceNow is the system of record that brackets it.
1. Create the ServiceNow OAuth integration account
The pipeline must authenticate as a machine, not a person. In ServiceNow, create an OAuth API endpoint for external clients (System OAuth → Application Registry → Create an OAuth API endpoint for external clients). Record the generated Client ID and Client Secret. Then create the integration user and grant it change-writing roles.
Verify the OAuth client works by minting a token against the instance’s token endpoint. ServiceNow exposes the standard OAuth 2.0 password and client-credentials grants at /oauth_token.do:
# One-time check from your laptop, NOT the pipeline.
SN_INSTANCE="dev-acme" # https://dev-acme.service-now.com
curl -s -X POST "https://${SN_INSTANCE}.service-now.com/oauth_token.do" \
-d "grant_type=password" \
-d "client_id=${SN_CLIENT_ID}" \
-d "client_secret=${SN_CLIENT_SECRET}" \
-d "username=svc.cicd.github" \
-d "password=${SN_SVC_PASSWORD}" | jq -r '.access_token'
A JWT-looking string in the output means the OAuth app, the integration user, and the roles all line up. If you get access_denied, the user is missing a role; if you get invalid_client, the client ID/secret pair is wrong.
Why OAuth and not Basic auth: ServiceNow access tokens are short-lived (30 minutes by default) and revocable per client, so a leaked pipeline credential has a small blast radius — unlike a long-lived password embedded in CI. We never put the user password in GitHub; only the OAuth client secret leaves Vault, and even that is exchanged for a 30-minute token the moment the job starts.
2. Store the ServiceNow credential in Vault
Put the OAuth client secret (and the service-account password, if you use the password grant) behind HashiCorp Vault so it is leased at runtime and never sits in GitHub. Write the secret to a KV v2 mount:
vault kv put secret/cicd/servicenow \
client_id="${SN_CLIENT_ID}" \
client_secret="${SN_CLIENT_SECRET}" \
svc_username="svc.cicd.github" \
svc_password="${SN_SVC_PASSWORD}"
Bind a Vault policy that grants read-only access to just that path, and map it to the GitHub OIDC auth role so only workflows from this repo can read it:
# servicenow-read.hcl
path "secret/data/cicd/servicenow" {
capabilities = ["read"]
}
vault policy write servicenow-read servicenow-read.hcl
# Allow this specific repo's Actions workflows to assume the policy via OIDC.
vault write auth/jwt/role/github-acme-deploy \
role_type="jwt" \
bound_audiences="https://github.com/acme" \
bound_claims_type="glob" \
bound_claims='{"repository":"acme/payments-platform","ref":"refs/heads/main"}' \
user_claim="repository" \
policies="servicenow-read" \
ttl="15m"
If you do not run Vault, skip this step and store SN_CLIENT_ID / SN_CLIENT_SECRET / SN_SVC_PASSWORD as GitHub Encrypted Secrets scoped to the environment instead — the workflow below reads them the same way, just from secrets.* rather than from the Vault step output.
3. Define the GitHub environment and approval gate
GitHub environments give you a second, native gate that complements the ServiceNow one — useful if you also want a GitHub reviewer or a wait timer. Create a production environment and attach the secrets to it. With the GitHub CLI:
gh api -X PUT repos/acme/payments-platform/environments/production \
-f wait_timer=0 \
-f 'reviewers[][type]=Team' \
-F 'reviewers[][id]=4815162' # the SRE team id; optional belt-and-braces gate
The authoritative approval still comes from ServiceNow in step 5; the GitHub environment is where we pin deploy-only secrets and (optionally) a human GitHub reviewer for defence in depth.
4. Open the change record from the pipeline
Now the workflow. The first job authenticates, then POSTs to the Change API. For a Standard (pre-approved, templated) change, the dedicated endpoint is /api/sn_chg_rest/change/standard/{template_sys_id}; for a Normal change use /api/sn_chg_rest/change. Create .github/workflows/deploy.yml:
name: Deploy with ServiceNow change control
on:
push:
branches: [main]
permissions:
id-token: write # required for Vault/Cloud OIDC
contents: read
jobs:
open-change:
runs-on: ubuntu-latest
outputs:
sys_id: ${{ steps.create.outputs.sys_id }}
number: ${{ steps.create.outputs.number }}
steps:
- uses: actions/checkout@v4
- name: Import ServiceNow secret from Vault
id: vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.acme.internal:8200
method: jwt
role: github-acme-deploy
secrets: |
secret/data/cicd/servicenow client_id | SN_CLIENT_ID ;
secret/data/cicd/servicenow client_secret | SN_CLIENT_SECRET ;
secret/data/cicd/servicenow svc_username | SN_SVC_USER ;
secret/data/cicd/servicenow svc_password | SN_SVC_PASS
- name: Mint ServiceNow OAuth token
id: token
env:
SN_INSTANCE: dev-acme
run: |
TOKEN=$(curl -s -X POST \
"https://${SN_INSTANCE}.service-now.com/oauth_token.do" \
-d "grant_type=password" \
-d "client_id=${SN_CLIENT_ID}" \
-d "client_secret=${SN_CLIENT_SECRET}" \
-d "username=${SN_SVC_USER}" \
-d "password=${SN_SVC_PASS}" | jq -r '.access_token')
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
- name: Create change request
id: create
env:
SN_INSTANCE: dev-acme
SN_TOKEN: ${{ steps.token.outputs.token }}
run: |
# A real rollback plan beats "redeploy previous" — point at the prior SHA.
PREV_SHA=$(git rev-parse HEAD~1)
BODY=$(jq -n \
--arg short "Deploy payments-platform ${GITHUB_SHA:0:7}" \
--arg desc "Automated deploy of commit ${GITHUB_SHA} (run ${GITHUB_RUN_ID})." \
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
--arg back "Re-run pipeline pinned to ${PREV_SHA}; Argo CD rollback to prior sync." \
'{short_description:$short,
description:$desc,
implementation_plan:("CI/CD run: " + $url),
backout_plan:$back,
type:"normal",
risk:"moderate",
assignment_group:"DevOps CAB"}')
RESP=$(curl -s -X POST \
"https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change" \
-H "Authorization: Bearer ${SN_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY")
echo "$RESP" | jq '.result | {number: .number.value, state: .state.value}'
echo "sys_id=$(echo "$RESP" | jq -r '.result.sys_id.value')" >> "$GITHUB_OUTPUT"
echo "number=$(echo "$RESP" | jq -r '.result.number.value')" >> "$GITHUB_OUTPUT"
The Change API returns each field as a { "value": ..., "display_value": ... } object, which is why every jq selector ends in .value. The sys_id it hands back is the thread that stitches the rest of the pipeline together.
For a Standard change, swap the URL for
.../change/standard/<template_sys_id>and droptype/risk— the template supplies them, and the record is born pre-approved, which makes step 5 return almost instantly.
5. Gate the deploy on approval
This is the heart of the guide: do not deploy until ServiceNow says the change is approved. A second job polls the record. Normal changes sit in state=-5 (New) / approval=requested until a CAB approver clicks Approve in ServiceNow (logged in via Okta/Entra), at which point approval flips to approved and the workflow proceeds. Add this job:
await-approval:
needs: open-change
runs-on: ubuntu-latest
steps:
- name: Re-mint token
id: token
env:
SN_INSTANCE: dev-acme
run: |
# (same Vault import + oauth_token.do call as job 1 — omitted for brevity)
echo "token=$SN_TOKEN" >> "$GITHUB_OUTPUT"
- name: Poll until approved
env:
SN_INSTANCE: dev-acme
SN_TOKEN: ${{ steps.token.outputs.token }}
SYS_ID: ${{ needs.open-change.outputs.sys_id }}
run: |
for i in $(seq 1 120); do # up to ~60 min at 30s cadence
APPROVAL=$(curl -s \
"https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
-H "Authorization: Bearer ${SN_TOKEN}" \
| jq -r '.result.approval.value')
echo "Attempt $i: approval=${APPROVAL}"
case "$APPROVAL" in
approved) echo "Change approved — proceeding."; exit 0 ;;
rejected) echo "::error::Change was REJECTED in ServiceNow."; exit 1 ;;
*) sleep 30 ;;
esac
done
echo "::error::Timed out waiting for CAB approval."; exit 1
The job blocks the workflow graph: because deploy declares needs: await-approval, GitHub will not start the rollout until this job exits 0. A rejection or a timeout fails the run cleanly and the deploy never happens — the gate is a hard fence, not a warning.
6. Deploy, then auto-close the change
The final job runs the real deploy and then drives the change record through its lifecycle to Closed. Use the Change API’s state field — -1 Implement, 0 Review, 3 Closed — and set close_code plus close_notes. Wrapping the deploy in an if/else lets you close successful or unsuccessful honestly.
deploy:
needs: [open-change, await-approval]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Re-mint token
id: token
run: |
# (Vault import + oauth_token.do — omitted for brevity)
echo "token=$SN_TOKEN" >> "$GITHUB_OUTPUT"
- name: Move change to Implement
env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}" }
run: |
curl -s -X PATCH \
"https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
-H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
-d '{"state":"-1","work_notes":"Deploy started from GitHub Actions."}' > /dev/null
- name: Run the actual deploy
id: rollout
run: |
# Your real rollout. For example, with Terraform:
terraform -chdir=infra init -input=false
terraform -chdir=infra apply -auto-approve -input=false
# ...or Ansible / Argo CD sync / kubectl rollout, as your stack dictates.
# Optionally push a deploy event to Dynatrace/Datadog here so the
# observability timeline lines up with this exact change number.
- name: Close change (success)
if: success()
env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}", NUMBER: "${{ needs.open-change.outputs.number }}" }
run: |
curl -s -X PATCH \
"https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
-H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
-d '{"state":"3","close_code":"successful",
"close_notes":"Deploy of '"${GITHUB_SHA:0:7}"' completed via run '"${GITHUB_RUN_ID}"'."}' \
| jq '.result.state'
echo "Closed ${NUMBER} as successful."
- name: Close change (failure)
if: failure()
env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}" }
run: |
curl -s -X PATCH \
"https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
-H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
-d '{"state":"3","close_code":"unsuccessful",
"close_notes":"Deploy FAILED — backout plan executed. See GitHub run for logs."}' > /dev/null
The if: success() / if: failure() pair guarantees the record is always closed with a truthful code, even when the rollout blows up mid-apply — which is the single behaviour that keeps the change log and production from drifting.
Validation
Prove the loop end to end before you trust it on a real release:
- Dry-run the API by hand. Create a throwaway change and read it back, confirming the fields landed:
curl -s "https://dev-acme.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \ -H "Authorization: Bearer ${SN_TOKEN}" \ | jq '.result | {number:.number.value, state:.state.display_value, approval:.approval.value}' - Watch a real run. Push a no-op commit to
main. In the GitHub Actions UI the run should pause atawait-approval; in ServiceNow the newCHG…record should be sitting in Assess/Authorize awaiting CAB. - Approve in ServiceNow as a human CAB member (via the Okta/Entra SSO login). Within one poll cycle (≤30 s) the
await-approvaljob turns green anddeploystarts. - Confirm the close. After the run finishes, the change record should be Closed with
close_code = successfuland close notes naming the commit and run ID. Cross-check the GitHub run URL recorded in the change against the actual run. - Test the rejection path. Re-run, then Reject the change in ServiceNow — the
await-approvaljob must fail anddeploymust never start.
Rollback and teardown
- In-flight rollback: if the deploy step fails, the
if: failure()job already closes the change unsuccessful and your rollout step’s backout (Argo CD rollback to the previous sync, orterraform applypinned to the prior SHA captured inPREV_SHA) restores production. The change record’sbackout_planfield documents exactly what ran. - Decommission the integration cleanly: revoke the pipeline’s access rather than deleting the audit trail.
# Disable the OAuth client (keeps history; stops new tokens) # System OAuth → Application Registry → set 'Active' = false on the client record, # or via the Table API: curl -s -X PATCH \ "https://dev-acme.service-now.com/api/now/table/oauth_entity/${OAUTH_SYS_ID}" \ -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \ -d '{"active":"false"}' # Remove the Vault secret and the OIDC role vault kv delete secret/cicd/servicenow vault delete auth/jwt/role/github-acme-deploy # Remove the GitHub environment (drops its secrets too) gh api -X DELETE repos/acme/payments-platform/environments/production - Never delete change records to “clean up” — closed changes are the audit evidence. Deactivate the account, retain the history.
Common pitfalls
.valueeverywhere. The Change API wraps every field in{value, display_value}. Reading.result.stateinstead of.result.state.valuesilently yields an object and your conditionals misfire. This trips up everyone the first time.- Standard vs Normal endpoint mismatch. Posting a Normal-change body to the Standard endpoint (or vice-versa) returns a 400 with a generic message. Standard = pre-approved + templated (
/change/standard/{tpl}); Normal = CAB-gated (/change). - Token expiry on long waits. Tokens live ~30 minutes; a CAB approval that takes an hour will outlast the token minted in job 1. Re-mint the token at the start of each job (as shown) rather than passing one token across the whole graph.
- Polling masking a rejection. A naïve loop that only checks for
approvedwill spin until timeout on a rejected change. Always branch onrejectedexplicitly and fail fast. - Mismatched state model. Some instances customise the change state values via the Change Management — State Model. Confirm your numeric states (
-5,-4,-3,-2,-1,0,3,4) against your instance before hard-coding them; read one record’sstate.display_valueto map them. - Rate limits. Tighten the poll cadence (30 s here) if you run many concurrent pipelines — ServiceNow inbound REST is rate-limited per instance, and a 60-pipeline storm polling every 5 s will get throttled.
Security notes
The pipeline authenticates as a dedicated, least-privileged integration user (sn_change_write + itil, nothing more) over OAuth with short-lived tokens, so a leaked credential expires in minutes and is revocable per client. The OAuth client secret is leased from HashiCorp Vault at job start via GitHub OIDC — no static ServiceNow secret is ever stored in GitHub, and the Vault role is bound to this one repo and branch. Every token printed in a step is masked with ::add-mask:: so it never leaks into logs. Crucially, human CAB approval flows through Okta/Entra SSO with MFA — the machine account can create and progress a change but the approval gate is a real person authenticating with corporate identity, preserving separation of duties. For posture, let Wiz (or Wiz Code scanning the workflow files) flag if a secret ever gets hard-coded into deploy.yml, and keep the integration user out of any role that can self-approve its own changes — that single misconfiguration would collapse the whole control.
Cost notes
This automation is effectively free of new infrastructure: it reuses your existing ServiceNow instance, GitHub Actions minutes you already pay for, and a Vault path. The real saving is engineering time — a team doing forty deploys a week reclaims the 10–15 minutes per deploy previously spent hand-filling and chasing change records, roughly a full engineer-day each week, while eliminating the audit-prep cost of reconciling drifted change logs. The only metered resource is Actions runner time: the await-approval poll job consumes minutes while it waits, so for Normal changes with long CAB queues prefer a wait-timer or scheduled re-check over a tight busy-poll, or move the approval wait to a ServiceNow outbound webhook / Business Rule that calls back the GitHub repository_dispatch API when the change is approved — turning a paid wait into an event and cutting idle runner minutes to near zero.