Documentation for v2.11.0. Read the latest, v2.12.0.

Developer

CI integration

A pipeline provides and requires unconditionally on every build. This chapter is what it needs to know: which credential to send, how to say snapshot or GA and which stream it is, how to validate a pull request without writing anything, and why re-runs never fight.

What changed in 2.6.0

  • Instead of polling, a pipeline or a chat channel can be told: Sanshain delivers signed webhooks for every published or rejected provide, every new dependency, every branch cut and catalog push. Registering receivers and verifying the signature is Webhooks.

API tokens#

Pipelines use API tokens, never passwords. A token is long-lived, revocable, owned by one user, and has a scope; CI needs full, because read_only is refused on provide, require and the catalog push. What the token may do beyond that is its owner's permissions: GA provides need the releaser role, so the CI user that releases holds it.

The CI user is best a service account: an administrator creates it in the Users section of /admin.html (or POST /admin/users with {"username":"jenkins"}). It has no password and never signs in; on their own /account.html, holders of manage_users choose it under For when creating a token. Every user manager sees its tokens with who issued each and can revoke them, so a leaked CI token does not wait for one particular person. It works in every auth mode, LDAP and OIDC included, and gets its roles like any user.

A token for a person's own account works the same way:

  1. Log in to /account.html as the CI user.
  2. Under API Tokens, enter a name (jenkins-ci) and an expiry in days, leave the read-only box unticked, and create.
  3. The raw token (san_…) is shown once; copy it into the CI secret store.

Or with the API, from a session token:

SESSION=$(curl -s -X POST $SANSHAIN/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"ci-user","password":"…"}' | jq -r .token)

curl -s -X POST $SANSHAIN/auth/tokens \
  -H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
  -d '{"name":"jenkins-ci","expires_in_days":365}'

# or, as a user manager, for the service account jenkins:
curl -s -X POST $SANSHAIN/auth/tokens \
  -H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
  -d '{"name":"deploy","expires_in_days":365,"for_user":"jenkins"}'

Every API call then sends Authorization: Bearer san_…. The header is the only place a token is read from; a call without it is refused with a bare 403, an invalid token with a bare 401.

Stability from the pipeline#

Every provide declares a stability, and the pipeline is the place to decide it. Release-branch builds provide ga; everything else provides snapshot. With a client that is the ga switch (SANSHAIN_GA=true, --ga, -Dsanshain.ga=true) set on the protected-branch pipeline only; a raw curl pipeline sets the field itself. The version is never a pipeline concern: it is read from the spec file.

Streams from the pipeline#

Alongside stability, the pipeline declares the stream:

Streams never change what is served or any version rule. The model is in Release graphs.

Why: the origin of a build#

A provide may carry what the pipeline knows about why it runs (Sanshain Service 2.9.0 or later): build_url, where the build ran; change_url, the pull or merge request; tickets, at most 20 ids of letters, digits, -, _ and .; and note, one line of at most 200 characters. Both URLs must be http(s). Sanshain shows the origin on the version row, in the diff and the blame; ticket ids become links through the administrator's ticket URL template. A raw curl pipeline sends the fields itself.

The build link is shown only when it came from the trunk build (trunk=true): CI servers such as Jenkins delete a branch's builds after the merge, so a branch build's link ends on an error page. When the trunk build after the merge provides the same content, its build link replaces the branch build's and fills fields the branch build left empty; the pull request, tickets and note of the branch build stay. Origins stored before 2.11 count as branch builds' and show no build link until their trunk build comes along.

The clients fill the two URLs from the runner's own variables; tickets and the note are intent and never come from the runner:

Runner build_url change_url
GitHub Actions $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/<n> when GITHUB_REF is refs/pull/<n>/…
GitLab CI CI_JOB_URL $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID in a merge-request pipeline
Jenkins BUILD_URL CHANGE_URL (multibranch pull-request builds)

The checks run in that order, whatever CI the build is on. Azure Pipelines, Bitbucket Pipelines, CircleCI, Buildkite, TeamCity and others fill nothing; set SANSHAIN_BUILD_URL and SANSHAIN_CHANGE_URL from their variables instead. For every field the client takes the first value that is not blank, in this order:

  1. the client's flag or property (table below);
  2. the environment: SANSHAIN_BUILD_URL, SANSHAIN_CHANGE_URL, SANSHAIN_TICKETS (comma-separated; trimmed, duplicates dropped), SANSHAIN_NOTE;
  3. for the two URLs, the runner variables above.

A blank value counts as unset, so an empty flag or variable does not switch the runner's URL off; only unsetting the runner variable does. No client reads the origin from sanshain.yaml or from git. Against a service older than 2.9.0, which refuses the fields, every client drops the origin, warns, and provides again without it.

Client Origin dry_run
Maven -Dsanshain.buildUrl, -Dsanshain.changeUrl, -Dsanshain.tickets, -Dsanshain.note -Dsanshain.dry.run=true (provide, require, catalog)
Rust (cargo sanshain provide) --build-url, --change-url, --tickets, --note not available
Go -build-url, -change-url, -tickets, -note, before the command (sanshain-go -tickets PAY-12 provide) not available
JavaScript CLI --build-url, --change-url, --tickets, --note --dry-run (provide, require)
GitHub Action inputs tickets and note; the URLs come from GitHub itself not available
Conan CLI --build-url, --change-url, --tickets, --note not available
Conan conanfile.py the environment only not available

The catalog push#

A build with catalogInfo in its sanshain.yaml also pushes the project's Backstage catalog-info.yaml once per build: PUT /catalog/{systemname} with {"catalog_info_yaml": "…", "trunk": …}. Sanshain reads the Component named like the project and of it the periphery and the catalog context; the exact fields are in sanshain.yaml. A file Sanshain cannot read as the project's Component is answered 200 with "recorded": false and a warning header; nothing is recorded and the previous state stays. An unchanged file changes nothing.

The Sanshain-Warning header#

The graph is informational and never fails a build. When Sanshain cannot record a call the way it was declared, the call still succeeds and the response carries one Sanshain-Warning line per problem, on provides, requires, bundles and catalog pushes, dry runs and 304 answers included:

Sanshain-Warning: no sanshain-branch '<name>' - this build was not recorded in a release graph
Sanshain-Warning: trunk and tag are mutually exclusive - this build was recorded in neither
Sanshain-Warning: <the reason a catalog push recorded nothing>

The value is plain ASCII: any other character, from a tag name say, is escaped as \u{XXXX} (the code point in hex, at least four digits), so Größe arrives as Gr\u{00F6}\u{00DF}e. The clients print each warning; in strict mode they fail the build on it. A raw curl pipeline checks the header itself (-D - dumps the headers).

Dry runs for pull requests#

/provide (all three types), /require and /require-bundle accept dry_run. The request runs the full validation and persists nothing, not even the Producer: a provide parses the spec, reads and validates the version, splits the endpoints and applies the version rules; a require resolves the endpoint at the pin without recording a dependency. Dry runs emit no webhook events. That is what a pull-request pipeline wants: a forgotten bump or a semver lie blocks the merge, and the database holds nothing temporary.

Validate a Producer's spec#

Request (in the Producer's pipeline, on a pull request)

curl -f -X POST $SANSHAIN/provide \
  -H "Authorization: Bearer $SANSHAIN_TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"producername\": \"orders\",
    \"stability\": \"ga\",
    \"openapi_yaml\": $(jq -Rs . < openapi.yaml),
    \"dry_run\": true
  }"

Response when the version needs a bump

HTTP/1.1 409 Conflict
{ "error": "version 1.1.0 of openapi 'orders' is GA and immutable, but the submitted content differs — did you forget to increment info's version? Publish as 1.2.0 instead",
  "proposed_version": "1.2.0" }

curl -f fails the job on the 409, and the developer knows which version to set before merging. A dry run as ga still needs the releaser role.

Validate a Consumer's pins#

Request

curl -f "$SANSHAIN/require?consumername=web-shop&producername=orders&version=2.1.0&path=/orders&method=GET&dry_run=true" \
  -H "Authorization: Bearer $SANSHAIN_TOKEN"

Responses

HTTP/1.1 404 Not Found
{ "error": "Producer 'orders' has no openapi version 2.1.0 (in either stability) — a missing pinned version is a configuration error, nothing waits for it to appear" }

HTTP/1.1 410 Gone
{ "error": "openapi version 2.1.0 of 'orders' does not include GET /orders" }

404 is unknown, fix the pin; 410 is absent, the version exists and deliberately lacks the endpoint.

Validate a bundle#

Request

curl -f -X POST $SANSHAIN/require-bundle \
  -H "Authorization: Bearer $SANSHAIN_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "consumername": "web-shop",
    "producername": "orders",
    "version": "2.1.0",
    "endpoints": [
      {"path": "/orders", "method": "GET"},
      {"path": "/orders/{id}", "method": "DELETE"}
    ],
    "dry_run": true
  }'

Response when endpoints are missing

HTTP/1.1 410 Gone
{ "error": "openapi version 2.1.0 of 'orders' does not include: DELETE /orders/{id}" }

Idempotency and caching#

Sanshain is built so pipelines can provide and require unconditionally on every build.

