How it works

One Go binary, five CI systems: inside our open-source CI agent

Last checked 2026-11-1412 min read

The Graphwake CI agent is an open-source Go binary (Apache-2.0, github.com/graphwake/agent) 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, so Graphwake can tell the teams that consume a changed endpoint before the change is merged.

Exampleopenapi.yaml · before → after
− before+ after
/orders/{id}:  get:    parameters:      - name: currency        in: query        schema: { type: string }-       required: false+       required: true    responses:      "200":        description: OK        content:          application/json:            schema:              type: object              properties:                id: { type: string }-               total: { type: number }
  • BreakingNew required parameter currency
  • BreakingResponse field total removed
GET /orders/{id}reaches checkout and mobile-app
A removed response field is a breaking change. Graphwake tells the teams that subscribed to the endpoint.
On this page
  1. Why the agent exists
  2. One binary, several ways to install it
  3. Step one: which CI is this?
  4. Step two: what kind of build is it?
  5. An old re-run must not replace a newer live spec
  6. Which pull request did this merge come from?
  7. Keeping the ingest key out of logs
  8. What happens in Graphwake after the spec arrives
  9. How Graphwake does it
  10. Open source, Apache-2.0
  11. Next steps
  12. 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. --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 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:

WhereHowWhat the wrapper does
GitHub Actionsuses: graphwake/agent/github-action@v0.1.0A composite action: downloads the pinned release for the runner’s OS and architecture, checks its SHA-256 against checksums.txt, runs it
GitLab CIinclude the template and extends: .graphwake-uploadRuns the image in merge request pipelines and on the default branch
Azure DevOpsA script step with the binary, or the GraphwakeUpload@0 task where the Graphwake extension is installedDownloads the binary, checks the checksum, runs it
Bitbucket Pipelines, Jenkins, anything elseThe image ghcr.io/graphwake/agent:0.1.0 or the binaryNothing extra: you call graphwake upload
yaml
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 }}
GitHub Actions, from the agent README. The closed type lets the agent report pull requests closed without merging.
yaml
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
GitLab CI. Add GRAPHWAKE_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.

bash
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
Download, verify against checksums.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:

text
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_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 do not 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?

How the CI step classifies a buildA CI build is classified three ways. A pull request build stores the spec as a draft so consumer teams hear before merge. A live-branch build makes the spec live, sends notifications and settles the PR draft as merged. Anything else sends nothing.CI buildPull requestbuildLive-branchbuildAnything elseDraft specstored as draftLive specPR draft: mergedNothing sentConsumer teamshear before mergeNotificationssent on mergeno spec, no alert
Three outcomes from one step. Only pull request builds and live-branch builds send anything.
PlatformPull request buildLive build
GitHub Actionspull_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 CIMerge request pipelines (CI_MERGE_REQUEST_IID)CI_COMMIT_BRANCH is a live branch (default CI_DEFAULT_BRANCH)
Azure DevOpsBUILD_REASON=PullRequestOther builds of a live branch (default main / master)
Bitbucket PipelinesBITBUCKET_PR_IDBITBUCKET_BRANCH is a live branch
Jenkins (multibranch)CHANGE_ID, CHANGE_URL, CHANGE_AUTHORBRANCH_NAME (or GIT_BRANCH) is a live branch
Anything else--prOnly 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.

json
{
  "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"
  }
}
Example: the request body for a pull request build on GitHub Actions (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:

  1. The checked-out commit is the build’s commit

    git rev-parse HEAD must match the commit the platform says it is building. If they differ, the live spec is not sent.

  2. 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 only contents: read. Elsewhere it runs git ls-remote origin refs/heads/<branch>. If the branch has moved on, nothing is sent and the newer build sends the live spec.

text
graphwake: this build is not the tip of main (a newer build sends the live spec), nothing sent
The one line printed when a build is behind.

We 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:

text
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:

text
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. --key works, but command-line arguments show up in process lists. The README recommends GRAPHWAKE_KEY from 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

See who your next breaking change reaches.Paste a spec URL or add one CI step. 7-day free trial. Cancel before day 7 and you pay nothing.
Start free trial

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.

bash
go vet ./... && go test ./...
go build -o graphwake ./cmd/graphwake
./graphwake version
Build and test it yourself.

Next steps

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 trial

Sources and last verified

Sources checked on 2026-11-01.