Skip to content

Instantly share code, notes, and snippets.

@anveo
Forked from denislemire/rotate-oauth-trigger.sh
Last active April 14, 2026 23:15
Show Gist options
  • Select an option

  • Save anveo/71ade69419be65d78feea0779a72c8b2 to your computer and use it in GitHub Desktop.

Select an option

Save anveo/71ade69419be65d78feea0779a72c8b2 to your computer and use it in GitHub Desktop.
rotate-oauth-trigger.sh

CircleCI GitHub OAuth Webhook Secret Rotation

Tooling for rotating the GitHub webhook secret on CircleCI GitHub OAuth project triggers. Background: CircleCI support article — Rotating the GitHub webhook secret for CircleCI GitHub OAuth project triggers.

Prerequisites

export CIRCLE_TOKEN=<your-personal-api-token>

Which projects need rotation?

Only projects using the GitHub OAuth integration need rotation. Projects already on the newer GitHub App integration are not affected.

Step 1 — enumerate projects

The v1.1 API returns all projects your token follows:

curl -sS \
  -H "Circle-Token: $CIRCLE_TOKEN" \
  "https://circleci.com/api/v1.1/projects" \
  | jq -r '.[] | select(.vcs_type == "github") | "gh/\(.username)/\(.reponame)"' \
  > slugs.txt

Note: this only returns projects you follow. For org-wide coverage with an admin token, paginate GET /api/v2/projects?org-slug=gh/<org> instead.

Step 2 — audit each project

Use DRY_RUN=1 to check which projects have an OAuth trigger without modifying anything. Projects that print a Trigger id: line need rotation; projects that print no pipeline definition or no github_oauth trigger are already on the GitHub App integration and can be skipped.

while IFS= read -r slug; do
  echo "--- $slug"
  CIRCLE_TOKEN="$CIRCLE_TOKEN" \
  CIRCLECI_PROJECT_SLUG="$slug" \
  DRY_RUN=1 \
  ./rotate.sh 2>&1 | grep -E '(definition id|Trigger id|no pipeline definition|no github_oauth)'
done < slugs.txt

Rotating a single project

export CIRCLECI_PROJECT_SLUG=gh/your-org/your-repo
./rotate.sh

Optional overrides:

Variable Default Description
PIPELINE_DEFINITION_ID first github_oauth definition explicit pipeline definition UUID
TRIGGER_ID sole OAuth trigger on the definition required if there are multiple OAuth triggers
DRY_RUN unset set to any non-empty value to print actions without making changes
CIRCLECI_API_ROOT https://circleci.com/api/v2 override for on-prem / proxy

Bulk rotation

To rotate all projects identified in the audit step:

while IFS= read -r slug; do
  echo "--- rotating $slug"
  CIRCLE_TOKEN="$CIRCLE_TOKEN" \
  CIRCLECI_PROJECT_SLUG="$slug" \
  ./rotate.sh
done < slugs.txt 2>&1 | tee output.log

rotate.sh sends diagnostic output (project id, trigger id, recreate body) to stderr and the final JSON response to stdout. Merging with 2>&1 before tee ensures both end up in the log. tee also prints to the terminal so you can follow progress as it runs.

How it works

rotate.sh performs a delete-and-recreate of the GitHub OAuth trigger, which has the same effect as running Project Setup in the CircleCI UI: the old GitHub webhook is removed and a new one with a fresh secret is registered.

The script:

  1. Resolves the human project slug (gh/org/repo) to an internal project UUID.
  2. Finds the pipeline definition with config_source.provider == "github_oauth".
  3. Finds the trigger with event_source.provider == "github_oauth" on that definition.
  4. Fetches the full trigger payload and strips read-only fields to build a recreate request.
  5. DELETEs the trigger, then POSTs a new one.

Both the pipeline definition ID and trigger ID are stable across rotation — CircleCI reassigns the same UUIDs when the trigger is recreated for the same repo and definition. Any stored references to these IDs remain valid after rotation.

API reference: CircleCI OpenAPI spec

