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.
- Log in to
/account.htmlas the CI user. -
Under API Tokens, enter a name (
jenkins-ci) and an expiry in days, leave the read-only box unticked, and create. - 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:
-
Trunk CI (the build of your default branch) adds
trunk: trueto provides, bundles and the catalog push, andtrunk=trueto requires. This maintains the main graph. Nightly re-runs of an unchanged trunk build are wanted: they keep trunk data fresh instead of ageing out. -
Release and hotfix pipelines add
tag: "<sanshain-branch>". The calls update that release's graph. The branch must exist, cut by a releaser; an unknown tag, ortrunkandtagtogether, does not fail the call but records it in the dev graph only, with a warning header.
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.