🧭 How toService ManagementIntermediate✨ AI-assisted

How to Open a Change Record Automatically From CI/CD

WittyTech Daily··6 min read
#servicenow#github-actions#change-management#ci-cd

Nobody has ever enjoyed pasting a pipeline URL into a change ticket five minutes before a deploy window. This guide puts the change record in your CI/CD workflow instead: a GitHub Actions run opens a ServiceNow change request through the Table API, waits for it to be authorized, deploys, and closes the record with a close code taken from the deploy result.

You will end up with four jobs, one service account and a change that links back to the exact run that caused it.

Create a service account and store the credentials

Make a dedicated integration user in ServiceNow, not a person's login. The Change Management API docs say POST and PATCH calls there need the change_manager, itil or admin role. The Table API only requires that the user can access the table. Give the account the smallest role that can create and update change_request on your instance, and test it on a non-production instance first.

gh secret set SN_INSTANCE --body "https://<your-instance>.service-now.com"
gh secret set SN_USER --body "<integration-user>"
gh secret set SN_PASSWORD

The last command prompts for the password, so it stays out of your shell history. The Table API examples use basic auth, so this guide does too. If your security team wants OAuth, change the auth header and keep the endpoints.

Open the change record from your CI/CD workflow

The first job posts to /api/now/table/change_request and hands the new record's sys_id and number to later jobs.

name: deploy
on:
  push:
    branches: [main]

env:
  SN_INSTANCE: ${{ secrets.SN_INSTANCE }}
  SN_USER: ${{ secrets.SN_USER }}
  SN_PASSWORD: ${{ secrets.SN_PASSWORD }}

jobs:
  open_change:
    runs-on: ubuntu-latest
    outputs:
      sys_id: ${{ steps.create.outputs.sys_id }}
      number: ${{ steps.create.outputs.number }}
    steps:
      - id: create
        env:
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
          ATTEMPT: ${{ github.run_attempt }}
        run: |
          set -euo pipefail
          START=$(date -u '+%Y-%m-%d %H:%M:%S')
          STOP=$(date -u -d '+1 hour' '+%Y-%m-%d %H:%M:%S')
          BODY=$(jq -n \
            --arg sd "Deploy ${GITHUB_REPOSITORY} ${GITHUB_SHA:0:7}" \
            --arg desc "Opened by CI/CD. Pipeline run (attempt ${ATTEMPT}): ${RUN_URL}" \
            --arg starts "$START" --arg ends "$STOP" \
            '{type: "normal", short_description: $sd, description: $desc,
              start_date: $starts, end_date: $ends,
              implementation_plan: "Automated deploy, see the pipeline run in the description",
              backout_plan: "Redeploy the previous release from the same pipeline",
              test_plan: "Smoke tests in the pipeline after deploy"}')
          RESP=$(curl -sS --fail-with-body --request POST \
            --user "${SN_USER}:${SN_PASSWORD}" \
            --header "Content-Type: application/json" \
            --header "Accept: application/json" \
            --data "$BODY" \
            "${SN_INSTANCE}/api/now/table/change_request?sysparm_fields=sys_id,number,state&sysparm_exclude_reference_link=true")
          echo "sys_id=$(jq -r .result.sys_id <<<"$RESP")" >> "$GITHUB_OUTPUT"
          echo "number=$(jq -r .result.number <<<"$RESP")" >> "$GITHUB_OUTPUT"

A successful insert returns 201 with the record under result. sysparm_fields trims the response and sysparm_exclude_reference_link=true drops the link objects. With sysparm_input_display_value left at its default of false, dates are read as GMT, which is why the script uses date -u. Add whatever your instance makes mandatory, often assignment_group, cmdb_ci or risk. --fail-with-body fails the step on a 4xx and prints ServiceNow's error message.

Wait for authorization before the deploy

