CI/CD Integration

Stop governance issues
before they reach production.

One call in your pipeline. Flowcerta validates the workflow file, scores it against your governance ruleset, preserves the pipeline context that triggered the run, and returns a 422 if blocking findings are present. No custom exit-code logic. No manual review step.

GitHub ActionsPower Platform PipelinesAny curl-capable runnermetadata -> pipeline context in appenforcement_mode: advisory | warning | blocking
Setup

Four steps from zero to gate.

01

Create an API key

Open Team -> API Keys in the app. Create a key with analysis:write scope. Takes 30 seconds.

02

Add it as a CI secret

Store the key as FLOWCERTA_API_KEY in GitHub Actions secrets, Power Automate secure inputs, or your runner secret manager. Never commit raw keys.

03

Add one step to your pipeline

Post the workflow file to /api/v1/validate with enforcement_mode=blocking and attach metadata so repository, branch, run, or deployment context survives into Flowcerta.

04

Review runs in the Validations feed

Every pipeline run lands in the same Validations feed your team already uses, labelled with repository, branch, run, or Power Platform deployment context.

GitHub ActionsMarketplace action

Add the published action

Use flowcerta/validate-action@v1 from the GitHub Actions Marketplace. It uploads your workflow files, preserves repo, branch, PR, and run context automatically, and returns a 422 on blocking findings so the job fails on its own.

- name: Flowcerta governance gate
  uses: flowcerta/validate-action@v1
  with:
    api_key: ${{ secrets.FLOWCERTA_API_KEY }}
    org_id: ${{ secrets.FLOWCERTA_ORG_ID }}
    files: workflows/Main.xaml
    platform: uipath
    enforcement_mode: blocking
  # The action posts your workflow files, preserves repo/branch/PR/run
  # context automatically, and fails the job on critical/high findings.
Raw APIcurl wrapper

Use from any runner

If you already have your own orchestration layer, call the API directly and send the same normalized metadata contract yourself.

curl -X POST "https://api.flowcerta.com/api/v1/validate" \
  -H "Authorization: ApiKey $FLOWCERTA_API_KEY" \
  -F "file=@flows/InvoiceApproval.json" \
  -F "platform=power_automate" \
  -F "enforcement_mode=blocking" \
  -F "source=cicd" \
  -F "label=contoso/rpa-bots / qa / InvoiceApproval.json" \
  -F 'metadata={
    "provider":"github-actions",
    "pipeline":{
      "provider":"github-actions",
      "repository":"contoso/rpa-bots",
      "branch":"main",
      "commit":"a3f8b2c",
      "workflowRunUrl":"https://github.com/contoso/rpa-bots/actions/runs/9001"
    }
  }'
Power PlatformExtension flow

Wrap Power Platform Pipelines

Use a pipeline-triggered cloud flow to call Flowcerta and preserve deployment, solution, and target environment context on the validation result.

Power Platform pipeline extension flow:

1. Trigger: "When a pipeline deployment completes"
2. Compose metadata:

{
  "provider": "power_platform",
  "pipeline": {
    "provider": "power_platform",
    "host": "power-platform-pipelines",
    "pipeline_name": "@{triggerBody()?['pipeline']?['name']}",
    "stage_name": "@{triggerBody()?['stage']?['name']}",
    "deployment_id": "@{triggerBody()?['deployment']?['id']}",
    "solution_name": "@{triggerBody()?['solution']?['uniqueName']}",
    "solution_version": "@{triggerBody()?['solution']?['version']}",
    "environment_name": "@{triggerBody()?['targetEnvironment']?['displayName']}",
    "environment_id": "@{triggerBody()?['targetEnvironment']?['id']}",
    "deployment_url": "@{triggerBody()?['deployment']?['link']}"
  }
}

3. HTTP action:
   POST https://api.flowcerta.com/api/v1/validate
   Authorization: ApiKey <FLOWCERTA_API_KEY>
   form-data:
     file = exported workflow package
     platform = power_automate
     source = cicd
     enforcement_mode = blocking
     label = @{triggerBody()?['pipeline']?['name']} / @{triggerBody()?['stage']?['name']}
     metadata = <Compose output>
Agent loop

The pull request a coding agent opened.

A coding agent can write a UiPath workflow in seconds, and the right place to check it is inside that loop, before the work comes back. CI is the backstop: it catches the agent that skipped the check, and it is where a reviewer finds out which agent has been editing the file in the first place.

Both jobs below use the @flowcerta/cli npm package, which needs no account for the gate itself. Note --baseline in each: it defaults to HEAD, and in a pull-request checkout the working tree is HEAD, so without it every workflow is compared against itself and the job goes green having checked nothing.

