Documentation for v2.10.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 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.