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."