GitHub ActionsGate, then comment

Copy to .github/workflows/

Gates every changed workflow against the base branch, then posts a table of which agents have been writing them as a pull-request comment it edits in place. The comment step is deliberately allowed to fail: context must never turn a passing build red.

# Flowcerta on a pull request: gate what changed, then say who has been writing it.
#
# Copy this to .github/workflows/governance.yml. It is a complete, working job - the
# comments explain the three things that are easy to get wrong, each of which produces
# a green build that checked nothing.
#
# The gate needs no account: the built-in rules ship with the binary. Only the
# provenance comment needs FLOWCERTA_API_KEY, and the job still gates without it.
name: Governance

on:
  pull_request:
    paths:
      - "**/*.xaml"

permissions:
  contents: read
  # Only the provenance comment needs this. A pull request from a fork gets read-only
  # permissions and no secrets whatever is written here, so that step is written to
  # skip rather than fail there - the gate itself still runs.
  pull-requests: write

jobs:
  governance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Trap 1. The default checkout is depth 1, so there is no history to compare
          # against and every workflow is judged on its own - which fails a pull request
          # for debt it inherited, on a file it did not touch.
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Which workflows changed
        id: changed
        env:
          BASE: ${{ github.event.pull_request.base.ref }}
        run: |
          set -euo pipefail

          # --diff-filter=d drops deletions. Gating a file that is no longer there is
          # exit 2, "the tool could not answer", and a deleted workflow is not a finding.
          git diff --name-only --diff-filter=d "origin/$BASE...HEAD" -- '*.xaml' > changed.txt

          if [ -s changed.txt ]; then
            echo "any=true" >> "$GITHUB_OUTPUT"
            echo "Gating against origin/$BASE:"; cat changed.txt
          else
            echo "any=false" >> "$GITHUB_OUTPUT"
            echo "No workflows changed."
          fi

      - name: Gate the changed workflows
        if: steps.changed.outputs.any == 'true'
        env:
          BASE: ${{ github.event.pull_request.base.ref }}
          # Optional. With it the gate also enforces your organisation's own rules and
          # policy packs; without it, the built-in ones.
          FLOWCERTA_API_KEY: ${{ secrets.FLOWCERTA_API_KEY }}
        run: |
          set -uo pipefail
          violations=0
          unanswered=0

          while IFS= read -r file; do
            [ -n "$file" ] || continue
            echo "::group::$file"

            # Trap 2, and the reason this flag is spelled out. --baseline defaults to HEAD,
            # and in a pull-request checkout the working tree IS HEAD - so the obvious
            # `flowcerta gate "$file"` compares the file against itself, finds no new
            # findings, and exits 0 on a workflow that hard-codes a password. The CLI says
            # "Gate judged nothing" when that happens, but the job would still be green.
            #
            # --no-report because CI is not the author. The agent's own gate run is what
            # should be counted; replaying it here would add an unattributed verdict per
            # push and make the table in the next step less informative, not more.
            npx -y @flowcerta/cli gate "$file" --baseline "origin/$BASE" --no-report
            code=$?
            echo "::endgroup::"

            # Trap 3. 1 and 2 are different answers and collapsing them loses the one that
            # matters: 1 is "this automation broke a rule", 2 is "the check could not run".
            # Both fail the job - a check that could not answer must never read as a pass -
            # but they are reported apart so nobody goes looking for a policy problem that
            # is really a malformed file.
            case "$code" in
              0) ;;
              1) violations=1; echo "::error file=$file::Governance policy violation" ;;
              *) unanswered=1; echo "::error file=$file::Flowcerta could not check this file" ;;
            esac
          done < changed.txt

          [ "$unanswered" -eq 0 ] || { echo "Some workflows could not be checked."; exit 1; }
          [ "$violations" -eq 0 ] || { echo "Governance policy violated."; exit 1; }
          echo "All changed workflows pass."

      - name: Who has been writing these workflows
        # always(), because a reviewer looking at a blocked pull request is exactly who
        # needs to know which agent has been editing the file. continue-on-error, because
        # this is context, not a gate: it must never turn a passing build red.
        if: always() && steps.changed.outputs.any == 'true'
        continue-on-error: true
        env:
          FLOWCERTA_API_KEY: ${{ secrets.FLOWCERTA_API_KEY }}
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          set -uo pipefail
          if [ -z "${FLOWCERTA_API_KEY:-}" ]; then
            echo "No FLOWCERTA_API_KEY (or a pull request from a fork). Skipping."
            exit 0
          fi

          # Only the file names are sent, and the markdown goes to stdout: the CLI has no
          # business knowing which forge you use, so the posting is yours to choose.
          tr '\n' '\0' < changed.txt \
            | xargs -0 npx -y @flowcerta/cli provenance --markdown > provenance.md

          # One comment that gets edited, rather than a new one per push. --edit-last fails
          # when there is nothing of ours to edit yet, which is what the fallback is for.
          gh pr comment "$PR" --edit-last --body-file provenance.md \
            || gh pr comment "$PR" --body-file provenance.md
