CI cookbook¶
These recipes make the source snapshot, lock schema, and gate policy explicit. The recipes describe boundver 0.13's v3/semantic-config-v2 contract. Boundver 0.11 writes v3/v1 locks and 0.10.x writes v2 locks; both require regeneration and must not be mixed with these writers.
GitHub Actions: recommended contract gate¶
# .github/workflows/boundary-check.yml
name: Contract boundary
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Keep the writer and verifier on the repository's lock-contract version.
- uses: yzm1/boundver@v0.15.2
with:
config: boundary.config.json
lock: boundary.lock.json
source: head
head is the committed pull-request tree. The tagged Action installs the
boundver source bundled with that release, including schema and YAML extras, and
returns exit-code, issues, observations, and compact JSON
consumer-impact outputs. Each potentially repository-sized payload is capped
at 64 KiB measured as UTF-16 so the Action stays well below GitHub's
1 MB per-job output limit.
truncated-outputs is a compact JSON array naming any bounded or unavailable
values. result-file points to the complete runner-local verify JSON for
inspection or artifact upload. If verification fails before emitting usable
JSON, all three repository-sized outputs are named in truncated-outputs and
result-file contains a valid diagnostic JSON with ok: false and an
action_transport.reason. Treat an incomplete consumer-impact as a
fail-closed routing condition; its bounded value is [], never an incomplete
downstream closure.
The Action inputs are:
configandlock: paths relative to the checkout root.source:head,index, orworking-tree.facets: optional comma-separated CLI-wide override. Leave it blank to honor component/default policy.components: optional comma-separated component names. Configured names cannot contain commas or surrounding whitespace.changed-from: optional Git ref for changed-path reporting.transitive: set totrueto include the downstream consumer closure in impact output.fail-fast: report only the highest-severity issue after safe evaluation.update: regenerate after successful computation; normally leave this false in a pull-request gate.python-version: Action runtime, defaulting to 3.12.
Verification baselines¶
The baseline Action input and the CLI's create-only --write-baseline and
shrink-only --update-baseline flags are available in v0.13. The Action
applies a supplied baseline read-only and never creates or updates baseline
debt. See migration inspection and verification
ratchets
for the complete workflow and safety constraints.
Pick a signal-to-noise policy¶
| Policy | Facets | What fails |
|---|---|---|
| Consumer-facing | boundary,compat |
Declared public artifact or compatibility-family drift; requires both facets on every selected component |
| Behavior-sensitive | behavior,boundary,compat |
Observable behavior, boundary, or compatibility drift; requires all three |
| Portable tracked-source hygiene | exact |
Tracked content, paths, and file identities; text CRLF/LF are equivalent; works for leaf and unversioned components |
Policy can live in configuration:
The Action leaves the override blank by default. An explicit --facets value
overrides every component; otherwise a component's
verify_facets overrides defaults.verify_facets. With no configured policy,
the implicit default gates all facets available for each component. Fingerprint
drift outside the effective gate is returned as an observation.
An explicitly selected but unavailable facet is a usage error (exit 2), not a
clean null comparison. For example, compat requires a version_source, and a
leaf component or a pathless implicit component has no boundary digest.
Give heterogeneous components their own policies instead of choosing one loose
global policy:
{
"project": "payments-platform",
"defaults": {"verify_facets": ["boundary", "compat"]},
"components": {
"public-api": {
"path": "services/api",
"version_source": {"file": "package.json", "field": "version"},
"boundary": {"provider": "openapi", "paths": ["openapi.yaml"]}
},
"website": {
"path": "apps/website",
"version_source": null,
"boundary": {"provider": "leaf", "paths": []},
"verify_facets": ["exact"]
}
},
"slices": {}
}
Structural, semantic-config, provider-metadata, and digest errors remain failures because the comparison is otherwise unreliable.
Facets classify reporting and exit policy; they do not select fields to hash or write. Every computed component entry contains all four facets. Exact-only is the portable strict source gate because every component has an exact fingerprint. It does not turn canonicalization into compatibility analysis or create a compatibility identity for an unversioned component.
Changed-path reporting without an integrity shortcut¶
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: yzm1/boundver@v0.15.2
with:
source: head
changed-from: origin/${{ github.base_ref }}
--changed-from reports the components and slices mapped from Git-tracked path
changes. It still recomputes the full lock for integrity, including provider
versions, semantic configuration, tag-derived versions, and stored metadata.
An invalid ref is an input error. A config-file change selects all components
for reporting because it can redefine contracts without touching their source
directories.
Use components only when the repository intentionally owns a narrower check:
The affected slices are checked as well. A component filter narrows ordinary verification, so use an unfiltered gate somewhere in the repository unless the remaining components have an independent owner.
Route pull-request work from an immutable range plan¶
From v0.15, the maintained Action can compare two reconciled commits and emit
the smaller boundver-plan/v1 routing contract. This is a historical query;
keep an ordinary verify job as the current-tree integrity gate.
The example below assumes the base commit and pull-request tip both contain
locks reconciled to those exact trees. Require the branch to update and commit
its lock before this job runs. If locks are updated only periodically, an
unreconciled pull-request tip is not a valid target; --merge-base does not
change that. Keep conservative test routing and use verify --changed-from for
current-tree integrity and path reporting until a complete plan is available.
jobs:
contract-plan:
runs-on: ubuntu-latest
outputs:
selection-complete: ${{ steps.review.outputs.selection-complete }}
test-components: ${{ steps.review.outputs.test-components }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- id: review
uses: yzm1/boundver@v0.15.2
with:
operation: review
base: ${{ github.event.pull_request.base.sha }}
target: HEAD
merge-base: true
transitive: true
upload-artifact: true
artifact-name: boundver-review-${{ github.run_id }}
- name: Refuse a partial output projection
if: steps.review.outputs.selection-complete != 'true'
run: exit 1
affected-consumers:
needs: contract-plan
if: >-
needs.contract-plan.outputs.selection-complete == 'true' &&
needs.contract-plan.outputs.test-components != '[]'
strategy:
matrix:
component: ${{ fromJSON(needs.contract-plan.outputs.test-components) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- env:
BOUNDVER_TEST_COMPONENT: ${{ matrix.component }}
run: ./ci/test-component "$BOUNDVER_TEST_COMPONENT"
On success, result-file is the complete runner-local plan. An unreliable
review instead sets transport-complete and selection-complete to false
and preserves a diagnostic result; it never substitutes a partial plan. The
name-array outputs are separately bounded for GitHub's job-output limit; a
bounded array becomes [], is named in truncated-outputs, and sets
selection-complete to false instead of returning a partial closure. The
optional uploaded artifact contains the full JSON or failure diagnostic and
the bounded Markdown summary. The Step Summary always labels presentation
truncation. File annotations are emitted only for exact structural target
files when the reviewed target is the checked-out HEAD.
Repository-controlled component names are passed through the environment in
the example instead of being interpolated into shell source. Platform matrix
job-count limits still apply; batch a very large selection or route from the
complete artifact rather than treating selection-complete as a waiver of
those limits.
The GitLab Catalog component exposes the same endpoint and policy inputs. Set
depth 0 at component expansion time so the runner fetches history before the
script starts:
include:
- component: gitlab.com/boundver-project/boundver/boundver@0.15.2
inputs:
job-name: boundver-review
operation: review
base: $CI_MERGE_REQUEST_DIFF_BASE_SHA
target: $CI_COMMIT_SHA
merge-base: true
transitive: true
history-depth: "0"
consumer-tests:
stage: test
needs:
- job: boundver-review
artifacts: true
script:
- python -c 'import json; print(json.load(open("boundver-result.json"))["selection"]["test_components"])'
The generated job retains boundver-result.json and boundver-summary.md even
on failure. Missing refs in a shallow checkout fail before endpoint content is
read and print the exact fetch-depth: 0 / GIT_DEPTH: 0 remediation.
Pin a package instead of the Action¶
Pin the writer/verifier version that matches the committed lock schema:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: python -m pip install --upgrade "boundver[schema,yaml]==0.15.2"
- run: python -c "import boundver; assert boundver.__version__ == '0.15.2', boundver.__version__"
- run: python -m boundver verify --source head
Use this form for a PyPI mirror or centrally managed Python environment. Do not let one job write an old v2 or v3/semantic-config-v1 lock while another verifies the current v3/semantic-config-v2 contract.
The exact upgraded install and import assertion are mandatory when the
environment persists across runs: reused developer virtual environments,
language: system pre-commit hooks, and prebuilt container images must not
trust an ambient boundver executable. Put the install/assert pair in the
environment or image build. Retain the assertion at invocation so a stale
layer fails before it can write or verify a lock. Invoke python -m boundver
with that same interpreter so PATH cannot select a different executable. A
disposable runner should still use the exact pin; the tagged composite Action
already binds its bundled implementation to the Action tag.
Match source mode to the lifecycle¶
| Lifecycle | Source |
|---|---|
| Pull request / post-commit CI | head |
| Pre-commit | index |
| Local review before staging | working-tree |
Config and lock are bound to the snapshot for head and index, so a staged
pipeline must stage the lock before it verifies. The full rules, including the
staged-refresh command sequence, are in
reference.
Check generated artifacts before verifying them¶
Boundver hashes a generated contract but does not bind it to its generator, so a stale committed artifact verifies clean. Run the generator's deterministic check first:
- name: Check generated OpenAPI is current
run: python ci/generate_platform_openapi.py --check
- name: Verify recorded boundaries
run: boundver verify --source head
See reference
for why there is no executable derived_from field.
Report in CI; update during review¶
A CI gate should normally leave the checkout unchanged:
- name: Verify contracts
run: boundver verify --source head
- name: Print machine-readable details
if: failure()
run: boundver verify --source head --format json
status --format json continues to expose lock state. Structured why and
slice output are also available:
boundver why payment-api --source head --transitive --format json
boundver slice checkout-contracts --format json
The author reviews and accepts drift locally:
boundver verify --source working-tree
boundver why payment-api --source working-tree
boundver verify --source working-tree --update
git diff -- boundary.lock.json
An unfiltered update regenerates the full lock. A component-scoped update first recomputes every current entry, refuses stale unselected entries, replaces each selected entry as one unit, and recomputes all slices:
boundver verify \
--source working-tree \
--components payment-api \
--facets boundary,compat \
--update
This command updates exact and behavior data for payment-api too; --facets
does not preserve old non-gating fields.
Exit-code-aware automation¶
Boundver's exit code carries the drift class, so a pipeline can route on it
directly. The code table and a complete case dispatcher are in
reference.
The one distinction worth building your pipeline around: exit 2 means
boundver could not perform a reliable check, not that something drifted. Fail
the build on 2 — never treat it as an acceptable result:
code=0
boundver verify --source head --facets behavior,boundary,compat || code=$?
if [ "$code" -eq 2 ]; then
echo "boundver could not perform a reliable check" >&2
exit 2
fi
exit "$code"
GitLab CI¶
boundary-verify:
stage: test
image: python:3.12-slim
before_script:
- apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
- python -m pip install --upgrade "boundver[schema,yaml]==0.15.2"
- python -c "import boundver; assert boundver.__version__ == '0.15.2', boundver.__version__"
script:
- python -m boundver verify --source head
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Ensure the checkout contains the required history when using
--changed-from or tag-based version sources.
Pre-commit and pre-push¶
Use the published hook definitions so the staged and committed lifecycles are not conflated:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/yzm1/boundver
rev: v0.15.2
hooks:
- id: boundver-verify # pre-commit: source=index, portable exact gate
- id: boundver-verify-push # pre-push: source=head, portable exact gate
The published hooks above run from the exact rev in pre-commit's managed
environment. If a repository instead uses a local language: system hook or a
shared hook environment, bootstrap it with the exact --upgrade install and
version assertion from Pin a package instead of the
Action. Then invoke it through
python -m boundver from that interpreter.
Use an exact patch tag such as v0.15.2 for reproducible hook execution. A
two-component alias such as v0.15 is intentionally mutable and advances to
the newest patch release in that line. Do not pin the hook to one patch while a
separate CI assertion or system installation expects another; update those
version identities together.
When the staged check finds intentional drift, regenerate from the index and stage the result. Because the config is read from the staged snapshot, stage a config change before generation:
# Include boundary.config.json here when it changed.
git add services/payment/openapi.yaml
boundver generate --source index
git add boundary.lock.json
boundver verify --source index
If you want the hook to generate automatically, add boundver-generate, but
always inspect the resulting lock diff before committing.
Slice fingerprints as cache keys¶
A slice combines one facet from several components. Membership can be an
explicit components list or closure_of, which resolves the seed plus its
transitive downstream configured-component graph. The resolved membership is
stored in the lock. Read the committed value into a workflow output:
jobs:
contract-key:
runs-on: ubuntu-latest
outputs:
fingerprint: ${{ steps.key.outputs.fingerprint }}
steps:
- uses: actions/checkout@v6
- id: key
shell: bash
run: |
value=$(python -c 'import json; print(json.load(open("boundary.lock.json"))["slices"]["checkout-contracts"]["fingerprint"])')
echo "fingerprint=$value" >> "$GITHUB_OUTPUT"
build-consumers:
needs: contract-key
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/cache@v4
with:
path: consumer-build
key: consumer-${{ needs.contract-key.outputs.fingerprint }}
- run: make build-consumers
A boundary slice rotates when a member boundary changes. It is a deterministic cache key, not a substitute for consumer tests.
Schedule transitive consumer work¶
consumers edges name configured downstream components;
external_consumers names opaque terminals outside the config. Direct impact
is the default. Add --transitive when the CI scheduler needs the complete
declared downstream closure:
The stable consumer_impact array is intended for CI fan-out. Each row names
the drifted producer, the gated boundary/compat facets, configured component
consumers, external terminals, and whether traversal was transitive. For a
component-only matrix:
boundver verify --source head --transitive --format json > boundver-result.json || rc=$?
jq -r '.consumer_impact[].components[]' boundver-result.json | sort -u
exit "${rc:-0}"
The composite Action exposes the same array as the compact JSON output
consumer-impact; use continue-on-error when a later step or job must route
work from an intentionally failing drift gate. Before routing, require that
fromJSON(steps.<id>.outputs.truncated-outputs) does not contain
consumer-impact; otherwise inspect or upload steps.<id>.outputs.result-file.
Traversal is deterministic and cycle-safe and includes external terminals attached to any reached component. It follows only declared graph edges; it does not discover build-system or runtime dependencies.
Concurrent lockfile updates¶
Do not hand-edit JSON conflict hunks and do not regenerate inside a Git merge driver. Finish merging source and configuration, then regenerate from the materialized snapshot. See the lockfile merge strategy.