Contracts
A generated lockfile for the interface and wiring of every workflow and local action — reviewed in pull requests, checked in CI.
A contract records what a workflow or local action accepts, what it returns, what it calls and who calls it. flowpact
writes one contract per workflow and per local action into .github/flowpact/, you commit them, and
flowpact check fails when the workflows no longer match.
Think of it as a lockfile for interfaces: package-lock.json makes dependency changes visible in review; contracts do
the same for the inputs, secrets and outputs that flow between workflows. When someone removes an output that another
workflow reads, the pull request shows the change in the contract, and CI marks it as breaking.
Generated only
Contracts are never written by hand:
flowpact generateproduces them from the workflows. Output is deterministic: keys are sorted, and there are no hashes, timestamps or tool versions in the file. Comments, key order and YAML quoting in your workflows do not change a contract; a change to the interface or the wiring does. (with:values are recorded as written, so editing an expression shows up too.)- A contract describes what is, so it can never disagree with the workflows after you regenerate.
- Decisions about findings live in the config, not in contracts.
Each file starts with a header that says so:
# Generated by flowpact — do not edit by hand. Run `flowpact generate` to update.
# Docs: https://rumankazi.github.io/flowpact/docs/contractsWhat is in a contract
This is the locked (committed) contract for a reusable deploy workflow in the
contracts-drift fixture. It still lists the
deployed-version output that the fixture's workflow has since removed — exactly the drift flowpact check reports as
FP803; flowpact generate would drop it:
# Generated by flowpact — do not edit by hand. Run `flowpact generate` to update.
# Docs: https://rumankazi.github.io/flowpact/docs/contracts
$schema: https://rumankazi.github.io/flowpact/schemas/contract/v1.json
version: 1
kind: workflow
path: .github/workflows/deploy.yml
name: Deploy
triggers:
- workflow_call
interface:
inputs:
dry-run:
type: boolean
required: false
default: false
environment:
type: string
required: true
legacy-flag:
type: string
required: false
default: ''
description: Read by the old release dispatcher only
version:
type: string
required: true
secrets:
deploy-token:
required: true
outputs:
deployed-version:
description: The version that was deployed
value: ${{ jobs.deploy.outputs.version }}
url:
description: Where the release was deployed
value: ${{ jobs.deploy.outputs.url }}
uses:
- job: deploy
step: '#2'
uses: .github/actions/notify
with:
message: Deployed ${{ inputs.version }} to ${{ inputs.environment }}
consumers:
- from: .github/workflows/release.yml
job: deploy
passes:
- dry-run
- environment
- version
reads:
- url| Section | Contents |
|---|---|
kind, path, name | workflow or action, the repository path and the display name. |
triggers | Workflow events, sorted (workflows only). |
interface.inputs | Every workflow_call input (or action input) with type, required, default, description and choice options. |
interface.dispatchInputs | workflow_dispatch inputs, in the same form (workflows only). |
interface.secrets | workflow_call secrets and whether they are required. |
interface.outputs | workflow_call (or action) outputs with their description and value expression. |
calls | Reusable workflows this workflow calls, per job: what it passes in with: and secrets: (or inherit), needs, and the matrix shape (combinations, keys, exact). |
uses | Local actions used by steps (step is the step id, or #n for the n-th step). |
consumers | Who calls this workflow or uses this action, which inputs they pass and which outputs they read. |
A caller's contract shows the wiring from the other side, including the matrix it fans out over:
calls:
- job: run
uses: .github/workflows/run-suite.yml
with:
config: ${{ matrix.config }}
suite: ${{ inputs.suite }}
variant: ${{ matrix.name }}
matrix:
combinations: 3
keys:
- config
- name
exact: trueThe schema is published at /schemas/contract/v1.json.
File names
| Unit | Contract file |
|---|---|
.github/workflows/ci.yml | .github/flowpact/contracts/workflows/ci.contract.yml |
.github/actions/setup | .github/flowpact/contracts/actions/setup.contract.yml |
.github/actions/node/setup | .github/flowpact/contracts/actions/node__setup.contract.yml |
tools/build (any other local action) | .github/flowpact/contracts/actions/tools__build.contract.yml |
Names that would collide (ci.yml and ci.yaml) get a distinguishing suffix, and an existing contract keeps its
file name.
Workflow
Generate
flowpact generateWrites a contract for every workflow and local action, updates the ones that changed and deletes contracts whose workflow or action no longer exists. It prints what it did, with the semantic changes per file; breaking ones are marked.
Preview first with --dry-run, which prints the same table plus a colored diff and writes nothing:
Commit
Commit .github/flowpact/ with the workflows. From now on, every pull request that changes an interface
also changes a contract, and reviewers see the change as a small YAML diff.
Check in CI
flowpact checkRuns every lint rule and compares the contracts on disk with the ones the current workflows produce. Contract findings are errors by default, so the job fails until the contracts are regenerated (see CI usage).
Regenerate on intentional changes
When the change is intended, run flowpact generate again and commit the result. For a breaking change, first update the
callers listed under consumers — including those in other repositories, which flowpact cannot see.
Breaking and non-breaking changes
flowpact generate and flowpact check compare the committed contract with the current one field by field:
| Change | Breaking |
|---|---|
| Input removed | yes |
Input type changed (string → number, …) | yes |
Input becomes required, or is added as required — for a workflow_call input even with a default (GitHub requires every caller to pass it); for action inputs only without one | yes |
| Choice option removed | yes |
| Secret removed, becomes required, or added as required | yes |
| Output removed | yes |
workflow_call trigger removed | yes |
workflow_dispatch input removed, type changed, choice option removed, or required without a default (added or changed) | yes |
Optional input added (or, for actions and workflow_dispatch, a required input with a default) | no |
| Input becomes optional, default or description changed, choice option added | no |
| Secret becomes optional, optional secret added | no |
| Output added, or its value or description changed | no |
| Other triggers or the name changed | no |
What this workflow passes to the workflows and actions it calls (calls, uses) | no |
| Consumers added or removed, or what they pass or read | no |
Breaking means: a caller written against the committed contract can now fail (unknown or missing input or secret) or silently read an empty value.
Codes
| Code | Name | Reported when |
|---|---|---|
FP801 | contract-missing | A workflow or local action has no contract. |
FP802 | contract-outdated | The contract differs from the workflow by non-breaking changes. |
FP803 | breaking-interface-change | A breaking change, one finding per change, with the known consumers as context. |
FP804 | orphan-contract | A contract exists for a workflow or action that no longer exists. |
FP805 | contract-invalid | A contract file is not valid YAML or does not match the schema. |
All five are errors by default and are only reported by flowpact check (flowpact lint does not read contracts). Like any
rule, their severity can be changed under rules: in the config.
Merge conflicts
Two branches that both change an interface will conflict in the contract. Do not merge contract files by hand: resolve
the conflicts in the workflow files, then regenerate. flowpact generate overwrites the conflicted contracts, conflict
markers and all.
flowpact generate
git add .github/flowpact/A contract that was hand-edited or merged into invalid YAML is reported by flowpact check as FP805.
Without the CLI: download the regenerated contracts
Not everyone who edits a workflow has Node.js or flowpact installed. CI can regenerate the contracts for them:
- The flowpact GitHub Action uploads the regenerated contracts and a
git apply-able patch as an artifact when it finds drift. - With the CLI alone,
flowpact check --patch flowpact-contracts.patchwrites the patch when contracts drifted; upload it withactions/upload-artifact(see CI usage).
Then, from the repository root:
git apply --index flowpact-contracts.patch # --index also stages contracts the patch creates
git commit -m "Update workflow contracts"flowpact generate --patch <file> writes the same patch locally, and flowpact generate --out <dir> writes the complete set of
contracts under another directory (without deleting anything) — useful to inspect them side by side.