DevOps Platform

Automate ServiceNow Change Requests from a CI/CD Pipeline via the Change API

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

Target topology

Automate ServiceNow Change Requests from a CI/CD Pipeline via the Change API — topology

The flow is a single deploy pipeline split into three jobs that share one identifier — the change sys_id — through GitHub Actions outputs:

  1. open-change authenticates 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 new sys_id and number (e.g. CHG0031245) as job outputs.
  2. await-approval polls the change record’s state and approval fields 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.
  3. deploy runs only after approval, performs the actual rollout (your Terraform/Ansible/Argo CD step), then transitions the change to ImplementReviewClosed 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 drop type/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:

  1. 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}'
    
  2. Watch a real run. Push a no-op commit to main. In the GitHub Actions UI the run should pause at await-approval; in ServiceNow the new CHG… record should be sitting in Assess/Authorize awaiting CAB.
  3. Approve in ServiceNow as a human CAB member (via the Okta/Entra SSO login). Within one poll cycle (≤30 s) the await-approval job turns green and deploy starts.
  4. Confirm the close. After the run finishes, the change record should be Closed with close_code = successful and close notes naming the commit and run ID. Cross-check the GitHub run URL recorded in the change against the actual run.
  5. Test the rejection path. Re-run, then Reject the change in ServiceNow — the await-approval job must fail and deploy must never start.

Rollback and teardown

Common pitfalls

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.

ServiceNowGitHub ActionsCI/CDChange ManagementDevOpsITSM
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading