Documentation for v2.6.1. 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.
  • On a multi-replica PostgreSQL install, a provide handled by one replica is visible on the others within 10 s (the data cache's expiry). A pipeline that provides and immediately requires the same version from another replica should expect at most that delay.

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.

  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}'

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.

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#

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