flowpactworkflow contracts

Impact mode

For publishers of reusable workflows and actions — check that the release impact a pull request declares covers what its changes require, so consumers never get a breaking change as a patch.

Repositories that publish reusable workflows or actions version them, and consumers pin a floating tag (@v1). Some changes break those consumers without any error in the publishing repository. The classic one is renaming a job: consumers' branch protection requires the check ci / Test, the job now reports ci / Unit tests, and their pull requests wait forever on Expected — Waiting for status to be reported.

Impact mode compares the pull request with its base, grades every change to what consumers can observe as major, minor, patch or none, and fails when the declared impact — usually the Conventional Commits pull request title — is too low.

flowpact impact --base origin/main --title "fix: tidy the test job"
✖  ERROR  FP810 impact-under-declared
  Declared patch (the title "fix: tidy the test job"), but the changes require major: check "… / Test" is now
  "… / Unit tests"; consumers that require the old name wait forever

Impact  declared patch (title "fix: tidy the test job")  ·  required MAJOR  ✖
  major  .github/workflows/ci-reusable.yml  check "… / Test" is now "… / Unit tests"; …
  baseline: origin/main (1a2b3c4)

What is graded

Only published units:

  • every reusable workflow (on: workflow_call), except files whose name starts with _ (.github/workflows/_build.yml), the convention for internal ones;
  • the repository's own action (action.yml at the root — what uses: owner/repo@ref runs);
  • the units listed in impact.publish (for example */action.yml in a repository of several actions). Setting impact.publish replaces the defaults. **/ also matches no directory, so **/action.yml includes the root action.

Workflows that only run the repository's own CI, _-prefixed reusable workflows, and .github/actions/** are not graded: renaming your own CI jobs is not a change your consumers can see. Internal workflows still count where a published one calls them — their check names appear under the caller's.

Change to a published unitRequired impact
A check name a reusable workflow reports is gone: job renamed, job id changed when there is no name, matrix key changed or value removed, ${{ }} added to or removed from name, job or nested call removedmajor
Job id renamed while its check name stays the samenone
A new check name (matrix value added, new job)minor
workflow_call input or secret removed, optional → required, new required input or secret, input type changed, choice option removedmajor
Output removed (callers silently read '')major
Optional input, secret or output addedminor
Input default changedminor
Reusable workflow moved or deleted, or workflow_call removed; action moved or deletedmajor
A published unit no longer parsesmajor
A job requests a new or higher permissions: scope — its own, the workflow-level ones it inherits, or a called workflow's job's (callers that grant less fail when the run starts)major
Action runs.using changed (for example node20 → node24)major
A new third-party action or workflow used inside a published unit (organization allow-lists and SHA-pinning policies apply to consumers)minor
Narrower permissions, descriptions, internal steps, workflow_dispatch inputsnone

Consumers' branch protection is not visible from the publishing repository, so every check name a published reusable workflow reports counts as one a consumer may require. Names are compared the way GitHub forms them — see how check names are formed. A name that reads the reusable workflow's own inputs depends on what each consumer passes, so it is compared by its template: Test ${{ inputs.os }} becoming Tests ${{ inputs.os }} is a rename whatever the consumer passes.

Where the declared impact comes from

The declared impact must be what the release tool will read, so exactly one source is authoritative (impact.declaredBy):

SourceFromMeans
explicit--expect, action input expected-impactas given
title (default)--title, or the pull request title in GitHub Actionstype!: → major, feat → minor, fix / perf → patch, other types → none
labels--labels, or the pull request labels in GitHub Actionssemver:major / semver:minor / semver:patch / semver:none
  • The title describes the squash commit, so it is the right source when pull requests are squash-merged with the title as the commit message. A title that is not a Conventional Commit counts as not declared.
  • Other sources are advisory. A higher advisory declaration (a semver:major label on a fix: title) is an error: the release tool would cut a patch.
  • Labels never stand in for a missing title. With declaredBy: title, a title that is not a Conventional Commit is not declared even when the pull request has a semver:* label.
  • Release pull requests (a release-please title chore(main): release 0.3.0, or the autorelease: pending label) compare everything since the last release tag with the proposed version. release-please's settings for the root package are respected — bump-minor-pre-major (before 1.0, 0.2.0 → 0.3.0 is a major release), bump-patch-for-minor-pre-major, include-v-in-tag and component tags (my-action-v0.2.0), at the top level or under packages["."].
  • Without a declaration, flowpact reports the required impact (FP813) and does not fail.

