Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
85d43ba
fix(compliance): track per-workflow version tags in stub checker
don-petry May 16, 2026
4b82049
fix(compliance): guard against missing version tag in centralized array
don-petry May 16, 2026
3645418
docs(ci-standards): update tier table and pin rationale for per-workf…
don-petry May 16, 2026
680929e
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
c9766e0
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
c5fde9b
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
6f89a0b
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
204a1f1
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
f1da6e6
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
930e1ad
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
eee61d0
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
dd63ce0
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
60bc9df
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
51b6150
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
cc02b14
Merge branch 'main' into fix/pr-review-mention-v2-compliance
don-petry May 17, 2026
fce58c3
chore(compliance): use safe regex escaping for reusable version tags
don-petry May 17, 2026
01eb68c
chore(compliance): include braces in regex escaping
don-petry May 17, 2026
ca4c4f7
docs(ci): clarify internal reusable workflow tag exceptions for v2
don-petry May 17, 2026
6b627fe
chore(compliance): document regex escaping invariant
don-petry May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 35 additions & 21 deletions scripts/compliance-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ log_end() { echo "::endgroup::" >&2; }
info() { echo "[INFO] $*" >&2; }
warn() { echo "::warning::$*" >&2; }

# escape_ere escapes ERE metacharacters in a string for literal matching in grep -E.
# This ensures that version tags (e.g. v2.1) and reusable basenames are treated
# as literal strings even if they contain regex metacharacters.
escape_ere() {
printf '%s' "$1" | sed 's/[][\.^$*+?(){}|\\/{}]/\\&/g'
}

