
We ship a small open-source Go binary that sends a service's OpenAPI spec from a CI pipeline to Graphwake. The same step runs in GitHub Actions, GitLab CI, Azure DevOps, Bitbucket Pipelines and Jenkins. It reads the platform's environment, decides whether the build is a pull request, a live-branch build or neither, and sends the spec with that context.
The code is at github.com/graphwake/agent under Apache-2.0. Below are the parts that took us the most thought.
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's 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 couldn't assume one CI system. A company with a few dozen services often runs two or three. So we set ourselves three goals.
One step, same settings everywhere. --spec and GRAPHWAKE_KEY are 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 doesn't 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's 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 |
GitHub Actions, from the agent README. The closed type lets the agent report pull requests closed without merging:
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 }}
GitLab CI. Add GRAPHWAKE_KEY as a masked CI/CD variable:
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.json
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. This is adapted from the README's install script:
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.json
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. From internal/ci/detect.go:
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}
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. Four cases took more care.
On a GitHub pull request, GITHUB_SHA points 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_dispatch and schedule may publish the live spec on GitHub. Events such as issue_comment or workflow_run also run on the default branch's ref, but they often build a pull request's code, so we don't trust them with the live spec.
Azure DevOps has two pull request ids. SYSTEM_PULLREQUEST_PULLREQUESTNUMBER is the GitHub number for GitHub-hosted repositories and SYSTEM_PULLREQUEST_PULLREQUESTID is the Azure Repos id. The agent takes the first one that is set.
On a change request, Jenkins puts PR-5 in BRANCH_NAME. The real source branch is in CHANGE_BRANCH.
Step two: what kind of build is it?

Three outcomes from one step. Only pull request builds and live-branch builds send anything.
| 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's the action's live-branch input, and the same branches go in the workflow's push trigger. Elsewhere it's 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.
Here's an example request body for a pull request build on GitHub Actions (POST /api/ingest/specs, with the key in the Authorization header):
{
"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"
}
}
An old re-run must not replace a newer live spec
Live builds have a race that pull request builds don't. Say 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.
This is the one line printed when a build is behind:
graphwake: this build is not the tip of main (a newer build sends the live spec), nothing sent
We made the check best effort on purpose. If it can't 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 can't 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 doesn't answer, it reads the commit message and looks for each platform's default merge message. From internal/ci/mergemsg.go:
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+)\)`
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 just see the merge and the live change separately.
Keeping the ingest key out of logs
CI logs are widely readable and kept for a long time. The agent prints exactly one line per run, and that line goes through a single reporter that knows the secrets. From internal/report/report.go, where every value registered with Hide is replaced before anything is printed:
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
...
}
A few more rules around that reporter:
- Secrets are registered before flags are parsed:
GRAPHWAKE_KEY, the GitHub token, and any value given after--key. A typo in the flags can't 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 doesn't come from a secret. --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 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.
So the teams that call a changed endpoint hear about it while it's still a pull request, not after a deploy, and only those teams: consumer teams subscribe to the endpoints they use, and nothing goes to a company-wide channel. The setup is the same two settings, the spec and the key, on all five CI systems, and the agent sends the OpenAPI file and the build's context, nothing else.
Build it yourself
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, and each platform's environment has its own tests. It runs inside your pipeline with one of your secrets, so you shouldn't have to take its behaviour on trust:
go vet ./... && go test ./...
go build -o graphwake ./cmd/graphwake
./graphwake version
On any CI we don't detect, pass --pr on pull request builds and --live on release builds.
Originally published on graphwake.com.
How many CI systems does your company run side by side? We went with environment sniffing plus a fallback flag; curious whether anyone has found a cleaner way to classify builds across platforms.
Comments
Join the discussionNo comments yet. Be the first to comment.






