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.ymlat the root — whatuses: owner/repo@refruns); - the units listed in
impact.publish(for example*/action.ymlin a repository of several actions). Settingimpact.publishreplaces the defaults.**/also matches no directory, so**/action.ymlincludes 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 unit | Required 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 removed | major |
| Job id renamed while its check name stays the same | none |
| 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 removed | major |
Output removed (callers silently read '') | major |
| Optional input, secret or output added | minor |
| Input default changed | minor |
Reusable workflow moved or deleted, or workflow_call removed; action moved or deleted | major |
| A published unit no longer parses | major |
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 inputs | none |
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):
| Source | From | Means |
|---|---|---|
explicit | --expect, action input expected-impact | as given |
title (default) | --title, or the pull request title in GitHub Actions | type!: → major, feat → minor, fix / perf → patch, other types → none |
labels | --labels, or the pull request labels in GitHub Actions | semver: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:majorlabel on afix: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 asemver:*label. - Release pull requests (a release-please title
chore(main): release 0.3.0, or theautorelease: pendinglabel) 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.0is a major release),bump-patch-for-minor-pre-major,include-v-in-tagand component tags (my-action-v0.2.0), at the top level or underpackages["."]. - 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
| Code | Severity | Reported when |
|---|---|---|
FP810 impact-under-declared | error | The changes require more than declared (at least minor). |
FP811 impact-declaration-conflict | error | An advisory source declares more than the authoritative one. |
FP812 impact-over-declared | info | The declaration is bigger than the changes require. |
FP813 impact-undeclared | info | Nothing is declared; the required impact is reported. |
FP814 impact-uncertain | warning | A 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
| Run | Compared with |
|---|---|
--base <ref> / action input base-ref | that ref |
pull_request | the base commit of the pull request |
push | the commit before the push (a new branch or tag is skipped) |
| release pull request | the last release tag (see above) |
| anything else | origin/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 | onauto 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| Option | Description |
|---|---|
--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
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 | failThe 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
includeadds to existing combinations are not shown, while combinations anincludecreates show all their values.nulland''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'swith: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/ calleechecks at all.