#!/usr/bin/env sh
# Rotate a GitHub OAuth VCS trigger via CircleCI API v2 (delete + recreate).
# Same effect as Project Setup: removes the GitHub webhook and registers a new one.
#
# Requires: curl, jq
# Auth: https://circleci.com/docs/guides/toolkit/api-developers-guide/
#
# Usage:
# export CIRCLE_TOKEN=... # personal API token; header Circle-Token
# export CIRCLECI_PROJECT_SLUG=gh/your-org/your-repo # OAuth org slug form
# # optional:
# # export PIPELINE_DEFINITION_ID=<uuid> # if omitted, first github_oauth definition is used
# # export TRIGGER_ID=<uuid> # if omitted and multiple OAuth triggers, script exits with error
# # export DRY_RUN=1 # print actions only
# ./rotate-oauth-trigger.sh
#
# API reference (OpenAPI): https://circleci.com/api/v2/openapi.json
# Project admin / triggers: paths under /projects/{project_id}/...
set -eu
API_ROOT="${CIRCLECI_API_ROOT:-https://circleci.com/api/v2}"
die() { printf '%s\n' "$*" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"; }
need_cmd curl
need_cmd jq
[ -n "${CIRCLE_TOKEN:-}" ] || die "set CIRCLE_TOKEN"
[ -n "${CIRCLECI_PROJECT_SLUG:-}" ] || die "set CIRCLECI_PROJECT_SLUG (e.g. gh/org/repo)"
uri_escape() { jq -nr --arg s "$1" '$s | @uri'; }
curl_cci() {
curl -sS -f \
-H "Circle-Token: ${CIRCLE_TOKEN}" \
-H "Accept: application/json" \
"$@"
}
ENC_SLUG="$(uri_escape "${CIRCLECI_PROJECT_SLUG}")"
# 1) Project UUID from human slug
PROJ_JSON="$(curl_cci "${API_ROOT}/project/${ENC_SLUG}")" || die "failed GET /project (check slug and token)"
PROJECT_ID="$(printf '%s' "$PROJ_JSON" | jq -r '.id')"
[ -n "$PROJECT_ID" ] && [ "$PROJECT_ID" != null ] || die "could not read project id from API response"
# 2) Pipeline definition (github_oauth)
DEFS_JSON="$(curl_cci "${API_ROOT}/projects/${PROJECT_ID}/pipeline-definitions")" || die "failed GET pipeline-definitions"
pick_oauth_def_id() {
printf '%s' "$DEFS_JSON" | jq -r '
.items[]
| select(.config_source.provider == "github_oauth")
| .id' | head -n 1
}
if [ -n "${PIPELINE_DEFINITION_ID:-}" ]; then
PIPELINE_DEF_ID="$PIPELINE_DEFINITION_ID"
else
PIPELINE_DEF_ID="$(pick_oauth_def_id)"
fi
[ -n "$PIPELINE_DEF_ID" ] || die "no pipeline definition with config_source.provider=github_oauth; set PIPELINE_DEFINITION_ID explicitly"
# 3) Triggers on that definition
TRIG_LIST="$(curl_cci "${API_ROOT}/projects/${PROJECT_ID}/pipeline-definitions/${PIPELINE_DEF_ID}/triggers")" || die "failed GET triggers"
oauth_trigger_ids() {
printf '%s' "$TRIG_LIST" | jq -r '
.items[]
| select(.event_source.provider == "github_oauth")
| .id'
}
if [ -n "${TRIGGER_ID:-}" ]; then
TID="$TRIGGER_ID"
else
count="$(oauth_trigger_ids | wc -l | tr -d ' ')"
[ "$count" -ge 1 ] || die "no github_oauth trigger on pipeline definition ${PIPELINE_DEF_ID}"
[ "$count" -eq 1 ] || die "multiple github_oauth triggers; set TRIGGER_ID to one of: $(oauth_trigger_ids | tr '\n' ' ')"
TID="$(oauth_trigger_ids | head -n 1)"
fi
# 4) Full trigger payload (for verbatim-ish recreate)
TRIG_JSON="$(curl_cci "${API_ROOT}/projects/${PROJECT_ID}/triggers/${TID}")" || die "failed GET trigger ${TID}"
# Build createTriggerRequest from GET trigger (strip read-only fields).
CREATE_BODY="$(printf '%s' "$TRIG_JSON" | jq '
{
event_source: {
provider: .event_source.provider,
repo: { external_id: .event_source.repo.external_id }
}
}
+ (if (.event_preset | type) == "string" and .event_preset != "" then { event_preset: .event_preset } else {} end)
+ (if (.checkout_ref | type) == "string" and .checkout_ref != "" then { checkout_ref: .checkout_ref } else {} end)
+ (if (.config_ref | type) == "string" and .config_ref != "" then { config_ref: .config_ref } else {} end)
')"
printf 'Project id: %s\nPipeline definition id: %s\nTrigger id: %s\n' "$PROJECT_ID" "$PIPELINE_DEF_ID" "$TID" >&2
printf 'Recreate body:\n%s\n' "$CREATE_BODY" >&2
if [ -n "${DRY_RUN:-}" ]; then
printf '\nDRY_RUN set — no DELETE/POST performed. Would run:\n' >&2
printf ' DELETE %s/projects/%s/triggers/%s\n' "$API_ROOT" "$PROJECT_ID" "$TID" >&2
printf ' POST %s/projects/%s/pipeline-definitions/%s/triggers\n' "$API_ROOT" "$PROJECT_ID" "$PIPELINE_DEF_ID" >&2
exit 0
fi
# 5) Delete then create
curl_cci -X DELETE "${API_ROOT}/projects/${PROJECT_ID}/triggers/${TID}" >/dev/null || die "DELETE trigger failed"
NEW_TRIG="$(curl_cci -X POST "${API_ROOT}/projects/${PROJECT_ID}/pipeline-definitions/${PIPELINE_DEF_ID}/triggers" \
-H "Content-Type: application/json" \
-d "$CREATE_BODY")" || die "POST create trigger failed (trigger was deleted; re-add via UI if needed)"
printf '%s\n' "$NEW_TRIG" | jq .
printf '\nNew trigger id: %s\n' "$(printf '%s' "$NEW_TRIG" | jq -r '.id')" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment