On this page
- Why the agent exists
- One binary, several ways to install it
- Step one: which CI is this?
- Step two: what kind of build is it?
- An old re-run must not replace a newer live spec
- Which pull request did this merge come from?
- Keeping the ingest key out of logs
- What happens in Graphwake after the spec arrives
- How Graphwake does it
- Open source, Apache-2.0
- Next steps
- Questions
Why the agent exists
Graphwake keeps a map of which teams consume each endpoint of your OpenAPI specs. When a spec changes in a breaking way, it scores the change and alerts only the teams subscribed to the affected endpoints, in Slack, Microsoft Teams or email. For that to be useful, the alert has to arrive while the change can still be stopped or adjusted.
A spec can reach Graphwake two ways. Graphwake can poll a published spec URL every 30 minutes, which sees a change once it is deployed. Or a step in the producer’s pipeline sends the spec from the pull request build, so Graphwake sees the change before anyone merges it. The agent is that step.
We could not assume one CI system. A company with a few dozen services often runs two or three. These were our goals for the agent:
- One step, same settings everywhere.
--specandGRAPHWAKE_KEYare enough on every platform; everything else is read from the CI environment. - Only the spec. The agent sends the spec file as it is (YAML or JSON). It does not generate it, and your source code is not sent. To classify the build it reads the CI environment and, where git is available, a few commit details: the SHA, the commit time and message, and the remote branch tip.
- Small enough to read. It is written in Go against the standard library only and ships as one static binary. You run it with a secret in your pipeline, so you should be able to read every line of it.
One binary, several ways to install it
Releases are built with GoReleaser: binaries for Linux, macOS and Windows on amd64 and arm64, a checksums.txt, and a multi-arch image ghcr.io/graphwake/agent (distroless, with a busybox shell so GitLab CI and Jenkins can run scripts in it). Each platform gets the wrapper it expects, and every wrapper runs the same binary:
| Where | How | What the wrapper does |
|---|---|---|
| GitHub Actions | uses: graphwake/agent/github-action@v0.1.0 | A composite action: downloads the pinned release for the runner’s OS and architecture, checks its SHA-256 against checksums.txt, runs it |
| GitLab CI | include the template and extends: .graphwake-upload | Runs the image in merge request pipelines and on the default branch |
| Azure DevOps | A script step with the binary, or the GraphwakeUpload@0 task where the Graphwake extension is installed | Downloads the binary, checks the checksum, runs it |
| Bitbucket Pipelines, Jenkins, anything else | The image ghcr.io/graphwake/agent:0.1.0 or the binary | Nothing extra: you call graphwake upload |
on:
pull_request:
types: [opened, synchronize, reopened, closed]
push:
branches: [main]
jobs:
api-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... build and produce openapi.json
- uses: graphwake/agent/github-action@v0.1.0
with:
spec: openapi.json
key: ${{ secrets.GRAPHWAKE_KEY }}closed type lets the agent report pull requests closed without merging.include:
- remote: https://raw.githubusercontent.com/graphwake/agent/v0.1.0/gitlab/graphwake.gitlab-ci.yml
graphwake:
extends: .graphwake-upload
needs: [build]
variables:
GRAPHWAKE_SPEC: build/openapi.jsonGRAPHWAKE_KEY as a masked CI/CD variable.Where you install the binary yourself, verify it the same way the wrappers do. For the image, pin it by digest (ghcr.io/graphwake/agent@sha256:...) to get the same guarantee.
VERSION=0.1.0
BASE=https://github.com/graphwake/agent/releases/download/v$VERSION
ARCHIVE=graphwake_${VERSION}_linux_amd64.tar.gz
curl -fsSLO "$BASE/$ARCHIVE" && curl -fsSLO "$BASE/checksums.txt" &&
sha256sum --check --ignore-missing checksums.txt &&
tar -xzf "$ARCHIVE" graphwake &&
./graphwake upload --spec openapi.jsonchecksums.txt, run. Adapted from the README’s install script.Step one: which CI is this?
Every CI system sets its own environment variables, and each has one that identifies it. The agent checks them in a fixed order and falls back to other when none matches:
switch {
case getenv("GITHUB_ACTIONS") == "true":
return detectGitHub(getenv, readFile)
case getenv("GITLAB_CI") != "":
return detectGitLab(getenv)
case strings.EqualFold(getenv("TF_BUILD"), "true"):
return detectAzure(getenv)
case getenv("BITBUCKET_BUILD_NUMBER") != "" || getenv("BITBUCKET_COMMIT") != "":
return detectBitbucket(getenv)
case getenv("JENKINS_URL") != "" || getenv("JENKINS_HOME") != "":
return detectJenkins(getenv)
}
return Info{Platform: Other, Event: EventUnknown}internal/ci/detect.go. getenv and readFile are injected, so every platform is tested from fixture environments and event payloads.Each detect* function then reads that platform’s own variables for the pull request number, link, author, commit and branch. Most of that is a lookup table. These four cases took more care:
- On a GitHub pull request,
GITHUB_SHApoints at GitHub’s temporary merge commit, not at the commit the author pushed. We read the pull request’s head SHA from the event payload instead, so Graphwake records the author’s commit. - Only
push,workflow_dispatchandschedulemay publish the live spec on GitHub. Events such asissue_commentorworkflow_runalso run on the default branch’s ref, but they often build a pull request’s code, so we do not trust them with the live spec. - Azure DevOps has two pull request ids.
SYSTEM_PULLREQUEST_PULLREQUESTNUMBERis the GitHub number for GitHub-hosted repositories andSYSTEM_PULLREQUEST_PULLREQUESTIDis the Azure Repos id. The agent takes the first one that is set. - On a change request, Jenkins puts
PR-5inBRANCH_NAME. The real source branch is inCHANGE_BRANCH.
Step two: what kind of build is it?
| Platform | Pull request build | Live build |
|---|---|---|
| GitHub Actions | pull_request / pull_request_target events. Closed without merging sends “closed” (no spec). A merged PR sends nothing: the default-branch build reports the merge. | push (or a manual or scheduled run) on a live branch |
| GitLab CI | Merge request pipelines (CI_MERGE_REQUEST_IID) | CI_COMMIT_BRANCH is a live branch (default CI_DEFAULT_BRANCH) |
| Azure DevOps | BUILD_REASON=PullRequest | Other builds of a live branch (default main / master) |
| Bitbucket Pipelines | BITBUCKET_PR_ID | BITBUCKET_BRANCH is a live branch |
| Jenkins (multibranch) | CHANGE_ID, CHANGE_URL, CHANGE_AUTHOR | BRANCH_NAME (or GIT_BRANCH) is a live branch |
| Anything else | --pr | Only with --live |
A live branch is the platform’s default branch when the platform says what it is (GitHub’s event payload, GitLab’s CI_DEFAULT_BRANCH), else main and master. Azure DevOps has no default-branch variable, so it uses the fallback. Teams that release from more than one branch list them. On GitHub Actions that is the action’s live-branch input, and the same branches go in the workflow’s push trigger; elsewhere it is GRAPHWAKE_LIVE_BRANCH=main,release/*. The patterns are globs.
With the spec, the agent sends the build’s context: the platform, the commit SHA, the commit time (the committer date from git when git is there, else the platform’s value) and the branch the commit is on. If the branch name is longer than 255 characters we drop it rather than reject the request, so the spec still arrives.
{
"spec": "openapi: 3.0.3\ninfo: ...",
"context": {
"kind": "pullRequest",
"prId": "42",
"prUrl": "https://github.com/example/orders-api/pull/42",
"author": "john",
"state": "open",
"platform": "github",
"commitSha": "9f2c1e4b7a0d",
"commitTime": "2026-11-10T10:05:00Z",
"branch": "orders-region-param"
}
}POST /api/ingest/specs, the key in the Authorization header).An old re-run must not replace a newer live spec
Live builds have a race that pull request builds do not. An example: John merges pull request #41 at 10:00 and pull request #42 at 10:05. The 10:05 pipeline finishes first and publishes the live spec. Then the 10:00 pipeline finishes, or someone re-runs it an hour later. If it sent its spec as live, the API would look as if #42’s changes had been reverted.
So before it sends a live spec, the agent checks two things:
The checked-out commit is the build’s commit
git rev-parse HEADmust match the commit the platform says it is building. If they differ, the live spec is not sent.The build’s commit is still the branch tip
On GitHub the agent asks the API with the job token (
GET /repos/{repo}/commits/{branch}, which answers with the SHA); that call needs onlycontents: read. Elsewhere it runsgit ls-remote origin refs/heads/<branch>. If the branch has moved on, nothing is sent and the newer build sends the live spec.
graphwake: this build is not the tip of main (a newer build sends the live spec), nothing sentWe made the check best effort on purpose. If it cannot run, for example because the image has no git or the job has no credentials, the spec is sent anyway. git runs with GIT_TERMINAL_PROMPT=0 and a 15-second timeout, so a missing credential cannot hang the job waiting for a password. The GitLab template’s image has no git, so the tip check is skipped there.
The server has its own check as well. Every send carries the commit SHA and commit time, so Graphwake can ignore a spec from an older commit than the live one.
Which pull request did this merge come from?
On a live build the agent also tries to find out whether the commit came from a merged pull request, because Graphwake handles the two cases differently. “Pull request #42 merged” settles the draft Graphwake already holds for #42; a plain live spec is a new change with no pull request behind it.
On GitHub the agent asks the API with the same job token: GET /repos/{repo}/commits/{sha}/pulls, keeping the pull request that has merged_at set and whose base is the branch being built. That works for merge commits, squash merges and rebase merges. This call needs pull-requests: read; if your workflow sets permissions:, list it there next to contents: read.
Everywhere else, or when the API does not answer, it reads the commit message and looks for each platform’s default merge message:
githubMerge = `\AMerge pull request #(\d+)\b`
githubSquash = `\A[^\n]*\(#(\d+)\)[ \t]*(?:\r?\n|\z)`
gitlabMerge = `See merge request [^\s!]*!(\d+)\b`
azureMerge = `\AMerged PR (\d+):`
bitbucketPR = `\(pull request #(\d+)\)`internal/ci/mergemsg.go. Jenkins and unknown CIs try all of them, because they can sit in front of any git host.That covers Merge pull request #12, a squash title ending in (#12), GitLab’s See merge request group/project!12, Azure’s Merged PR 12: and Bitbucket’s (pull request #12). Teams that write custom merge messages still get the live spec; they see the merge and the live change separately.
Keeping the ingest key out of logs
CI logs are widely readable and kept for months. The agent prints exactly one line per run, and that line goes through a single reporter that knows the secrets:
func (r *Reporter) clean(s string) string {
for _, secret := range r.secrets {
s = strings.ReplaceAll(s, secret, "***")
}
s = strings.Join(strings.Fields(s), " ") // one line, no control characters
...
}internal/report/report.go. Every value registered with Hide is replaced before anything is printed.- Secrets are registered before flags are parsed:
GRAPHWAKE_KEY, the GitHub token, and any value given after--key. A typo in the flags cannot echo the key back. - Flag errors never show values. We discard the parser’s own output. An unknown flag is reported as “unknown flag” without its name, because a key pasted as
-dsk_...would otherwise end up in the log. - The GitHub Action masks the key too (
::add-mask::), even when it does not come from a secret. - Use the variable, not the flag.
--keyworks, but command-line arguments show up in process lists. The README recommendsGRAPHWAKE_KEYfrom a CI secret. - Pull requests from forks get no secrets. The step prints a warning and sends nothing.
On Graphwake’s side, an ingest key belongs to one service and is stored only as a SHA-256 hash. A repository with several services runs the step once per service, each with its own spec and key.
What happens in Graphwake after the spec arrives
Pull request build: a draft
The spec is stored as a draft for the pull request. Graphwake finds the breaking changes and scores the change set from 0 to 100 with public rules.
Consumer teams hear before the merge
With “Alerts on open pull requests” turned on in settings, the teams subscribed to the changed endpoints are told in Slack, Microsoft Teams or email. The alert names the endpoint, what changed, who changed it and which pull request did it.
Merge: the spec goes live
The default-branch build sends the spec as live. If the commit came from a pull request, that pull request’s draft notifications are settled as “merged” and the real notifications go out.
Closed without merging: the draft goes away
Only GitHub reports a close; the agent sends it and the drafts are discarded. On the other platforms, drafts of a pull request that never merges close on their own after 14 days.
How Graphwake does it
Told while it is still a pull request
A pull request build stores a draft, so the teams that call a changed endpoint can hear about it before the merge, not after a deploy.
Only the subscribed teams
Consumer teams subscribe to the endpoints they use. A breaking change goes to those teams in Slack, Microsoft Teams or email, not to a company-wide channel.
One step on five CI systems
GitHub Actions, GitLab CI, Azure DevOps, Bitbucket Pipelines and Jenkins, with the same two settings: the spec and the key.
Only the spec
The agent sends the OpenAPI file and the build’s context. Your source code is not sent, and the agent’s own code is public.
Open source, Apache-2.0
The agent is public at github.com/graphwake/agent under the Apache-2.0 license. It runs inside your pipeline with one of your secrets, so you should not have to take its behaviour on trust. The logic above lives in one Go module with no dependencies outside the standard library; the wrappers are a few lines of shell, YAML and Node. Each platform’s environment has its own tests.
go vet ./... && go test ./...
go build -o graphwake ./cmd/graphwake
./graphwake versionNext steps
- Who to tell once a breaking change is found: API breaking change alerts.
- Other ways to check a spec in a pipeline: OpenAPI diff in CI.
- What a reviewer should ask of a pull request that changes the spec: the API breaking change checklist.
- How each change set is scored: the impact score rules.
Questions
Which CI systems does the Graphwake CI agent support?
GitHub Actions, GitLab CI, Azure DevOps, Bitbucket Pipelines and Jenkins are detected on their own. On any other CI, pass --pr on pull request builds and --live on release builds.
Does the agent need access to our source code?
Your source code is not sent. The agent sends only the OpenAPI spec file you point it at, with the build’s context (platform, commit, branch, pull request). To classify the build it reads the CI environment and, where git is available, the commit’s SHA, time and message. It does not generate the spec; a build step does that first.
What happens when an old pipeline on main is re-run?
Before a live send the agent checks that the build’s commit is still the tip of the branch. If the branch has moved on, it sends nothing and the newer build’s spec stays live. Graphwake can also ignore a spec from an older commit than the live one.
Is the Graphwake CI agent open source?
Yes. It is at github.com/graphwake/agent under the Apache-2.0 license, with checksummed release binaries and a multi-arch Docker image.
Can one repository send several specs?
Yes. Each ingest key belongs to one service, so run the step once per service, each with its own spec and key.
Pick an endpoint. See who it reaches. 7-day free trial. Cancel before day 7 and you pay nothing.
Start free trialSources and last verified
Sources checked on 2026-11-01.