# Retry wrapper for gh api calls (handles rate limits)
gh_api() {
local retries=3
Expand Down Expand Up @@ -697,17 +704,20 @@ check_ci_concurrency() {
}

# ---------------------------------------------------------------------------
# Check: Tier 1 centralized workflows must be thin caller stubs pinned to @v1
# Check: Tier 1 centralized workflows must be thin caller stubs pinned to the
# canonical version tag for their reusable.
#
# For each workflow that the org has centralized into a reusable workflow,
# verify the downstream repo's copy is a stub that delegates via:
# uses: petry-projects/.github/.github/workflows/<reusable>.yml@v1
# uses: petry-projects/.github/.github/workflows/<reusable>.yml@<version>
#
# This prevents drift: a repo that copies the inline pre-centralization
# version (or pins to @main, or pins to an older tag) is flagged so it
# can be re-synced from the standard. The central .github repo itself is
# version (or pins to @main, or pins to a non-canonical tag) is flagged so
# it can be re-synced from the standard. The central .github repo itself is
# exempt because it owns the reusables and may legitimately reference
# its own workflows by @main during release prep.
#
# Array format: "workflow-filename:expected-reusable-basename:version-tag"
# ---------------------------------------------------------------------------
check_centralized_workflow_stubs() {
local repo="$1"
Expand All @@ -716,16 +726,16 @@ check_centralized_workflow_stubs() {
# own reusables by @main; skip the stub check for it.
[ "$repo" = ".github" ] && return

# workflow-filename:expected-reusable-basename
# workflow-filename:expected-reusable-basename:version-tag
local centralized=(
"dev-lead.yml:dev-lead-reusable"
"auto-rebase.yml:auto-rebase-reusable"
"dependency-audit.yml:dependency-audit-reusable"
"dependabot-automerge.yml:dependabot-automerge-reusable"
"dependabot-rebase.yml:dependabot-rebase-reusable"
"agent-shield.yml:agent-shield-reusable"
"feature-ideation.yml:feature-ideation-reusable"
"pr-review-mention.yml:pr-review-mention-reusable"
"dev-lead.yml:dev-lead-reusable:v1"
"auto-rebase.yml:auto-rebase-reusable:v1"
"dependency-audit.yml:dependency-audit-reusable:v1"
"dependabot-automerge.yml:dependabot-automerge-reusable:v1"
"dependabot-rebase.yml:dependabot-rebase-reusable:v1"
"agent-shield.yml:agent-shield-reusable:v1"
"feature-ideation.yml:feature-ideation-reusable:v1"
"pr-review-mention.yml:pr-review-mention-reusable:v2"
)

# List the repo's workflow directory once instead of probing each file.
Expand All @@ -734,9 +744,10 @@ check_centralized_workflow_stubs() {
workflow_list=$(gh_api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null || echo "")
[ -z "$workflow_list" ] && return

local entry wf reusable
local entry wf reusable version
for entry in "${centralized[@]}"; do
IFS=':' read -r wf reusable <<< "$entry"
IFS=':' read -r wf reusable version <<< "$entry"
Comment thread
don-petry marked this conversation as resolved.
[ -z "$version" ] && { echo "::error::centralized entry '$entry' missing version tag — expected format 'wf:reusable:version'" >&2; exit 1; }

# Skip workflows that don't exist in this repo. Required workflows are
# checked separately by check_required_workflows; conditional ones
Expand All @@ -754,27 +765,30 @@ check_centralized_workflow_stubs() {
[ -z "$decoded" ] && continue

# Required pattern: a non-comment line whose `uses:` value is exactly
# petry-projects/.github/.github/workflows/<reusable>.yml@v1
# petry-projects/.github/.github/workflows/<reusable>.yml@<version>
# Anchor to start-of-line + optional indent so a `# uses: ...` comment
# cannot satisfy the check.
local expected="petry-projects/\\.github/\\.github/workflows/${reusable}\\.yml@v1"
local esc_reusable esc_version
esc_reusable=$(escape_ere "$reusable")
esc_version=$(escape_ere "$version")
local expected="petry-projects/\\.github/\\.github/workflows/${esc_reusable}\\.yml@${esc_version}"

if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*${expected}([[:space:]]|$)"; then
continue # stub is correctly pinned to @v1 — compliant
continue # stub is correctly pinned to the canonical version — compliant
fi

# Determine why it's non-compliant for a more actionable message.
local why
if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*petry-projects/\\.github/\\.github/workflows/${reusable}\\.yml@"; then
why="references the reusable but is not pinned to \`@v1\` (org standard)"
if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*petry-projects/\\.github/\\.github/workflows/${esc_reusable}\\.yml@"; then
why="references the reusable but is not pinned to \`@${version}\` (org standard)"
elif echo "$decoded" | grep -qF "petry-projects/.github/.github/workflows/${reusable}"; then
why="references the reusable but the \`uses:\` line does not match the canonical stub"
else
why="is an inline copy instead of a thin caller stub — re-sync from \`standards/workflows/${wf}\`"
fi

add_finding "$repo" "ci-workflows" "non-stub-$wf" "error" \
"Centralized workflow \`$wf\` $why. Replace with the canonical stub from \`standards/workflows/${wf}\` which delegates to \`petry-projects/.github/.github/workflows/${reusable}.yml@v1\`." \
"Centralized workflow \`$wf\` $why. Replace with the canonical stub from \`standards/workflows/${wf}\` which delegates to \`petry-projects/.github/.github/workflows/${reusable}.yml@${version}\`." \
"standards/ci-standards.md#centralization-tiers"
done
}
Expand Down
37 changes: 19 additions & 18 deletions standards/ci-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ where to send a fix when behavior needs to change.

| Tier | Examples | What lives in `standards/workflows/` | Where logic lives | Edits allowed in adopting repo |
|---|---|---|---|---|
| **1. Stub** | `dev-lead.yml`, `dependency-audit.yml`, `dependabot-automerge.yml`, `dependabot-rebase.yml`, `agent-shield.yml`, `feature-ideation.yml` | A thin caller stub that delegates via `uses: petry-projects/.github/.github/workflows/<name>-reusable.yml@v1` | The matching `*-reusable.yml` in this repo (single source of truth) | **None** in normal use. May tune `with:` inputs where the reusable exposes them (e.g. `agent-shield` accepts `min-severity`, `required-files`; `feature-ideation` requires `project_context`). To change behavior, open a PR against the reusable in this repo — repos on `@v1` pick it up after the `v1` tag is bumped; repos on `@main` pick it up on their next run. |
| **1. Stub** | `dev-lead.yml`, `dependency-audit.yml`, `dependabot-automerge.yml`, `dependabot-rebase.yml`, `agent-shield.yml`, `feature-ideation.yml`, `pr-review-mention.yml` | A thin caller stub that delegates via `uses: petry-projects/.github/.github/workflows/<name>-reusable.yml@<version>`, where `<version>` is the canonical tag for that reusable (see `check_centralized_workflow_stubs` in `scripts/compliance-audit.sh`; most are `@v1`, `pr-review-mention` is `@v2`) | The matching `*-reusable.yml` in this repo (single source of truth) | **None** in normal use. May tune `with:` inputs where the reusable exposes them (e.g. `agent-shield` accepts `min-severity`, `required-files`; `feature-ideation` requires `project_context`). To change behavior, open a PR against the reusable in this repo — the canonical tag is bumped deliberately when a release is ready. |
| **2. Per-repo template** | `ci.yml`, `sonarcloud.yml` | _(no template — see the patterns documented below)_ | In each repo, because the workflow is tech-stack-specific (language matrix, build tool, test framework) | **Limited.** Each adopting repo carries its own copy. Stay within the patterns in this document; do not change action SHAs, permission scopes, trigger events, or job names without raising a standards PR first. |
| **GitHub-managed** | CodeQL default setup | _(no workflow file — managed via repo Settings → Code security)_ | GitHub | None. Configured via `apply-repo-settings.sh`; per-repo `codeql.yml` files are treated as drift by the compliance audit. See [§2 CodeQL Analysis](#2-codeql-analysis-github-managed-default-setup). |
| **3. Free per-repo** | `release.yml`, project-specific automation | _(out of scope for this standard)_ | Per-repo | Free, but must still comply with the [Action Pinning Policy](#action-pinning-policy) and the [Required Workflows](#required-workflows) constraints. |
Expand All @@ -33,11 +33,12 @@ file with that header, **stop and read the header first** — if the change
isn't allowed by the contract, the right move is a PR against the central
reusable, not a local edit.

> **Why pin to `@v1`?** Stubs reference reusables by tag, not `@main`, so a
> **Why pin to a canonical tag?** Stubs reference reusables by tag, not `@main`, so a
> bad commit on the central repo's `main` branch cannot break every
> downstream repo simultaneously. The `v1` tag is bumped deliberately when
> a backward-compatible release is ready; breaking changes will publish a
> `v2` tag that downstream repos opt into explicitly.
> downstream repo simultaneously. Each reusable has its own canonical tag
> (most are `@v1`; `pr-review-mention` is `@v2`). A tag is bumped deliberately
> when a backward-compatible release is ready; breaking changes publish a new
> tag that downstream repos opt into explicitly.

### Available templates

Expand Down Expand Up @@ -556,7 +557,7 @@ present as an org-level secret; no per-repo setup needed.

**Compliance:** The compliance audit (`check_centralized_workflow_stubs`) verifies that
repos have `pr-review-mention.yml` as a thin caller stub delegating to
`petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v1`.
`petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v2`.
Comment thread
don-petry marked this conversation as resolved.

---

Expand Down Expand Up @@ -855,26 +856,26 @@ incorrect pinned one.
### Exception: Internal Reusable Workflow References

Calls to `petry-projects/.github` reusable workflows use tag references
(`@v1`, `@main`) — **not SHA pins** — and are exempt from this policy.
(`@v1`, `@v2`, or `@main`) — **not SHA pins** — and are exempt from this policy.

```yaml
# CORRECT — tag ref for internal reusable workflow
uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@v1
uses: petry-projects/.github/.github/workflows/dev-lead-reusable.yml@v1
uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v2

# WRONG — do not SHA-pin internal reusable workflow refs
uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@ee22b427cbce9ecadcf2b436acb57c3adf0cb63d
uses: petry-projects/.github/.github/workflows/dev-lead-reusable.yml@ee22b427cbce9ecadcf2b436acb57c3adf0cb63d
```

**Why:** Pinning the `uses:` line in a Tier 1 caller stub creates a diff from
the default branch. Anthropic's OIDC token endpoint validates that
`.github/workflows/claude.yml` on a PR branch is identical to the default
branch — any diff causes `401 Workflow validation failed` and Claude Code
cannot run on that PR.

The `@v1` tag on `petry-projects/.github` is managed deliberately (bumped only
on backward-compatible releases) and is not subject to tag-force-push risk
because the org controls the tag. **Do not open compliance PRs to pin these
references.**
the default branch. Anthropic's OIDC token endpoint validates that the
workflow file on a PR branch is identical to the default branch — any diff
causes `401 Workflow validation failed` and the agent cannot run on that PR.

The canonical tags (e.g. `@v1`, `@v2`) on `petry-projects/.github` are managed
deliberately (bumped only on backward-compatible releases) and are not subject
to tag-force-push risk because the org controls the tag. **Do not open
compliance PRs to pin these references.**

---

Expand Down