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
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:
- 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}'
# 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:
-
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.
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:
- the client's flag or property (table below);
-
the environment:
SANSHAIN_BUILD_URL,SANSHAIN_CHANGE_URL,SANSHAIN_TICKETS(comma-separated; trimmed, duplicates dropped),SANSHAIN_NOTE; - 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.