Skip to content

Operationalize anti-ceremony guardrails #4598

Operationalize anti-ceremony guardrails

Operationalize anti-ceremony guardrails #4598

Workflow file for this run

name: Validate
# First-principles rebuild (ag-877 / ag-5ck, 2026-05-28): 67 fine-grained jobs
# collapsed to ~10 purpose-grouped jobs. The 59 scripts/{check,validate}-* are
# the safety logic and are preserved verbatim; only the GitHub-job orchestration
# was re-shaped. Each purpose job runs its family of scripts as path-gated steps
# (per-step `if: needs.changes.outputs.X`), with advisory checks marked
# `continue-on-error: true` so they report without blocking. Design:
# spec/teardown-2026-05-28/gate-rebuild-first-principles.md
#
# Coverage invariant (the no-strangler safety net): every script invoked by the
# previous validate.yml is still invoked here. Proof:
# comm -23 \
# <(git show main:.github/workflows/validate.yml | grep -oE 'scripts/[a-z0-9./_-]+\.(sh|py)' | sort -u) \
# <(grep -oE 'scripts/[a-z0-9./_-]+\.(sh|py)' .github/workflows/validate.yml | sort -u)
# MUST be empty.
on:
# This repository chooses tag, manual, pull-request, and merge-group runs.
# AgentOps itself does not assign delivery authority to this workflow.
push:
tags:
- 'v*'
workflow_dispatch:
pull_request:
branches: [main]
# Merge queue: GitHub validates queued PRs on the combined merge_group ref.
# The `summary` required check MUST run here or the queue hangs forever. The
# `changes` job forces a full run on merge_group (see the release step) so the
# path-filter — which has no PR base in-queue — can't skip validation.
merge_group:
# Cancel superseded PR runs so update-branch / force-push churn during a merge
# train doesn't leave stale runs eating the (20-slot, free-plan) concurrency pool
# — the self-DoS that produced a 19-deep queue on 2026-06-06. NEVER cancels main
# or tag pushes (cancel-in-progress is false unless this is a pull_request).
concurrency:
group: validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
runs-on: ubuntu-latest
outputs:
go: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.go }}
skills: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.skills }}
hooks: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.hooks }}
docs: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.docs }}
eval: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.eval }}
codex: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.codex }}
shell: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.shell }}
bats: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.bats }}
ci: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.ci }}
contracts: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.contracts }}
goals: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.goals }}
learning: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.learning }}
markdown: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.markdown }}
corpus: ${{ steps.release.outputs.release == 'true' || steps.filter.outputs.corpus }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Detect release tag push or merge-queue (force full run)
id: release
shell: bash
run: |
# merge_group has no PR base for the path-filter, so force the full
# validation suite — the queue must validate the combined ref in full.
if [[ "${GITHUB_REF}" == refs/tags/v* || "${GITHUB_EVENT_NAME}" == "merge_group" ]]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
fi
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
if: steps.release.outputs.release != 'true'
id: filter
with:
filters: |
go:
- 'cli/**'
- 'go.mod'
- 'go.sum'
- 'tests/windows/**'
skills:
- 'skills/**'
- 'skills-codex/**'
- 'skills-codex-overrides/**'
- 'tests/skills/**'
hooks:
- 'lib/**'
- 'cli/embedded/**'
docs:
- 'docs/**'
- 'README.md'
- 'CHANGELOG.md'
- 'PRODUCT.md'
- 'SKILL-TIERS.md'
eval:
- 'evals/**'
- 'scripts/check-applied-ood-headroom.sh'
- 'cli/internal/eval/**'
- 'cli/cmd/ao/eval*'
- 'schemas/eval-*'
codex:
- 'skills-codex/**'
- 'skills-codex-overrides/**'
shell:
- '**/*.sh'
- 'scripts/**'
bats:
- '**/*.bats'
ci:
- '.github/**'
contracts:
- 'schemas/**'
- 'docs/contracts/**'
# Runtime workflow changes are contract-shaped inputs.
- '.claude/workflows/**'
# redteam-pack target globs (ag-nl1u): every file the
# security redteam pack
# (skills/security/references/agentops-redteam-pack.json)
# asserts behavioral contracts against MUST re-run the
# contracts-sync canaries when edited — otherwise a break to a
# guarded file lands without the canary that guards it (the #634
# regression). The guard test
# tests/scripts/test-pathfilter-gate-coverage.sh asserts this
# list stays a superset of the pack's target globs.
- 'AGENTS.md'
- 'docs/ARCHITECTURE.md'
- 'docs/CI-CD.md'
- 'docs/strategic-direction.md'
- 'docs/standards/shell-script-standards.md'
- 'skills/security/**'
# AGENTS detail owners (post sibling cutover): scripts/validate-agents-split.sh
# validates AGENTS.md AND these four owner docs (existence, bidirectional
# links). The gate triggers on contracts (added with this list), so an
# owner-only edit re-runs the split gate that guards it — without these
# globs a docs/agent-workflow-reference.md-only edit would skip its own
# gate (the #634 class). The guard test
# tests/scripts/test-pathfilter-gate-coverage.bats asserts every owner
# path the split script reads is covered by the contracts filter.
- 'docs/agent-workflow-reference.md'
- 'docs/CI-CD.md'
- 'docs/contracts/codex-skill-api.md'
goals:
# correctness/doctrine gates that police GOALS.md + the executable
# spec scenarios (ag-n4m7): a GOALS.md-only or scenario-only edit
# must trigger the directive↔scenario link gates, which previously
# ran only on go/docs/ci and silently SKIPPED on these paths (the
# #591/#593 phantom-scenario regression). docs/** already covers
# the ADR; GOALS.md + spec/scenarios/** are added explicitly here.
- 'GOALS.md'
- 'spec/scenarios/**'
- 'docs/adr/ADR-0003*'
learning:
- '.agents/learnings/**'
markdown:
- '**/*.md'
corpus:
- '.agents/**/*.md'
- '.agents/**/*.jsonl'
# ─────────────────────────────────────────────────────────────────────────
# go-gate-shadow — PB3 migration authority lane.
#
# Runs the single Go gate entrypoint in CI and emits per-check GitHub
# annotations + JSON evidence. The job is required and enforces workflow
# parity for non-deferred blocking scripts, while the legacy purpose jobs
# remain blocking until the final dual-orchestration collapse.
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────
# go-gate-shadow — PB3 migration authority lane.
#
# Runs the single Go gate entrypoint in CI and emits per-check GitHub
# annotations + JSON evidence. The job is required and enforces workflow
# parity for non-deferred blocking scripts, while the legacy purpose jobs
# remain blocking until the final dual-orchestration collapse.
# ─────────────────────────────────────────────────────────────────────────
go-gate-shadow:
needs: [changes]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history keeps changed-scope and repository checks reproducible
# for tag, PR, and manual runs.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.5'
cache-dependency-path: cli/go.sum
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
- name: Install gate dependencies
run: |
sudo apt-get update
# bats + ripgrep: the full gate's check-cli-contract.sh shells out to
# both (bats runs tests/cli_contract_gate.bats, whose tests `run rg`
# and assert exit 1 — an absent rg exits 127 and fails them).
sudo apt-get install -y jq shellcheck bats ripgrep
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
pip install jsonschema pyyaml
GOBIN=/usr/local/bin go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0
- name: Build ao
run: |
mkdir -p bin
(cd cli && go build -o ../bin/ao ./cmd/ao)
- name: Run Go gate shadow
id: go_gate
shell: bash
run: |
set +e
WORKTREE_DISPOSITION_CI_SKIP=1 \
./bin/ao gate check --full --json --github-annotations --workflow-coverage --require-workflow-parity \
> ao-gate-report.json
rc=$?
jq '.run.summary' ao-gate-report.json
jq '.coverage | {workflow_script_count, registry_script_count, missing_script_count, missing_blocking_count, missing_advisory_count, missing_deferred_count, registry_only_script_count, missing_blocking_scripts, missing_advisory_scripts, missing_deferred_scripts, registry_only_scripts}' ao-gate-report.json
echo "exit_code=$rc" >> "$GITHUB_OUTPUT"
exit "$rc"
- name: Upload Go gate report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ao-gate-shadow-report
path: ao-gate-report.json
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────
# skill-gates — REQUIRED. The consolidated skill-authoring gate surface:
# every check that guards a SKILL.md / .feature / derived-skill-surface edit,
# grouped into one named job (ag-87sv). Pure regroup of already-live gates —
# no new gate logic. Absorbs from skills-integrity (heal --strict,
# validate-skill-schema, validate-skill-frontmatter, validate-skill-body-refs)
# + contracts-sync + doctrine-proof
# (check-scenario-test-linkage), and adds the six-surface drift sweep
# `regen-all.sh --check`. Required: listed in `summary.needs`.
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────
# correctness — go build/test/coverage/complexity, embedded sync, CLI
# integration, JSON-flag consistency, bats, python smoke, native Windows
# smoke, and the advisory `ao doctor` dead-reference check. Windows folds in
# as a matrix leg (its smoke runs `tests/windows/*.ps1`, no scripts/ family).
# Absorbs: go-build, cli-integration, json-flag-consistency, bats-tests,
# smoke-test, windows-smoke, doctor-check.
# ─────────────────────────────────────────────────────────────────────────
correctness:
needs: [changes]
if: >-
needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' ||
needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' ||
needs.changes.outputs.bats == 'true' || needs.changes.outputs.skills == 'true' ||
needs.changes.outputs.docs == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history keeps range-sensitive tests reproducible.
fetch-depth: 0
# ── bats runs FIRST, on the pristine checkout, before the Go build/test
# steps below. The Go test phase shares this working tree and a cli/ test
# exercises git plumbing that can leave the shared repo HEAD moved
# (observed: detached at origin/main), which would make the workflow
# self-tests in the bats suite read stale validate.yml. Running bats up
# front — exactly the pristine-checkout + npm-bats setup the standalone
# bats-tests job used pre-rebuild — keeps it pollution-free (ag-877).
#
# bats needs only the runner's Python, jq, and ripgrep. It does not need
# a semantic-review runtime or a pre-seeded lifecycle verdict.
- name: Install bats
if: runner.os == 'Linux' && (needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.bats == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.skills == 'true' || needs.changes.outputs.docs == 'true')
run: sudo npm install -g bats@1.12.0
- name: Run bats tests
if: runner.os == 'Linux' && (needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.bats == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.skills == 'true' || needs.changes.outputs.docs == 'true')
run: |
echo "=== Running bats tests (4-way parallel) ==="
# bats --jobs needs GNU parallel; the serial run was ~237s (55% of the
# correctness critical path). --no-parallelize-within-files preserves
# ordering inside each file; only independent files run concurrently.
# ripgrep is NOT preinstalled on the runner image: a few gate scripts
# (check-orchestration-skill-boundaries.sh, skill-standards-convergence,
# the cli_contract nested gate) shell out to `rg` and otherwise 127.
sudo apt-get install -y parallel ripgrep >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y parallel ripgrep; }
bats --jobs 4 --no-parallelize-within-files --print-output-on-failure tests/scripts/*.bats
echo "✅ Bats tests passed"
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.5'
cache-dependency-path: cli/go.sum
- name: Set up Python (smoke-test)
if: runner.os == 'Linux' && (needs.changes.outputs.skills == 'true' || needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true')
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
- name: Install PyYAML for bats python helpers
# setup-python@v6 provisions a clean interpreter WITHOUT the runner's
# preinstalled PyYAML. Several bats suites run inside this job invoke
# `python3 ... import yaml` (audit-skill-metadata, generate-ci-jobs-table),
# so reinstall it whenever we've shadowed the system python.
if: runner.os == 'Linux' && (needs.changes.outputs.skills == 'true' || needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true')
run: python3 -m pip install pyyaml
# ── go-build (Linux) ──────────────────────────────────────────────────
- name: Build ao CLI
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
echo "=== Building ao CLI ==="
cd cli
go build -o /tmp/ao-test ./cmd/ao
echo "✅ ao CLI builds successfully"
- name: Run Go tests with race detection and coverage
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
echo "=== Running Go tests ==="
cd cli
set -o pipefail
go test -race -shuffle=on -coverprofile=coverage.out -covermode=atomic ./... -v 2>&1 | tee /tmp/go-test-output.txt
echo ""
echo "=== Coverage Summary ==="
go tool cover -func=coverage.out | tail -1
echo "✅ Go tests passed"
- name: Enforce cmd/ao coverage floor
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
echo "=== Enforcing cli/cmd/ao coverage floor ==="
# Ratchet target: 85% (source epic evolve-cycle-6-coverage-85pct).
# Current floor is set in scripts/check-cmd-ao-coverage.sh; bump it
# one whole percent each time real coverage rises.
bash scripts/check-cmd-ao-coverage.sh --profile cli/coverage.out
- name: Warn about slow test packages
if: always() && runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
echo "=== Test Package Duration Analysis ==="
THRESHOLD=45
FOUND_SLOW=0
if [[ ! -f /tmp/go-test-output.txt ]]; then
echo "No test output file found; skipping."
exit 0
fi
while IFS= read -r line; do
if [[ "$line" =~ ^(ok|FAIL)[[:space:]]+([^[:space:]]+)[[:space:]]+([0-9]+(\.[0-9]+)?)s ]]; then
pkg="${BASH_REMATCH[2]}"
elapsed="${BASH_REMATCH[3]}"
is_slow=$(awk -v e="$elapsed" -v t="$THRESHOLD" 'BEGIN { print (e > t) ? "1" : "0" }')
if [[ "$is_slow" == "1" ]]; then
echo "::warning::Slow test package ${pkg}: ${elapsed}s exceeds ${THRESHOLD}s threshold"
FOUND_SLOW=$((FOUND_SLOW + 1))
fi
fi
done < /tmp/go-test-output.txt
if [[ "$FOUND_SLOW" -gt 0 ]]; then
echo "⚠️ Found $FOUND_SLOW slow package(s) exceeding ${THRESHOLD}s threshold"
else
echo "✅ All test packages completed within ${THRESHOLD}s"
fi
- name: Enforce Go complexity budget on changed files
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF_NAME: ${{ github.base_ref }}
run: |
echo "=== Enforcing Go complexity budget ==="
GOBIN=/usr/local/bin go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0
if [[ "$EVENT_NAME" == "pull_request" ]]; then
git fetch --no-tags --depth=1 origin "$BASE_REF_NAME"
BASE_REF="origin/$BASE_REF_NAME"
else
BASE_REF="HEAD~1"
fi
./scripts/check-go-complexity.sh --base "$BASE_REF" --warn 15 --fail 25
# Swallowed-json guard (agentops-tqc.3 / planning-rule f-2026-04-29-002).
# Blocking. golangci-lint's errcheck does NOT catch the blank-discard
# swallow (`b, _ := json.Marshal(...)`) because check-blank is off, and
# turning it on globally would explode unrelated discards — so this
# dedicated step runs errcheck -blank filtered to conventional `json.`
# selector call sites (textual, not alias-resolving).
- name: Enforce checked json.Marshal/Unmarshal returns
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
echo "=== Enforcing checked json.Marshal/Unmarshal returns ==="
GOBIN=/usr/local/bin go install github.com/kisielk/errcheck@v1.20.0
bash scripts/check-json-marshal-checked.sh
- name: Upload coverage to Codecov
if: runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
with:
files: ./cli/coverage.out
flags: cli
fail_ci_if_error: false
- name: Upload coverage artifact
if: always() && runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: go-coverage
path: cli/coverage.out
retention-days: 7
# ── cli-integration (Linux) ───────────────────────────────────────────
- name: Build ao CLI (integration)
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: cd cli && make build
- name: Run CLI commands integration test
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: bash tests/integration/test-cli-commands.sh
- name: Run release smoke test
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: bash scripts/release-smoke-test.sh --skip-build
# ── json-flag-consistency (Linux; was its own job, needs go-build → go||ci) ─
- name: Run JSON flag consistency tests
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
run: |
chmod +x tests/cli/test-json-flag-consistency.sh
./tests/cli/test-json-flag-consistency.sh
# ── smoke-test (Linux; python) ────────────────────────────────────────
- name: Run smoke tests
if: runner.os == 'Linux' && (needs.changes.outputs.skills == 'true' || needs.changes.outputs.hooks == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true')
run: |
chmod +x tests/smoke-test.sh
./tests/smoke-test.sh --verbose
# ── doctor-check (Linux; advisory; was needs go-build → go||ci) ───────
- name: Run ao doctor (advisory dead-reference check)
if: runner.os == 'Linux' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
continue-on-error: true
run: |
echo "=== Running ao doctor ==="
/tmp/ao-test doctor 2>/dev/null || true
echo ""
echo "Note: doctor may report warnings for CI-expected missing tools."
echo "This job catches stale references and dead commands."
# ── windows-smoke (Windows matrix leg) ────────────────────────────────
- name: Run native Windows smoke tests
if: runner.os == 'Windows' && (needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true')
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .\tests\windows\test-windows-smoke.ps1
# ─────────────────────────────────────────────────────────────────────────
# lint — shellcheck, markdownlint, skill-lint. Absorbs: shellcheck,
# markdownlint, skill-lint. (No scripts/ family — inline + action + tests/.)
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────
# security — secret/dangerous-pattern scan + full security toolchain gate.
# Absorbs: security-scan, security-toolchain-gate.
# ─────────────────────────────────────────────────────────────────────────
security:
needs: [changes]
if: >-
needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' ||
needs.changes.outputs.skills == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Scan for secrets
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.skills == 'true' || needs.changes.outputs.ci == 'true'
run: |
echo "=== Scanning for secrets ==="
# Common secret patterns
patterns=(
"password.*=.*['\"][^'\"]{8,}['\"]"
"api[_-]?key.*=.*['\"][^'\"]{16,}['\"]"
"secret.*=.*['\"][^'\"]{8,}['\"]"
"(access|auth|refresh|bearer)[_-]?token.*=.*['\"][^'\"]{16,}['\"]"
"AWS[_A-Z]*=.*['\"][A-Z0-9]{16,}['\"]"
)
found=0
for pattern in "${patterns[@]}"; do
if grep -r -i -E "$pattern" \
--exclude-dir=.git \
--exclude-dir=tests \
--exclude-dir=testdata \
--exclude-dir=cli/testdata \
--exclude-dir=dist \
--exclude-dir=.agents \
--exclude-dir=.tmp \
--exclude-dir=.gc \
--exclude="*.md" \
--exclude="*.jsonl" \
--exclude="*.sh" \
--exclude="*_test.go" \
--exclude="validate.yml" \
--binary-files=without-match \
. 2>/dev/null | grep -v 'os\.Getenv(' | grep -v 'regexp\.MustCompile(' ; then
found=1
fi
done
if [[ $found -eq 1 ]]; then
echo "⚠️ Potential secrets found - review above"
exit 1
fi
echo "✅ No secrets detected"
- name: Check for dangerous patterns
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.skills == 'true' || needs.changes.outputs.ci == 'true'
run: |
echo "=== Checking for dangerous patterns ==="
# Patterns that could be dangerous in scripts
# Note: validate.sh files use eval safely for CLI validation
dangerous=(
"rm -rf /"
"curl.*\| *sh"
"curl.*\| *bash"
"wget.*\| *sh"
)
found=0
for pattern in "${dangerous[@]}"; do
if grep -r -E "$pattern" \
--include="*.sh" \
--exclude-dir=.git \
--exclude-dir=tests \
--exclude-dir=cli/testdata \
--exclude="install-ao.ps1" \
--exclude="install-bd.sh" \
--exclude="installer-bootstrap.sh" \
--exclude="installer-common.sh" \
--exclude="ci-local-release.sh" \
. 2>/dev/null; then
echo "Found: $pattern"
found=1
fi
done
if [[ $found -eq 1 ]]; then
echo "⚠️ Dangerous patterns found"
exit 1
fi
echo "✅ No dangerous patterns"
- name: "Door 9 guard: no phased RPI Claude print path"
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true'
run: bash scripts/check-door9-no-claude-p.sh
- name: Set up Go (security toolchain)
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.5'
cache-dependency-path: cli/go.sum
- name: Set up Python (security toolchain)
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
- name: Install scanner tools
# 3-attempt exponential-backoff retry per soc-z7qq item 40
# (covers transient trivy/hadolint network timeouts).
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true'
run: |
retry() {
local max=3
local delay=5
local attempt=1
while true; do
if "$@"; then
return 0
fi
if [[ $attempt -ge $max ]]; then
echo "command failed after $max attempts: $*" >&2
return 1
fi
echo "attempt $attempt failed; retrying in ${delay}s..." >&2
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
}
retry python -m pip install --upgrade pip
retry python -m pip install semgrep==1.169.0 ruff==0.15.21 radon==6.0.1
retry env GOBIN=/usr/local/bin go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
retry env GOBIN=/usr/local/bin go install github.com/zricethezav/gitleaks/v8@v8.30.1
retry env GOBIN=/usr/local/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
retry env GOBIN=/usr/local/bin go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
# trivy — pinned tag, download-then-execute (mirrors nightly.yml; piping a
# mutable-branch script into sh is the gha-curl-pipe-shell supply-chain class).
retry bash -c 'curl -sSfL -o /tmp/trivy-install.sh https://raw.githubusercontent.com/aquasecurity/trivy/v0.72.0/contrib/install.sh && sh /tmp/trivy-install.sh -b /usr/local/bin v0.72.0'
retry bash -c 'curl -sL https://github.com/hadolint/hadolint/releases/download/v2.14.0/hadolint-Linux-x86_64 -o /tmp/hadolint && chmod +x /tmp/hadolint && sudo mv /tmp/hadolint /usr/local/bin/hadolint'
- name: Run security toolchain gate
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true'
run: |
chmod +x scripts/security-gate.sh
./scripts/security-gate.sh --mode quick
env:
SECURITY_GATE_OUTPUT_DIR: ${{ runner.temp }}/agentops-security
TOOLCHAIN_OUTPUT_DIR: ${{ runner.temp }}/agentops-tooling
- name: Upload security gate artifacts
if: always() && (needs.changes.outputs.go == 'true' || needs.changes.outputs.shell == 'true' || needs.changes.outputs.ci == 'true')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: security-gate
path: ${{ runner.temp }}/agentops-security/
retention-days: 7
# ─────────────────────────────────────────────────────────────────────────
# summary — aggregate deterministic check status over the purpose jobs.
# ─────────────────────────────────────────────────────────────────────────
summary:
needs: [changes, go-gate-shadow, correctness, security]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check results
run: |
echo "=== CI Summary ==="
echo "go-gate-shadow: ${{ needs.go-gate-shadow.result }}"
echo "correctness: ${{ needs.correctness.result }}"
echo "security: ${{ needs.security.result }}"
# Fail on actual failures. On release tags, skipped purpose jobs are
# also failures because validate.yml must be a full exact-SHA release
# verdict, not a path-filtered partial signal.
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
echo ""
echo "❌ Some checks failed (failure detected)"
exit 1
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
release_needs_json='${{ toJson(needs) }}'
release_skipped="$(
NEEDS_JSON="$release_needs_json" python3 - <<'PY'
import json
import os
# All purpose jobs run on release tags (changes sets every output
# true), so no job should be skipped. None are allowlisted.
allowed_skips = set()
needs = json.loads(os.environ["NEEDS_JSON"])
unexpected = sorted(
name
for name, metadata in needs.items()
if metadata.get("result") == "skipped" and name not in allowed_skips
)
for name in unexpected:
print(name)
PY
)"
if [[ -n "$release_skipped" ]]; then
echo ""
echo "❌ Release-tag Validate had unexpected skipped jobs; skipped release lanes are not a release verdict"
printf '%s\n' "$release_skipped" | sed 's/^/- /'
exit 1
fi
fi
echo ""
echo "✅ All checks passed"