flowpactworkflow contracts

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 generate produces 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/contracts

What 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:

.github/flowpact/contracts/workflows/deploy.contract.yml
# 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
SectionContents
kind, path, nameworkflow or action, the repository path and the display name.
triggersWorkflow events, sorted (workflows only).
interface.inputsEvery workflow_call input (or action input) with type, required, default, description and choice options.
interface.dispatchInputsworkflow_dispatch inputs, in the same form (workflows only).
interface.secretsworkflow_call secrets and whether they are required.
interface.outputsworkflow_call (or action) outputs with their description and value expression.
callsReusable workflows this workflow calls, per job: what it passes in with: and secrets: (or inherit), needs, and the matrix shape (combinations, keys, exact).
usesLocal actions used by steps (step is the step id, or #n for the n-th step).
consumersWho 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:

.github/flowpact/contracts/workflows/tests.contract.yml (excerpt)
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: true

The schema is published at /schemas/contract/v1.json.

File names

UnitContract 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 generate

Writes 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:

flowpact generate --dry-run listing new contract files and the unified diff of their contents
First run on a repository without contracts: every file is new.

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 check

Runs 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).

flowpact check reporting an outdated action contract, an unused override, an orphaned contract and a breaking change
An added action input (outdated), a contract for a deleted workflow (orphan), and a removed output (breaking).

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:

ChangeBreaking
Input removedyes
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 oneyes
Choice option removedyes
Secret removed, becomes required, or added as requiredyes
Output removedyes
workflow_call trigger removedyes
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 addedno
Secret becomes optional, optional secret addedno
Output added, or its value or description changedno
Other triggers or the name changedno
What this workflow passes to the workflows and actions it calls (calls, uses)no
Consumers added or removed, or what they pass or readno

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

CodeNameReported when
FP801contract-missingA workflow or local action has no contract.
FP802contract-outdatedThe contract differs from the workflow by non-breaking changes.
FP803breaking-interface-changeA breaking change, one finding per change, with the known consumers as context.
FP804orphan-contractA contract exists for a workflow or action that no longer exists.
FP805contract-invalidA 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.patch writes the patch when contracts drifted; upload it with actions/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.

On this page