# Workflow Convergence Campaign — 2026-05-26

> Generated by: estate-wide audit of drifting per-repo workflow templates
> Scope: 5 candidate templates ranked by drift × deployments × feature-variance
> Outcome: 5 reusable-workflow PRs filed in this repo; classifier tooling shipped; nested-path methodology gotcha documented for future campaigns

## Summary

This campaign extracted 5 drifting per-repo workflow templates into reusable workflows hosted in this repo, plus shipped the classifier tooling and a Git-Tree-walk helper that planned the wrapper sweeps and validated the deployment counts.

The campaign also surfaced a **methodology gotcha** that affects every future drift survey in this estate: `gh api /search/code` queries undercount workflow files in three compounding ways. After the campaign-meta-doc filed its first cut, the helper at [#204](https://github.com/hyperpolymath/standards/pull/204) walked the Git Tree API for all 5 templates and revised the count tables — top-level deployments were undercounted by 1–35% depending on template, and nested copies were undercounted by 100%+ for several templates. See [Corrected estate counts](#corrected-estate-counts) for the helper-validated tables.

## Filed PRs

Deploy counts here are the **helper-validated top-level counts** (from `list-workflow-paths.sh` walking each repo's Git Tree). The initial path-filtered survey numbers — which are listed in the PR bodies themselves — are 1–35% lower depending on template.

| PR | Template | Top-level (helper) | Top-level (PR body) | Reusable LOC | Top SHA share |
|---|---|---:|---:|---:|---|
| [#187](https://github.com/hyperpolymath/standards/pull/187) | `mirror-reusable.yml` | 293 | 289 | 165 | 76% |
| [#190](https://github.com/hyperpolymath/standards/pull/190) | `secret-scanner-reusable.yml` | 299 | 281 | 159 | 69% (across top 4 SHAs) |
| [#192](https://github.com/hyperpolymath/standards/pull/192) | `codeql-reusable.yml` | 280 | 263 | 96 | 83% single-language |
| [#193](https://github.com/hyperpolymath/standards/pull/193) | `hypatia-scan-reusable.yml` | 344 | 255 | 459 | 83.5% top-5 |
| [#194](https://github.com/hyperpolymath/standards/pull/194) | sweep-classifier scripts | — | — | — | tooling |
| [#199](https://github.com/hyperpolymath/standards/pull/199) | this campaign meta-doc | — | — | — | docs |
| [#204](https://github.com/hyperpolymath/standards/pull/204) | `list-workflow-paths.sh` helper + classifier ingestion | — | — | — | tooling |
| [#205](https://github.com/hyperpolymath/standards/pull/205) | `scorecard-reusable.yml` | 278 | 258 | 87 | 38.8% |

All PRs have auto-merge enabled (per the [auto-merge-on-all-PRs standing policy](https://github.com/hyperpolymath/.github)). The wrapper sweep does NOT fire automatically — each template is owner-gated post-merge.

## Convergence set status

The 5-candidate convergence set is **fully filed** (#187 mirror / #190 secret-scanner / #192 codeql / #193 hypatia-scan / #205 scorecard). #194 (classifiers), #199 (this doc), and #204 (helper + nested-path classifier ingestion) close out the supporting infrastructure.

## Ranking methodology

For each candidate, three signals were combined:

1. **Drift** = (unique blob SHAs / total deployments). Lower = more homogeneous.
2. **Deployments** = number of repos carrying the template. Higher = more leverage.
3. **Feature variance** = whether the SHAs differ in job-set / step-set / language matrix (real customization) or only in SPDX-header / action-pin / whitespace (mechanical lag).

A reusable is viable when (2) is high AND (3) is low. (1) tells you how much wrapper-sweep work the rollout needs.

| Template | Deploys | Drift | Feature variance | Reusable viable |
|---|---|---|---|---|
| `hypatia-scan.yml` | 255 | 11.8% | ZERO | ✅ (filed #193) |
| `scorecard.yml` | 258 | 17.8% | ZERO | ✅ (recommended #195) |
| `secret-scanner.yml` | 281 | 19% | LOW (job-set homogeneous; force-propagates `shell-secrets` guardrail) | ✅ (filed #190) |
| `mirror.yml` | 289 | 24% | LOW (job-set homogeneous; same 7 forges across top SHAs) | ✅ (filed #187) |
| `codeql.yml` | 263 | 26% | MEDIUM (language matrix variance — needed `language` + `build-mode` inputs) | ✅ (filed #192) |

## The nested-path methodology gotcha

### Layer 1: path-prefix filter excludes nested workflows

`gh api /search/code` with `path:.github/workflows` matches the path **PREFIX**, so:
- `.github/workflows/codeql.yml` ✓ matches
- `developer-ecosystem/asdf-augmenters/.github/workflows/codeql.yml` ✗ does NOT match

Removing the `path:` filter and running `filename:codeql.yml org:hyperpolymath` exposes nested copies inside monorepos like `developer-ecosystem`, `ssg-collection`, `ambientops`, `standards`, `julia-ecosystem`, `asdf-tool-plugins`, etc.

### Layer 2: even the broad query is org-scope-truncated

GitHub Code Search's org-scoped result set is capped well below the true file count. For `codeql.yml`:

| Monorepo | Broad query saw | Per-repo query (truth) | Missed |
|---|---:|---:|---:|
| developer-ecosystem | 41 | 170 | 129 |
| asdf-tool-plugins | 11 | 111 | 100 |
| ssg-collection | 39 | 68 | 29 |
| standards | 26 | 39 | 13 |
| ambientops | 12 | 29 | 17 |
| julia-ecosystem | 11 | 20 | 9 |
| **Top-6 total** | **140** | **437** | **297** |

Broad-query undercount factor: **~2.6×** inside heavily-nested monorepos. Per-repo queries (`gh api repos/<org>/<repo>/git/trees/HEAD?recursive=1 --jq '.tree[] | select(.path | endswith("/<file>.yml"))'`) are the only reliable source of truth for monorepo-nested workflow files.

**Layer 2 also affects path-filtered queries** (a finding from the helper re-survey). `path:.github/workflows filename:hypatia-scan.yml` returned 255 results, but the Git-Tree walk found 344 top-level files — a 35% gap on the most-deployed template. The path filter is less severely truncated than the broad query, but both are sub-truth. Only direct Git-Tree enumeration is reliable.

### Layer 3: nested workflows are inert

**GitHub Actions only runs workflows from the repo-root `.github/workflows/` directory.** Workflows under any other path (e.g. `a2ml/bindings/deno/.github/workflows/secret-scanner.yml` inside the `standards` repo) are **never triggered**.

This means nested copies are one of:

1. **Vendored templates** — maintained as a snapshot of what a sub-package's own workflow would be if it were extracted into its own GitHub repo. Read-only intent; not load-bearing for the sub-package's CI.
2. **Stale leftover** — surviving artifacts of a previous monorepo merger; pure dead code.

**Implication:** the security-gap argument that motivated the secret-scanner reusable (forcing `shell-secrets` propagation post-Cloudflare-leak) does NOT extend to nested copies. The ~282 nested `secret-scanner.yml` files do not represent missing-guardrail attack surface — they don't run. The wrapper sweep on nested copies is **single-source-of-truth cleanup**, not security-hardening.

### Corrected estate counts

After [#204](https://github.com/hyperpolymath/standards/pull/204) shipped `list-workflow-paths.sh`, all 5 templates were re-enumerated by walking each repo's Git Tree API directly. The results invalidated **both** the broad-query and path-filtered survey numbers — even top-level counts were undercounted across the board.

#### Helper-validated counts (Git-Tree walk, all 5 templates)

| Template | Top-level | Nested | Total | Unique blob SHAs (all) |
|---|---:|---:|---:|---:|
| `hypatia-scan.yml` | **344** | 603 | **947** | 32 |
| `mirror.yml` | **293** | 335 | **628** | 120 |
| `secret-scanner.yml` | **299** | 292 | **591** | 83 |
| `codeql.yml` | **280** | 646 | **926** | 175 |
| `scorecard.yml` | **278** | 626 | **904** | 114 |

#### Top-level-only drift

The drift % when nested copies are excluded is the figure relevant for the *executing* surface (since nested workflows are inert, per Layer 3):

| Template | Top-level | Unique blob SHAs (top-level only) | Drift |
|---|---:|---:|---:|
| `hypatia-scan.yml` | 344 | **3** | **0.9%** |
| `secret-scanner.yml` | 299 | 54 | 18.1% |
| `scorecard.yml` | 278 | 46 | 16.5% |
| `mirror.yml` | 293 | 75 | 25.6% |
| `codeql.yml` | 280 | 75 | 26.8% |

**hypatia-scan top-level is byte-near-identical across 344 sites** — only 3 unique blob SHAs total. The reusable wrapper sweep for hypatia-scan is therefore essentially mechanical with near-zero per-repo variance, much tighter than the 11.8% drift the PR body reports (which was the drift across top-level + nested).

#### Initial-survey undercount summary

| Template | PR-body top-level | Helper top-level | Top-level undercount | Original nested estimate | Helper nested |
|---|---:|---:|---:|---:|---:|
| `hypatia-scan.yml` | 255 | 344 | **+89 (35%)** | 449 | 603 |
| `secret-scanner.yml` | 281 | 299 | +18 (6%) | 282 | 292 |
| `codeql.yml` | 263 | 280 | +17 (6%) | ~518 | 646 |
| `scorecard.yml` | 258 | 278 | +20 (8%) | 626 | 626 |
| `mirror.yml` | 289 | 293 | +4 (1%) | 133 | 335 |

The `path:.github/workflows`-filtered query under-reports top-level counts at the rate above, and the broad query under-reports nested counts even more severely (most extremely for mirror: 133 reported vs 335 true). Some of the gap is creation-of-new-repos between survey-and-helper runs, but most is search-index truncation.

#### LOC retirement (top-level only — the executing surface)

| Template | Top-level | × Canonical LOC | LOC retired |
|---|---:|---:|---:|
| `hypatia-scan.yml` | 344 | 416 | **~143,000** |
| `mirror.yml` | 293 | 145 | ~42,500 |
| `codeql.yml` | 280 | ~150 | ~42,000 |
| `secret-scanner.yml` | 299 | ~120 | ~36,000 |
| `scorecard.yml` | 278 | 41 | ~11,400 |
| **Total (5 reusables, top-level)** | | | **~275,000** |

Including nested copies (single-source-of-truth cleanup, not the executing surface): **~732,000 LOC** estate-wide eligible for wrapper replacement.

## Classifier tooling pattern (`scripts/sweep-classifiers/`)

Each template has a `classify-<template>.sh` script (committed in [#194](https://github.com/hyperpolymath/standards/pull/194)) that:

1. Reads paginated `gh api /search/code` JSON into a `(repo, sha)` table.
2. Fetches each unique blob ONCE (cached in `$BLOBS_DIR`) — bandwidth-efficient.
3. Classifies each blob by job-set / line-count / language-matrix / SPDX-header lag.
4. Emits a per-repo TSV with `<repo> <class> <evidence>` for sweep batching.

Pattern: the classifier output drives a 3-wave sweep (mechanical bulk → propagation-lag bulk → per-repo review). Wave-1 PRs are auto-generated wrappers pinned to the merged-reusable HEAD SHA.

**Outstanding tooling gap** (filed as follow-up): classifiers currently emit one row per repo, not per `(repo, path)` tuple. For monorepo-nested copies, the classifiers need to ingest the per-repo recursive-tree listing and emit one wrapper site per nested copy. This is a small change to each script's input loop.

## Per-template specifics

### `mirror.yml` (PR #187)

7 forge jobs (gitlab, bitbucket, codeberg, sourcehut, disroot, gitea, radicle), each gated on `vars.<FORGE>_MIRROR_ENABLED == 'true'`. Only input: `runs-on`. Caller MUST use `secrets: inherit` (jobs reference forge-specific secrets). Wrapper sweep is mostly mechanical; the 22 NEEDS_REVIEW cases in the classifier output are slim 2-3-forge variants (older repos pre-7-forge expansion) or NONE-stub placeholders.

### `secret-scanner.yml` (PR #190)

4 jobs: `trufflehog`, `gitleaks`, `rust-secrets`, `shell-secrets`. Self-conditioning — `rust-secrets` exits early on no `Cargo.toml`, `shell-secrets` no-ops without `.sh`/`.bash`. **Force-propagates** the `shell-secrets` job (post-Cloudflare-leak guardrail) to the estate, where it currently exists in only 1 of 281 top-level deployments (this repo itself). The 282 nested copies do NOT close additional security gaps (Layer 3 caveat) but DO consolidate the source of truth.

The full-pagination data corrected an initial 100-repo sample's "55% drift" reading to a true **19% drift** with 100% job-set homogeneity. The parallel-session ranking that said "skip secret-scanner" was based on the sample drift figure and missed the homogeneity finding.

### `codeql.yml` (PR #192)

Two inputs: `language` (default `javascript-typescript`) and `build-mode` (default `none`). Single-language single-job design — multi-language wrappers invoke the reusable per-language. Language matrix distribution across 263 top-level deployments:

- 84.8% `javascript-typescript`-only
- 8.4% `actions`-only
- ~3.4% multi-language
- ~3.4% NONE-matrix (needs per-repo review)

The campaign's most complex reusable — but still ~93% mechanical sweep.

### `hypatia-scan.yml` (PR #193)

Byte-identical to the canonical 416-line `scan` job. Single `runs-on` input. Caller MUST grant `security-events: write` + `pull-requests: write` permissions AND use `secrets: inherit`. The 416-line canonical × 704 total estate sites = ~280k LOC retired (the largest leverage of the 5 campaigns). 30 unique blob SHAs / 11.8% drift / 100% single-`scan`-job homogeneity. All drift is pure propagation lag (older slimmer versions of the same body).

### `scorecard.yml` (recommended #195)

258 deployments, 17.8% drift, **100% mechanical drift** — every variant has one `analysis` job with `checkout → ossf/scorecard-action → upload-sarif`. Same single `scorecard-action` SHA pin across all variants; differences are SPDX header (`PMPL-1.0` / `PMPL-1.0-or-later` / `MPL-2.0`), `upload-sarif` SHA pin, `permissions: read-all` vs `contents: read`, and presence/absence of the estate concurrency-guardrail block. 41-line canonical → ~8k LOC retired top-level only. Zero nested copies, making it the cleanest sweep candidate.

## Reconciliation with the parallel-session ranking

A parallel session ([[project_foundational_workflow_survey_2026_05_26]] in agent memory) ranked the 5 candidates and concluded "pursue hypatia-scan + mirror; skip codeql / secret-scanner / scorecard." The recommendation set differs from this campaign's outcome on three templates:

| Template | Parallel call | This campaign | Reason for divergence |
|---|---|---|---|
| `secret-scanner.yml` | SKIP | PURSUE | Parallel based on 55% sample drift; full pagination → 19% drift, 100% homogeneity. The skip call missed the shell-secrets propagation gap. |
| `codeql.yml` | SKIP | PURSUE | Real (but contained) language-matrix variance — 85% are single-language `javascript-typescript`, making the 2-input reusable dead-simple for the dominant case. |
| `scorecard.yml` | SKIP | PURSUE | Same shape as secret-scanner mistake: 18% sample drift looks like real customization but is ALL mechanical (action-SHA churn + SPDX header drift). |

The agreement between sessions was strong on `hypatia-scan.yml` (both: pursue, HIGH homogeneity) and `mirror.yml` (both: pursue, ~76% top-SHA dominance). The divergence comes from the parallel session's reliance on sample-drift estimates without full pagination, where my campaign full-paginated and blob-fetched each candidate.

## Standing follow-ups

1. **Owner-review on the 8 PRs** (#187/#190/#192/#193/#194/#199/#204/#205) — auto-merge enabled; CI-green will auto-land.
2. **Per-repo recursive-tree classifier inputs** — DONE in #204. Classifiers now accept the helper's TSV output (`<repo>\t<path>\t<blob-sha>`) and emit one row per `(repo, path)` tuple. Backward-compatible with legacy JSONL.
3. **Wrapper sweep firing** is owner-gated per template. The 82-PR rust-ci sweep is the upper bound the user has signalled comfort with; mirror's 293-PR sweep would need explicit per-batch sign-off. With the helper-validated counts, sweeps now have ground-truth target lists.
4. **Nested-copy disposition decision** — for each monorepo (developer-ecosystem, ssg-collection, ambientops, julia-ecosystem, asdf-tool-plugins, etc.), are the nested workflows vendored templates (keep, sync to reusable) or stale leftover (delete)? This is a per-monorepo judgement call; the campaign defers it.
5. **Re-run helper periodically** — the Git-Tree-walk truth shifts as new repos land. Suggest a quarterly re-run of `list-workflow-paths.sh` against each of the 5 templates to keep this doc's count tables fresh.

## Cross-references

- Prior pattern: PR [#168 language-policy convergence](https://github.com/hyperpolymath/standards/pull/168) — first instance of "subsume legacy workflow into governance-reusable" pattern; merged 2026-05-26.
- Prior pattern: PR [#174 rust-ci + elixir-ci reusables](https://github.com/hyperpolymath/standards/pull/174) — extended the pattern; 82-PR wrapper sweep filed downstream (`project_rust_ci_wrapper_sweep_2026_05_26`).
- Estate-wide language policy: [`.claude/CLAUDE.md`](../../.claude/CLAUDE.md) — Python/V-lang/ATS2/ReScript/TypeScript bans enforced by `governance-reusable.yml`.
- Security context: the `shell-secrets` job force-propagation in #190 is direct mitigation for the [unrotated Cloudflare API token leak](#) (memory `project_cloudflare_token_leak_2026_05_21`).