According to the change state model, a normal change moves through New (-5), Assess (-4), Authorize (-3), Scheduled (-2), Implement (-1), Review (0) and Closed (3), with Canceled at 4. Implement is where the planned start has arrived and the work happens, so it is your green light.

  wait_for_implement:
    needs: open_change
    runs-on: ubuntu-latest
    timeout-minutes: 45
    env:
      SYS_ID: ${{ needs.open_change.outputs.sys_id }}
      NUMBER: ${{ needs.open_change.outputs.number }}
    steps:
      - run: |
          set -euo pipefail
          for i in $(seq 1 80); do
            STATE=$(curl -sS --fail-with-body \
              --user "${SN_USER}:${SN_PASSWORD}" \
              --header "Accept: application/json" \
              "${SN_INSTANCE}/api/now/table/change_request/${SYS_ID}?sysparm_fields=state" \
              | jq -r .result.state)
            echo "${NUMBER} is in state ${STATE}"
            case "$STATE" in
              -1)  echo "Implement reached, deploying"; exit 0 ;;
              3|4) echo "Change was closed or canceled"; exit 1 ;;
            esac
            sleep 30
          done
          echo "Timed out waiting for ${NUMBER}"; exit 1

The loop polls every 30 seconds for 40 minutes and fails if someone closes or cancels the change. If your process parks changes in Scheduled until a human clicks Implement, that click is your go button.

Deploy, then close the change record from the result

The close job runs even when the deploy fails, because a failed deploy still needs an honest record.

  deploy:
    needs: [open_change, wait_for_implement]
    runs-on: ubuntu-latest
    steps:
      - run: echo "your deploy steps go here"

  close_change:
    needs: [open_change, wait_for_implement, deploy]
    if: ${{ always() && needs.wait_for_implement.result == 'success' }}
    runs-on: ubuntu-latest
    env:
      SYS_ID: ${{ needs.open_change.outputs.sys_id }}
      DEPLOY_RESULT: ${{ needs.deploy.result }}
      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
    steps:
      - run: |
          set -euo pipefail
          if [ "$DEPLOY_RESULT" = "success" ]; then CODE=successful; else CODE=unsuccessful; fi
          patch() {
            curl -sS --fail-with-body --request PATCH \
              --user "${SN_USER}:${SN_PASSWORD}" \
              --header "Content-Type: application/json" \
              --header "Accept: application/json" \
              --data "$1" \
              "${SN_INSTANCE}/api/now/table/change_request/${SYS_ID}?sysparm_fields=number,state" > /dev/null
          }
          patch '{"state":"0"}'
          patch "$(jq -n --arg code "$CODE" --arg notes "Deploy result: ${DEPLOY_RESULT}. Run: ${RUN_URL}" \
            '{state: "3", close_code: $code, close_notes: $notes}')"

needs.deploy.result is success, failure, cancelled or skipped, and the script maps anything but success to unsuccessful. The close_code value must come from the field's choice list: successful, successful_issues or unsuccessful. I send Review before Closed because Review sits between them in the state model. If your change model allows Implement straight to Closed, drop the first call.

Use a standard change for routine deploys

Routine deploys fit a standard change better. The state model treats standard changes as pre-authorized, so they bypass Assess and Authorize. The Change Management API creates one from a published Standard Change Template:

SD=$(jq -rn --arg v "Deploy ${GITHUB_REPOSITORY} ${GITHUB_SHA:0:7}" '$v|@uri')
curl -sS --fail-with-body --request POST \
  --user "${SN_USER}:${SN_PASSWORD}" \
  --header "Accept: application/json" \
  "${SN_INSTANCE}/api/sn_chg_rest/change/standard/<standard-change-template-sys-id>?short_description=${SD}"

Fields go in the query string. Description, backout, test and implementation plan values are ignored because they belong to the template. This API wraps each returned field as value and display_value, unlike the Table API, so print the response once before you write the jq. The wait and close jobs stay the same, since the change still has to reach Implement.

Gotchas

  • Mandatory fields differ per instance. Run the workflow against a dev instance first and read the 400 message.
  • State numbers depend on your change model. Ask GET /api/sn_chg_rest/change/{sys_id}/nextstates for the legal next states before hardcoding transitions.
  • A workflow re-run opens a new change. The run attempt in the description helps reviewers tell them apart.
  • If the wait times out, the change stays open, and the close job skips it. Add a step that cancels it (state 4 is reachable at any point before closure).
  • Polling burns runner minutes. If approvals take hours, look at ServiceNow's DevOps Change Velocity GitHub integration.

For anyone shipping AI to production inside an enterprise, this turns "we deployed it" into an auditable change record that names the run, the approval and the outcome, which your customer's change board can trust.

← More in Service Management