Skip to content

ci: make path-filtered required gates always report (unblock stuck PR… #77

ci: make path-filtered required gates always report (unblock stuck PR…

ci: make path-filtered required gates always report (unblock stuck PR… #77

Workflow file for this run

# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# ABI Drift Gate (standards#92 Phase 2)
#
# For every cartridge that has a paired `abi/.../Safe*.idr` (Idris2 ABI
# source of truth) and `ffi/*_ffi.zig` (Zig FFI mirror), this workflow:
# 1. emits the ABI manifest from the Idris2 source (`iseriser abi-emit-manifest`)
# 2. verifies the Zig FFI against the emitted manifest (`iseriser abi-verify`)
# Fails the PR check on any structural drift in: enum encoding,
# transition table, or accept-by-omission. See iseriser/README and
# `iseriser/examples/abi-manifests/README.adoc` for the drift taxonomy.
#
# Triggers only when an ABI- or FFI-bearing file under cartridges/
# changes, so unrelated PRs do not pay for it.
name: ABI Drift Gate
# Was workflow-level path-filtered: a required check that never ran on PRs
# touching none of its paths, leaving the check "Expected" and the PR blocked.
# Now always runs; the `changes` job gates the heavy `verify` job, which when
# skipped reports SUCCESS to required checks. (verify also does its own
# per-cartridge scoping for the relevant case.)
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: abi-drift-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: detect
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.base_ref }}
BEFORE: ${{ github.event.before }}
run: |
set -uo pipefail
run=true
if [ "$EVENT" = pull_request ]; then
git fetch --no-tags --depth=200 origin "$BASE" 2>/dev/null \
&& changed=$(git diff --name-only "origin/${BASE}...HEAD" 2>/dev/null) \
&& { printf '%s\n' "$changed" | grep -qE '^cartridges/.*/abi/|^cartridges/.*/ffi/' && run=true || run=false; }
elif [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
changed=$(git diff --name-only "${BEFORE}...${GITHUB_SHA}" 2>/dev/null) \
&& { printf '%s\n' "$changed" | grep -qE '^cartridges/.*/abi/|^cartridges/.*/ffi/' && run=true || run=false; }
fi
printf 'run=%s\n' "$run" >> "$GITHUB_OUTPUT"
echo "relevant=$run; changed files:"; printf '%s\n' "${changed:-<none computed>}"
verify:
name: Emit manifest + verify FFI
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need at least two commits so `git diff origin/<base>...HEAD`
# can compute the changed-cartridge set on pull_request events.
# `0` = full history (cheap on this repo).
fetch-depth: 0
- name: Install Rust toolchain (stable)
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
# Pin to a specific iseriser commit so the cache key changes when
# we bump it. Floating `--branch main` + a prefix-matching
# `restore-keys` (`iseriser-${{ runner.os }}-`) was sticky: once a
# binary was cached for a given OS, the install step's
# `command -v iseriser` short-circuit skipped re-build, and #111's
# GADT-skip fix (iseriser#20, merged 2026-05-20) never reached CI.
# Bumping ISERISER_REV invalidates the cache and forces a rebuild.
- name: Cache cargo bin (iseriser install)
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
env:
ISERISER_REV: 741a3b63e7619b6e9cfe7f91b04d7ccfb130b1ca
with:
path: |
~/.cargo/bin/iseriser
~/.cargo/registry
~/.cargo/git
key: iseriser-${{ runner.os }}-${{ env.ISERISER_REV }}
- name: Install iseriser
env:
ISERISER_REV: 741a3b63e7619b6e9cfe7f91b04d7ccfb130b1ca
run: |
set -euo pipefail
cargo install --git https://github.com/hyperpolymath/iseriser \
--rev "$ISERISER_REV" --locked --force
iseriser --version
- name: Resolve cartridge allowlist
id: discover
# 80 cartridges in the tree carry a paired Safe*.idr + *_ffi.zig.
# 66 are audited green against the Phase 1b emitter + Phase 1
# verifier (re-run 2026-05-20 after iseriser#20 / #21 / #22 merged:
# GADT-skip in the emitter, runtogether candidate for multi-cap
# acronyms, and terminal `false` switch-arm tolerance in the
# verifier). Versus the previous 56-cartridge allowlist this:
# * ADDS 10 cartridges that the three iseriser fixes unblocked
# (cloud, comms, container, git, gitlab-api, ml,
# mongodb, queues, research, vordr)
# * RESTORES browser-mcp — the Class D `BrowserAction.Type` ↔
# Zig `type_text` drift was fixed in cartridge-side PR #134
# (`Type` renamed to `TypeText` in the Idris2 ADT to match
# the underlying Zig FFI tag).
# 14 cartridges carry real drift across Classes B/C/D — see
# standards#92 plus open sub-issues standards#150-155 (Class C)
# and standards#156 (Class D). Class P (verifier parser limit)
# and Class E (malformed Idris2 source) are now empty: iseriser#22
# closed Class P (5 cartridges) and the vordr-mcp Class E case
# was closed via iseriser#20 + the cartridge's clean re-survey.
# chapeliser-mcp was over-listed in #133 — the cartridge dir does
# not exist in the tree; the loop's `ls cartridges/<n>/abi/*/...`
# fails with exit code 2 and the whole job goes red.
run: |
set -euo pipefail
{
echo 'carts<<EOF'
echo '007-mcp'
echo 'affinescript-mcp'
echo 'agent-mcp'
echo 'airtable-mcp'
echo 'arango-mcp'
echo 'browser-mcp'
echo 'buildkite-mcp'
echo 'clickhouse-mcp'
echo 'cloud-mcp'
echo 'cloudflare-mcp'
echo 'comms-mcp'
echo 'container-mcp'
echo 'crates-mcp'
echo 'discord-mcp'
echo 'dns-shield-mcp'
echo 'docker-hub-mcp'
echo 'duckdb-mcp'
echo 'feedback-mcp'
echo 'fleet-mcp'
echo 'fly-mcp'
echo 'git-mcp'
echo 'github-actions-mcp'
echo 'github-api-mcp'
echo 'gitlab-api-mcp'
echo 'google-docs-mcp'
echo 'google-sheets-mcp'
echo 'grafana-mcp'
echo 'hackage-mcp'
echo 'hex-mcp'
echo 'iac-mcp'
echo 'jira-mcp'
echo 'k8s-mcp'
echo 'k9iser-mcp'
echo 'linear-mcp'
echo 'local-coord-mcp'
echo 'matrix-mcp'
echo 'ml-mcp'
echo 'mongodb-mcp'
echo 'neo4j-mcp'
echo 'neon-mcp'
echo 'nesy-mcp'
echo 'notion-mcp'
echo 'npm-registry-mcp'
echo 'observe-mcp'
echo 'obsidian-mcp'
echo 'opam-mcp'
echo 'opsm-mcp'
echo 'pmpl-mcp'
echo 'prometheus-mcp'
echo 'proof-mcp'
echo 'pypi-mcp'
echo 'queues-mcp'
echo 'railway-mcp'
echo 'render-mcp'
echo 'research-mcp'
echo 'rokur-mcp'
echo 'secrets-mcp'
echo 'sentry-mcp'
echo 'slack-mcp'
echo 'ssg-mcp'
echo 'supabase-mcp'
echo 'telegram-mcp'
echo 'todoist-mcp'
echo 'turso-mcp'
echo 'vordr-mcp'
echo 'zotero-mcp'
echo 'EOF'
} >> "$GITHUB_OUTPUT"
# On pull_request, restrict the verify loop to cartridges that
# actually changed in this PR — unrelated cartridge drift is the
# main branch's problem, not this PR's. On push to main, scan the
# full allowlist so cross-cutting regressions are still caught at
# trunk. Empty changed-set on a PR ⇒ skip the loop (handled in
# the verify step's defensive empty check below).
#
# Single output, always-correct: scope.outputs.carts is the
# PR-filtered allowlist on pull_request, the full allowlist on
# push. Avoids the `${{ X && Y || Z }}` ternary footgun where Y
# being an empty string short-circuits to Z (which would silently
# re-expand to the full sweep — exactly the bug we're trying to
# fix).
- name: Scope cartridges to verify
id: scope
env:
EVENT: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
CARTS: ${{ steps.discover.outputs.carts }}
run: |
set -euo pipefail
if [ "$EVENT" = "pull_request" ]; then
git fetch --no-tags --depth=50 origin "$BASE_REF" || true
changed=$(git diff --name-only "origin/${BASE_REF}...HEAD" -- 'cartridges/**' \
| awk -F/ '{print $2}' | sort -u)
scope=""
while IFS= read -r cart; do
[ -z "$cart" ] && continue
if printf '%s\n' "$changed" | grep -qx "$cart"; then
scope="${scope}${cart}"$'\n'
fi
done <<< "$CARTS"
else
scope="$CARTS"
fi
{
echo 'carts<<EOF'
printf '%s' "$scope"
echo
echo 'EOF'
} >> "$GITHUB_OUTPUT"
echo "Cartridges in scope for this run:"
printf ' • %s\n' $(printf '%s\n' "$scope" | grep -v '^$' || true)
- name: Emit + verify each cartridge
env:
CARTS: ${{ steps.scope.outputs.carts }}
run: |
set -euo pipefail
if [ -z "$(printf '%s' "$CARTS" | tr -d '[:space:]')" ]; then
echo "::notice::No allowlisted cartridges changed in this PR — skipping drift verify."
exit 0
fi
failed=""
while IFS= read -r cart; do
[ -z "$cart" ] && continue
echo "::group::abi-drift: $cart"
idris_src="$(ls cartridges/${cart}/abi/*/Safe*.idr | head -n1)"
zig_ffi="$(ls cartridges/${cart}/ffi/*_ffi.zig | head -n1)"
manifest="$(mktemp --suffix=.json)"
if iseriser abi-emit-manifest \
--idris "$idris_src" \
--cartridge "$cart" \
--source-path "$idris_src" \
--out "$manifest"; then
if iseriser abi-verify --manifest "$manifest" --zig-ffi "$zig_ffi"; then
echo " ✓ $cart"
else
echo " ✗ $cart DRIFT"
failed="$failed $cart"
fi
else
echo " ✗ $cart EMIT FAILED"
failed="$failed $cart"
fi
rm -f "$manifest"
echo "::endgroup::"
done <<< "$CARTS"
if [ -n "$failed" ]; then
echo "::error::ABI drift detected in:$failed"
exit 1
fi