GitLab CIMerge requests

The same two steps, anywhere

Nothing in the CLI is GitHub-specific. gate compares against a git ref and provenance --markdown writes a table to stdout, so the posting is yours to pipe wherever reviews happen.

# The same two steps on GitLab, for merge requests.
#
# Nothing about the CLI is GitHub-specific: `gate` compares against a git ref and
# `provenance --markdown` writes a table to stdout. Only the posting differs, which is
# why the CLI does not do it.
#
# Include this in .gitlab-ci.yml. FLOWCERTA_API_KEY is optional for the gate and
# required for the note; GITLAB_TOKEN needs api scope to post one.
governance:
  image: node:20
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - "**/*.xaml"
  variables:
    # The gate compares a workflow against its committed version, so the history has to
    # be there. GitLab's default is a shallow clone, and with depth 1 there is nothing to
    # compare against: every file is judged on its own and a merge request fails for debt
    # it inherited.
    GIT_DEPTH: 0
  script:
    - apt-get update -qq && apt-get install -y -qq git curl jq
    - git fetch --no-tags origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - BASE="origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - git diff --name-only --diff-filter=d "$BASE...HEAD" -- '*.xaml' > changed.txt
    - test -s changed.txt || { echo "No workflows changed."; exit 0; }
    # --baseline is the whole point: it defaults to HEAD, and in a merge-request checkout
    # the working tree is HEAD, so without it every file is compared against itself and
    # the job goes green having checked nothing.
    # set +e, because the runner runs this script with set -e and a violation is a non-zero
    # exit: without it the job dies on the first finding, before the loop has seen the rest
    # of the changed files and before the provenance note goes up.
    - |
      set +e
      violations=0
      unanswered=0
      while IFS= read -r file; do
        [ -n "$file" ] || continue
        npx -y @flowcerta/cli gate "$file" --baseline "$BASE" --no-report
        case "$?" in
          0) ;;
          1) violations=1 ;;
          *) unanswered=1 ;;
        esac
      done < changed.txt
      set -e
    # Posted before the verdict is raised, so a blocked merge request still gets the
    # context, and never allowed to change it.
    - |
      if [ -n "${FLOWCERTA_API_KEY:-}" ] && [ -n "${GITLAB_TOKEN:-}" ]; then
        tr '\n' '\0' < changed.txt \
          | xargs -0 npx -y @flowcerta/cli provenance --markdown > provenance.md || true
        curl --silent --show-error --request POST \
          --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
          --form "body=<provenance.md" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
          > /dev/null || echo "Could not post the provenance note; carrying on."
      fi
    # 1 is a policy violation, 2 is the check not being able to run. Both fail, because a
    # check that could not answer must not read as a pass, and they are kept apart so
    # nobody hunts for a policy problem that is really a malformed file.
    - |
      [ "$unanswered" -eq 0 ] || { echo "Some workflows could not be checked."; exit 1; }
      [ "$violations" -eq 0 ] || { echo "Governance policy violated."; exit 1; }
      echo "All changed workflows pass."
Enforcement

Start observing. Enforce when ready.

Set enforcement_mode on each request. Switch from advisory to blocking without changing any other pipeline config.

advisoryHTTP 200

Always passes

Findings are returned but the pipeline is never blocked. Use this to baseline your estate before enforcing.

warningHTTP 200

Soft gate

Returns 200 even on high-severity findings. Full findings in the response body. Visibility without breaking CI.

Rule coverage

What gets caught at the gate.

The default ruleset runs on every validation. Critical and high findings block the pipeline in blocking mode.

criticalCRED-001Hardcoded connection stringsPlaybook ->
criticalCRED-002Hardcoded API keys and secretsPlaybook ->
highERR-001External calls without Try/CatchPlaybook ->
highPII-001PII access without audit loggingPlaybook ->
highPII-002PII written to log messagesPlaybook ->
mediumANL-007Missing log messagesPlaybook ->

Browse all 73 rules ->

Add the gate. Ship with confidence.

Create a free API key, test against one workflow file, then drop the step or flow into your pipeline. The whole setup takes under 10 minutes.