flowpact

Build on flowpact

Run flowpact under your organization's policies — a copy of the action, a plain run step, your own action or a wrapper — with the interfaces that stay stable, organization defaults and rules, and keeping the version current.

Organizations that cannot use a third-party action, or that want their own defaults, rules and reports, can run flowpact their way without forking it: copy the action, run the CLI in a step, write your own action on the API, or write a composite action. The organization owns the policy (rules, when to fail, where reports go); flowpact stays unmodified, verified, and updated with a version bump.

What is stable

Build on these. A release that changes one in an incompatible way says so under Breaking changes in its release notes, with upgrade notes. Before 1.0, that can happen in a minor release (0.8 → 0.9), never in a patch release.

InterfaceReference
CLI commands, flags and exit codesCLI
JSON report/schemas/report/v1.json; an incompatible change makes it v2, and meta.schemas.report says which a report follows
SARIFSARIF 2.1.0, with stable fingerprints in partialFingerprints["flowpact/v1"]
Annotations--format github: GitHub workflow commands
Config and contracts/schemas/config/v1.json, /schemas/contract/v1.json
Rule codes and namesRules; configs, overrides and --only refer to them
Plugin rulesThe rule object in Custom rules
GitHub Action inputs and outputsGitHub Action
API: the functions, their options, results and errors (import { lint } from 'flowpact')API