Over-declaring is never an error — a release may contain changes flowpact does not grade — and a patch change in a pull request that declares none does not fail either.

Findings

CodeSeverityReported when
FP810 impact-under-declarederrorThe changes require more than declared (at least minor).
FP811 impact-declaration-conflicterrorAn advisory source declares more than the authoritative one.
FP812 impact-over-declaredinfoThe declaration is bigger than the changes require.
FP813 impact-undeclaredinfoNothing is declared; the required impact is reported.
FP814 impact-uncertainwarningA change depends on something flowpact cannot evaluate (a check name built from vars or the event, a runtime matrix). Not counted unless impact.uncertain: fail.

The baseline

RunCompared with
--base <ref> / action input base-refthat ref
pull_requestthe base commit of the pull request
pushthe commit before the push (a new branch or tag is skipped)
release pull requestthe last release tag (see above)
anything elseorigin/HEAD, else origin/main, else origin/master

merge_group runs are skipped (the pull request was checked), and so is a run whose baseline is the checked-out commit.

Using it

GitHub Action

on:
  pull_request:
    # edited / labeled: re-check when the title or the labels change
    types: [opened, edited, synchronize, reopened, labeled, unlabeled]

jobs:
  flowpact:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7
      - uses: rumankazi/flowpact@v0.5
        with:
          impact: auto   # off (default) | auto | on

auto turns impact mode on for pull requests in repositories that publish workflows or actions. The action fetches the base commit (and, for release pull requests, the release tags) when the checkout is shallow, with a one-off authorization header — persist-credentials: false works, and contents: read is enough. The job summary gets an Impact section, and the outputs required-impact, declared-impact and impact-ok let later steps react. Use pull_request, not pull_request_target: the latter checks out the default branch, so flowpact refuses it.

CLI

flowpact impact --base origin/main --title "$PR_TITLE"     # only the impact verdict
flowpact check --impact --base origin/main --expect minor  # everything check does, plus impact
OptionDescription
--base <ref>Baseline (default: the pull request base in GitHub Actions, else origin/HEAD). It must be in the clone (git fetch).
--expect <level>Declared impact: none, patch, minor or major.
--title <text>Pull request title to read the declaration from.
--labels <a,b>Pull request labels.

--format json adds an impact section (baseline, required, declared, ok and every graded change) to the report.

Configuration

.github/flowpact/flowpact.config.yml
impact:
  publish: [.github/workflows/reusable-*.yml, '*/action.yml'] # what other repositories use
  declaredBy: title                                            # explicit | title | labels
  labels: { major: breaking, minor: enhancement }              # label names per level
  types: { feat: minor, fix: patch, perf: patch, deps: patch } # Conventional Commits types
  uncertain: warn                                              # warn | fail

The impact settings are read from the baseline (the same --config path, or the default locations), so a pull request cannot relax its own check; changed settings apply once it is merged. When the baseline has no config, or an invalid one, the defaults apply.

How check names are formed

Verified against the check runs GitHub reported for a lab repository with one workflow per case (the workflows and the captured names are flowpact's fixtures/checknames-lab, and a test requires an exact match):

  • A job's check name is its name:, trimmed at both ends, or the job id when it has no name or the name is blank. The workflow name and file name are not part of it.
  • A matrix job gets the suffix (value1, value2) unless its name is dynamic: text around an expression (Lit ${{ 'X' }}) or an expression that reads anything (${{ matrix.os }}) gets no suffix, so such names can collide; a name that is a single literal expression (${{ 'Only' }}) keeps the suffix.
  • The suffix lists the matrix dimensions in declared order; keys an include adds to existing combinations are not shown, while combinations an include creates show all their values. null and '' are skipped, objects and arrays are flattened, and numbers are formatted like .NET (3.10 → 3.1, 1e15 → 1E+15).
  • A job in a called workflow reports caller job / callee job, one more / per nesting level; callee names can read the caller's with: values.
  • A job skipped by its if: (or because a job it needs failed) reports one check with its raw, unexpanded name, and a skipped caller reports no / callee checks at all.

On this page