Byte-identical content is a no-op#

Re-providing identical content, whatever the stability or the caller, stores nothing and answers changes all zero: the idempotent no-op. Before comparing, hashing and storing, Sanshain normalises every upload: CRLF and lone CR become LF and a leading UTF-8 BOM is removed, so the same file hashes the same from every platform. The comparison is byte-for-byte on the normalised text; whitespace and comments still count. A .gitattributes rule keeps the repository's files equal to what Sanshain stores:

*.yaml  text eol=lf
*.proto text eol=lf

Version conflicts are self-service#

There is no pull-merge-retry loop. A 409 carries the proposed version; the fix is a version bump in your own spec file. Two developers editing the same spec coordinate in git, and the next publish after their merge converges the snapshot.

ETags on requires#

Every require answer carries an ETag; If-None-Match yields 304 Not Modified when the content is unchanged, so a pipeline skips code generation. A GA pin can never change content; a snapshot pin can, and the ETag detects exactly that. The clients do this for you.

The typical pipelines#

The dry runs below need a client that sends dry_run: Maven and the JavaScript CLI do (table above); with the other clients a pull-request pipeline calls the route with curl, as in Dry runs for pull requests.

A Producer#

pull request   → provide as ga with dry_run (validate the version rules)
merge to main  → provide as ga, trunk: true (store; trunk's current version)
feature branch → provide as snapshot (optional; opted-in Consumers can pin it)
hotfix branch  → provide as ga, tag: "<release>"

A Consumer#

pull request   → require or bundle with dry_run (every pin exists and holds its endpoints)
merge to main  → require or bundle, trunk=true (record the edge, generate the client)
hotfix branch  → require with tag=<release>

What an error says#

Status Meaning
409 Conflict Refused by the version rules; proposed_version names the number to publish as.
403 Forbidden A GA provide without the releaser role, a retire without the role or a maintainer grant, or a read-only token on a write route. The body names the role or scope needed.
404 Not Found Unknown: the Producer or the pinned version does not exist. Fix the pin.
410 Gone Absent: the pinned version exists and lacks the endpoint(s); bundles name them.
400 Bad Request The document is invalid: malformed YAML, a missing or non-semver version, a missing proto marker.
422 The JSON body has the wrong shape: a missing stability, or a 1.x field such as branch.

The messages are written to be shown in CI logs as they are; the complete list of shapes is in Using the API.

GitHub Actions#

Keep the URL in a repository or organisation variable and the token in a secret, so moving the service changes one variable. With the JavaScript client's Action:

name: Contracts
on: [pull_request, push]

jobs:
  sanshain:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: paxel/sanshain-js@main
        with:
          command: provide
          ga: ${{ github.ref == 'refs/heads/main' }}
          trunk: ${{ github.ref == 'refs/heads/main' }}
        env:
          SANSHAIN_URL: ${{ vars.SANSHAIN_URL }}
          SANSHAIN_TOKEN: ${{ secrets.SANSHAIN_TOKEN }}

Or with curl, as a pull-request check:

      - name: Validate the API contract (dry run)
        run: |
          curl -f -X POST ${{ vars.SANSHAIN_URL }}/provide \
            -H "Authorization: Bearer ${{ secrets.SANSHAIN_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d "{
              \"producername\": \"my-service\",
              \"stability\": \"ga\",
              \"openapi_yaml\": $(jq -Rs . < openapi.yaml),
              \"dry_run\": true
            }"

Maven and the other build tools#

The Maven plugin reads the URL from a property and the token from a <server> entry, so neither has to live in the repository; the <sanshain.url> property can also sit in pom.xml. The profile needs activating:

Maven settings.xml

<profiles>
  <profile>
    <id>sanshain-config</id>
    <activation>
      <activeByDefault>true</activeByDefault>
    </activation>
    <properties>
      <sanshain.url>https://sanshain.corp.com</sanshain.url>
    </properties>
  </profile>
</profiles>

<servers>
  <server>
    <id>sanshain</id>
    <username>ignored</username>
    <password>san_xxxxxxxxxxxx</password>
  </server>
</servers>

The Maven plugin keeps the require ETags in target/.sanshain-cache.json, which mvn clean removes. Every other build tool (Gradle included, which has no plugin and runs one of the CLIs) gets the URL and the token from the environment:

export SANSHAIN_URL=https://sanshain.corp.com
export SANSHAIN_TOKEN=san_xxxxxxxxxxxx

To check the format: openapi answer end to end with openapi-generator against a running instance, the repository has scripts/openapi-generator-check.sh, a manual check that is not part of CI.

Checked against v2.10.0.