Not stable: the terminal output and the wording of messages and Markdown reports, which are written for people (parse JSON or SARIF instead); the types of the data-flow graph a plugin rule reads through ctx.index; the @flowpact/* packages inside the repository; and @ciatlas/core, the workflow model they are built on. None of them is published (use the API).

Copy the action

The flowpact action is a self-contained JavaScript action (node24): it uses no other actions and installs nothing when it runs. Each release has it as an archive, flowpact-action-X.Y.Z.tar.gz: action.yml, the bundle, its license notices and SBOM. Copy it into a repository of your organization, where your action policy allows it:

gh release download vX.Y.Z --repo ciatlas/flowpact --pattern 'flowpact-action-X.Y.Z.tar.gz'
gh attestation verify flowpact-action-X.Y.Z.tar.gz --repo ciatlas/flowpact
tar -xzf flowpact-action-X.Y.Z.tar.gz -C acme-flowpact   # a clone of a new repository

Give the copy a repository of its own: the archive has flowpact's LICENSE at its root, which stays with the copy and would replace a LICENSE already there.

Add your defaults and rules next to it, and make them the defaults of its base-config and plugin inputs (0.10.0 and later): change the default: '' those two inputs already have in action.yml. Relative paths are read from the action's own directory:

acme/flowpact
├── action.yml            # flowpact's, with the two defaults below
├── LICENSE               # flowpact's
├── packages/action/dist/ # the bundle, as released
├── acme.base.yml
└── rules/acme.mjs
action.yml (in your copy)
   base-config:
     description: …
     required: false
-    default: ''
+    default: acme.base.yml
   plugin:
     description: …
     required: false
-    default: ''
+    default: rules/acme.mjs

Repositories then use uses: acme/flowpact@<commit-sha> with every input of the GitHub Action. In a private or internal repository, first allow that under Settings → Actions → General → Access. An empty base-config or plugin still means your defaults, but a workflow can set them to something else, and its plugin replaces your list rather than adding to it (list rules/acme.mjs with the workflow's own plugins). To enforce the policy, require the workflow centrally (below). To update, copy the next archive the same way and keep the two defaults. The action reaches the network only in impact mode (a git fetch of the base commit) and to upload regenerated contracts, which GitHub Enterprise Server does not support (set upload-contracts: false there).

Run the CLI in a step

A run: step is not an action, so no action policy applies to it. The CLI is bundled with everything it needs and installs nothing; it needs only Node.js 22 or later, which GitHub-hosted runners have:

.github/workflows/flowpact.yml
jobs:
  flowpact:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7 # pin to a commit SHA
      - name: flowpact
        env:
          GH_TOKEN: ${{ github.token }}
          FLOWPACT_VERSION: 0.12.3
        run: |
          gh release download "v$FLOWPACT_VERSION" --repo ciatlas/flowpact \
            --pattern "flowpact-$FLOWPACT_VERSION.tgz" --dir "$RUNNER_TEMP"
          gh attestation verify "$RUNNER_TEMP/flowpact-$FLOWPACT_VERSION.tgz" --repo ciatlas/flowpact
          tar -xzf "$RUNNER_TEMP/flowpact-$FLOWPACT_VERSION.tgz" -C "$RUNNER_TEMP"
          node "$RUNNER_TEMP/package/dist/index.js" lint --format github -o markdown:"$GITHUB_STEP_SUMMARY"

The step refuses to run a file whose provenance does not verify: gh attestation verify fails it, with HTTP 404: Not Found for a file that GitHub holds no attestation for. Without network access at run time, verify the tarball once and commit the extracted package/ folder to an internal repository as tools/flowpact/ (its own package.json keeps the code an ES module whatever package.json the repository has); the step is then node tools/flowpact/dist/index.js lint …. Add --base-config and --plugin for your defaults and rules.

On GitHub Enterprise Cloud, an organization ruleset can require such a workflow in every repository, whichever of these ways it uses; the workflow must run on pull_request, pull_request_target or merge_group, and its repository must allow access from the others.

Write your own action

A JavaScript action of your own can run flowpact through its API: lint(), check() and the other commands as functions, which return the report and render the annotations, the job summary and SARIF. It applies your defaults and rules like the CLI does (baseConfig, plugins), with the inputs and reports you choose, and its bundle installs nothing when it runs. Write your own action has a complete one.

A composite action

An internal repository with a composite action that installs a pinned flowpact and runs it with your defaults. It uses actions/setup-node and installs from npm when it runs, so where your policy allows only your organization's actions or blocks network access, copy the action, run the CLI in a step or write your own action instead.

acme/flowpact-action
├── action.yml
├── package.json        # flowpact, pinned to an exact version
├── package-lock.json   # and its integrity hash
├── acme.base.yml       # optional: organization defaults (below)
└── rules/acme.mjs      # optional: organization rules (below)

Create package.json and the lockfile with npm install --save-exact flowpact@0.12. The flowpact package has no dependencies of its own: everything it runs is bundled, with its license notices and an SBOM next to it.

action.yml
name: Acme flowpact
description: flowpact with Acme's rules and reports
inputs:
  command:
    description: lint, or check when the repository commits contracts
    default: lint
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v7 # pin to a commit SHA
      with:
        node-version: 22
    - name: Install flowpact
      shell: bash
      run: npm ci --prefix "$GITHUB_ACTION_PATH" --ignore-scripts
    - name: Run flowpact
      shell: bash
      env:
        COMMAND: ${{ inputs.command }}
        # The repository's own plugins run its code: not on events that run a fork's code with secrets.
        NO_PLUGINS: ${{ contains(fromJSON('["pull_request_target", "workflow_run"]'), github.event_name) && '--no-plugins' || '' }}
      run: >-
        "$GITHUB_ACTION_PATH/node_modules/.bin/flowpact" "$COMMAND"
        --format github
        --base-config "$GITHUB_ACTION_PATH/acme.base.yml"
        --plugin "$GITHUB_ACTION_PATH/rules/acme.mjs"
        $NO_PLUGINS
        -o markdown:"$GITHUB_STEP_SUMMARY"
        -o flowpact.sarif

From one run, this gives:

  • the organization's defaults and rules, under each repository's own config;
  • an annotation on the file and line of each finding, in the run and the pull request (--format github);
  • the Markdown report in the job summary (-o markdown:"$GITHUB_STEP_SUMMARY");
  • flowpact.sarif, for github/codeql-action/upload-sarif in the calling job or in the wrapper;
  • a failed step when there are errors (exit code 1); add --fail-on warning or --fail-on never to change that.

Repositories use it like any action, after actions/checkout. With --root (a repository in a subdirectory of the workspace), the paths in annotations, SARIF and Markdown links stay relative to the repository.

Without access to the npm registry, install the npm package from a release instead: each GitHub release has flowpact-X.Y.Z.tgz with build provenance. Verify it, commit it to the wrapper and depend on the file (npm install --save-exact ./flowpact-X.Y.Z.tgz).

Organization defaults

A base config holds settings for every repository, and each repository's own config goes on top of it. Pass it with the action's base-config input, the CLI's --base-config or the API's baseConfig:

acme.base.yml
rules:
  FP105: error                     # stricter than flowpact's default
  needs-without-data: info         # an opt-in rule, on for everyone
  prod-deploy-needs-approval: error
ignore:
  - .github/workflows/generated/
impact:
  declaredBy: labels               # Acme declares release impact with labels
  • rules, limits and impact settings merge key by key, and the repository's value wins: a repository can still set FP105: warning. ignore and generated lists add up.
  • Settings that name files of one repository (overrides, matrixShapes, repository, impact.publish) and plugins are config errors in a base config.
  • In impact mode, the base config also applies under the baseline's config.

Repositories keep a config that works on its own: locally and in the editor, without the wrapper, flowpact uses the repository's config and its defaults.

Organization rules

Write your rules as plugins and ship them with your copy of the action or your wrapper, such as rules/acme.mjs with the prod-deploy-needs-approval rule from Custom rules:

  • The action's plugin input, the CLI's --plugin <file> and the API's plugins load a plugin in addition to those the repository's config lists (the input reads relative paths from the action's directory, the flag and the API from the working directory; all take several).
  • The repository's own plugins run the repository's code, so the action skips them on untrusted events (plugins: auto), the CLI with --no-plugins and the API with repositoryPlugins: false. Your rules keep running: the action skips a plugin file only when it is in the checkout.
  • The base config sets how strict each rule is, and a repository can change that under rules: or accept a finding with an override, like for built-in rules. Where the rule is not loaded (plain flowpact lint, the editor), those entries are ignored with a warning.
.github/flowpact/flowpact.config.yml (in one repository)
rules:
  FP105: warning                   # this repository wins over the base config
overrides:
  - rule: prod-deploy-needs-approval
    target: .github/workflows/deploy.yml#jobs.hotfix
    reason: Hotfix deploys are approved in the incident channel (INC-142)
    expires: 2026-12-31
    owner: '@acme/sre'

Reading the results

For a wrapper that decides by itself, run with --fail-on never and read the JSON report:

flowpact lint --fail-on never --format json > flowpact.json
jq -r '.findings[] | "\(.severity) \(.code) \(.loc.file):\(.loc.line)"' flowpact.json
jq '.summary.errors' flowpact.json
  • Exit codes: 0 no findings at or above --fail-on, 1 findings, 2 usage or config error, 3 internal error.
  • fingerprint stays the same across unrelated edits, so a finding can be tracked over time or compared with a baseline.
  • symbol names what the finding is about; it is the target of an override.
  • Logs go to stderr and never mix with the report on stdout.

Keeping it current

Renovate updates the wrapper's npm dependency, and pinned action references wherever flowpact's own action is used:

renovate.json
{
  "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
  "packageRules": [
    {
      "matchPackageNames": ["flowpact", "ciatlas/flowpact"],
      "groupName": "flowpact",
      "minimumReleaseAge": "3 days"
    }
  ]
}
  • minimumReleaseAge waits a few days after a release before proposing it.
  • Before 1.0, read the Breaking changes section of a minor update before merging it; patch updates can be merged automatically ("matchUpdateTypes": ["patch"], "automerge": true).
  • npm audit signatures in the wrapper's CI checks that the installed version was published from this repository's release workflow.
  • Dependabot works the same way, with the npm and github-actions ecosystems.
  • Your own action gets the same update of its npm dependency; rebuild its bundle with it.
  • A copy of the action or a CLI run in a step is updated by copying or downloading the next release the same way, verified again; for the step, that is the FLOWPACT_VERSION line.

Why not extends

Tools such as ESLint let a config extend another file. flowpact's base config comes from the command line instead, for two reasons:

  • A repository's config that extends a file in the wrapper, or in a private package, points at something that is not there when a developer runs flowpact lint or opens the editor, so the config would fail everywhere but in CI. Passed by the wrapper, the base config applies where the wrapper runs, and the repository's config still works alone.
  • The organization applies its defaults: a repository cannot leave them out by deleting a line.

Why not a fork

A fork that changes flowpact's code has to be rebuilt, re-verified and merged on every release, and its bundle no longer matches the signed release. A wrapper keeps flowpact unmodified and adds what your organization needs around it. If it needs something flowpact does not offer — a flag, an output, an extension point — open an issue.

On this page