hseshadr/ci

GitHub: hseshadr/ci

该项目为 edgeproc 仓库群提供一套集中的可复用 GitHub Actions 工作流与组合操作,消除跨仓库 CI 配置的重复与漂移。

Stars: 0 | Forks: 0

# hseshadr/ci — one home for the portfolio's CI/CD **TL;DR — what this is.** Every repo in the edgeproc portfolio used to copy-paste the same GitHub Actions setup: check out the code, install the toolchain, run the quality gate, scan for leaked secrets, audit dependencies, deploy the site. Seven repos, seven near-identical copies that drifted apart over time. This repo holds **one shared copy of each**, and every other repo calls it in a few lines. Change CI once here; all seven get the change. **Why it works.** GitHub lets a workflow *call* a workflow that lives in another repo (`uses: hseshadr/ci/...@`), and lets a job *reuse* a bundle of steps called a "composite action." So the shared logic lives here exactly once, and each repo keeps only the one thing that is genuinely its own — its build command. **Where that stands today.** Written is not the same as adopted, so the picture shows both: flowchart TD CI["hseshadr/ci — one copy of each CI job
7 reusable workflows + 5 composite actions"] CI -->|"called at a pinned commit: 2a575cd = ci-v3.0.0"| USED["In use today — 6 call-sites
ts-publish.yml ×3 · setup-python-uv ×3"] USED --> WHO["assay · edge-proc · edgeproc-core · privacy-core
their release path runs this shared copy"] CI -.->|"nobody calls these yet"| IDLE["The other 10 bricks — 0 call-sites
almamesh · aml-filter · edge-reco
still hand-roll their own CI"] The dotted branch is the point of the [consumer-drift guard](#consumer-drift-what-is-still-hand-rolled): publishing a shared control does nothing until something calls it, so this repo measures the gap instead of assuming it away. The counts below are that measurement. **Why it exists.** "If we are manually changing things per project per repo, nothing is standardized." One place to bump `actions/checkout`, one place to fix the gitleaks pattern, one place that defines what "run the gate" means. No drift. **Status.** Current release: **`ci-v3.0.0`** (commit `2a575cd193e2e1fc093ccd26821020538e2547b7`, 2026-07-30). Templates written and statically validated — all 32 YAML files parse, and `actionlint` plus `zizmor` run in CI over the workflows *and* over `examples/` (the examples need staging into a `.github/workflows/` layout first, which `tests/lint-examples.sh` does; a plain repo-root scan reaches none of them). Both are clean. Every example is additionally resolved against the repository it is written for — see [Guards that run in CI](#guards-that-run-in-ci). The cross-repo [access flip](#required-setup-read-this-first) is done, so callers resolve. **Adopted in code by four repos — six call-sites, all of them on the publish path.** Counted by grepping every consumer's `.github/workflows/` on 2026-07-31: | Brick | Call-sites | Where | |---|---|---| | `setup-python-uv` (composite) | 3 | assay, edge-proc, edgeproc-core | | `ts-publish.yml` (reusable workflow) | 3 | assay (×2), privacy-core | | the other 4 composites and 6 reusable workflows | **0** | nowhere | `privacy-core` calls `ts-publish.yml` cross-repo; `assay` calls `ts-publish.yml` cross-repo **and** carries an inline PyPI job that composes this repo's `setup-python-uv` composite; `edge-proc` and `edgeproc-core` carry the same inline PyPI job (cross-repo PyPI is structurally impossible — see the warning below). All six call-sites pin the `ci-v3.0.0` commit SHA `2a575cd…`; five of the six still carry a stale `# ci-v2.0.3` label comment beside it, which is a Dependabot-readability nit, not a wrong pin. `almamesh`, `aml-filter` and `edge-reco` have zero call-sites of any kind. **The publish path is LIVE-VALIDATED end-to-end — two consumer releases have run through it green (2026-07-22):** - **npm, cross-repo:** privacy-core `v0.2.1 Publish (npm, OIDC)` — [run 29886074787](https://github.com/hseshadr/privacy-core/actions/runs/29886074787), SUCCESS — a consumer release executing this repo's `ts-publish.yml` at the pinned `ci-v2.0.3` SHA. - **the calls-both pattern:** assay `v0.1.1 Publish (OIDC)` — [run 29887096259](https://github.com/hseshadr/assay/actions/runs/29887096259), SUCCESS on both jobs — `publish-pypi` inline (composing this repo's `setup-python-uv` composite at the pinned SHA) and `publish-npm` through cross-repo `ts-publish.yml`. Still unproven: the gate, secret-scan, security-audit, frontend, and deploy templates have **no consumer runs** — those repos still run their own inline `ci.yml` and `security-audit.yml`. A daily sweep counts exactly how much of that is left: **29 hand-rolled controls across 7 consumer repositories** as of 2026-07-31 (see [Consumer drift](#consumer-drift-what-is-still-hand-rolled)). And `edgeproc-core`'s six older green publish runs (when it was still named `shared-libs-python`) predate the migration *and* its PyPI trusted-publisher bootstrap, which is why the package never resolved on PyPI (see [Publish verification](#publish-verification)). Those six older green runs are also why both publish workflows **verify the release against the registry** after uploading: the package they were releasing does not resolve on PyPI. A green upload step and a published package are different facts, and until then nothing here checked the second one. See [Publish verification](#publish-verification). Net: the npm publish workflow (`ts-publish.yml`) and the `setup-python-uv` composite have executed green inside real consumer releases — the two runs above. The gate, audit, and deploy templates have not yet had a consumer run. See the [self-assessment](#self-assessment) for the scorecard. ## Adopt it with one caller A repo's entire CI can become this (`.github/workflows/ci.yml`): name: CI on: { push: { branches: [main] }, pull_request: } permissions: contents: read pull-requests: read jobs: gate: uses: hseshadr/ci/.github/workflows/python-gate.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 with: { sync-args: "--frozen --all-extras" } gitleaks: uses: hseshadr/ci/.github/workflows/secret-scan.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 That is the *whole file*, and it is copy-pasteable as written: the SHA above **is** `ci-v3.0.0`, the current release. `gate` runs the repo's `poe gate` (lint, format-check, types, complexity, tests + coverage floor); `gitleaks` scans the full git history for secrets. Ready-to-copy callers for all seven consumer repos live in [`examples/`](./examples), carrying the same SHA. Every `hseshadr/ci/...` ref must be a full commit SHA, never a moving `@ci-vN` tag; see [Version pinning](#version-pinning-full-commit-shas) for why. ### Before → after (a real consumer) edge-proc's hand-rolled `ci.yml` + `security-audit.yml` was **~89 lines** of the same five steps every Python repo repeats: # BEFORE — .github/workflows/ci.yml (representative; ~49 lines, and a ~40-line # security-audit.yml just like it) name: CI on: { push: { branches: [main] }, pull_request: } jobs: gate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v8.1.0 with: { enable-cache: true } - run: uv python install 3.13 - run: uv sync --frozen --all-extras - run: uv run poe gate gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: { fetch-depth: 0 } - uses: gitleaks/gitleaks-action@v2 env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } # …then a whole separate security-audit.yml repeating the uv setup for pip-audit… **After** — the small `ci.yml` above plus a 10-line `security-audit.yml`. Across the whole portfolio the templatable workflows shrink from **~598 lines to ~135** (about a 77% cut), and the action-version drift the survey found (edge-reco on `checkout@v7` while the others were on `@v5`) collapses to one pinned set here. ## Which brick do I want? A **reusable workflow** replaces a whole job (`uses:` at job level). A **composite action** replaces a few steps *inside* a job you still own (`uses:` at step level). "Adopted by" is what actually calls it in a consumer repo today — a blank means nobody does yet, not that it is broken. | Brick | Use it when | Adopted by | |---|---|---| | `python-gate.yml` (workflow) | your Python repo runs `uv run poe gate` | — | | `frontend-gate.yml` (workflow) | your JS repo runs `pnpm gate`, optionally with Playwright | — | | `secret-scan.yml` (workflow) | any repo — gitleaks over the full git history | — | | `security-audit.yml` (workflow) | you want `pip-audit` and/or `pnpm audit` (at least one must be on) | — | | `cloudflare-pages-deploy.yml` (workflow) | you deploy a built site to Cloudflare Pages | — | | `ts-publish.yml` (workflow) | you release an npm package from a `v*` tag, token-free via OIDC | assay (×2), privacy-core | | `python-publish.yml` (workflow) | **same-repo only.** PyPI Trusted Publishing cannot match a cross-repo call — copy the inline job from `examples/edge-proc/publish.yml` instead | — | | `setup-python-uv` (composite) | your own job needs uv + a pinned Python + a locked `uv sync` | assay, edge-proc, edgeproc-core | | `setup-pnpm` (composite) | your own job needs pnpm + Node with a warm store cache | — | | `setup-playwright` (composite) | your own job needs cached Playwright browsers | — | | `restore-model-cache` (composite) | your own job needs large model weights cached between runs | — | | `pages-deploy-dist` (composite) | your build is bespoke but you want the shared, header-hardened `wrangler pages deploy` | — | ## What's in here (maps 1:1 to the tree) .github/ workflows/ # reusable workflows plus this repo's own gate ci.yml # validates this repo's CI security policy python-gate.yml # checkout → setup → uv run poe gate → (opt) codecov frontend-gate.yml # checkout → pnpm setup → (opt) Playwright → pnpm gate secret-scan.yml # gitleaks over full history security-audit.yml # pip-audit and/or pnpm audit (each bool-gated) cloudflare-pages-deploy.yml # preflight → build → wrangler pages deploy python-publish.yml # gate → uv build → PyPI via OIDC → verify on PyPI (SAME-REPO only) ts-publish.yml # gate → build → npm via OIDC → verify on npm consumer-drift.yml # daily sweep: which consumers still hand-roll a control actions/ # composite actions — a bundle of steps you `uses:` INSIDE a job setup-python-uv/ # install uv (cached) + pin Python + (opt) uv sync setup-pnpm/ # pnpm + Node (pnpm cache) + (opt) install setup-playwright/ # cache + install Playwright browsers restore-model-cache/ # cache self-hosted model weights + fetch on miss pages-deploy-dist/ # baseline headers + shared wrangler deploy dependabot.yml # bumps THIS repo's action pins; consumers re-pin the new SHA examples/ # copy-paste caller workflows, one folder per consumer repo tests/ security-policy.sh # YAML + pins + pin PROVENANCE + permissions + injection lint-examples.sh # stages examples/ into a real workflow layout, then # actionlint + zizmor them (neither tool reaches them # otherwise), then runs example-fidelity.sh example-fidelity.sh # does every example still resolve against the repo it serves? example-fidelity-cases.sh # both-polarity fixtures for that checker consumer-drift.sh # which consumers still hand-roll a control we publish? consumer-drift-cases.sh # both-polarity fixtures for the drift classifier consumer-drift-allowlist.txt # the convergence backlog: known drift, each with a reason lineage-guard-cases.sh # drives the lineage guard against synthetic repos to prove # its release-commit exemption stays one release wide lib/ scan-run-interpolation.rb # finds attacker-controllable ${{ }} inside run: blocks scan-publish-provenance.rb # proves every publish path is signed workflow-run-pin.rb # parses fork-deploy gates into a boolean AST classify-workflow.rb # classifies a consumer workflow by behavior example-references.rb # resolves an example's references inside a consumer repo first-party-lineage.sh # the lineage/currency guard, shared by two suites above .ruby-version # 3.4.10 — the Ruby the guards above are run on, in CI too CHANGELOG.md **Reusable workflow vs composite action — the one distinction that explains everything.** A *reusable workflow* is an entire job on its own fresh runner: great when the whole job is shared, but it cannot accept extra `uses:` steps injected by the caller. A *composite action* runs *inside* the caller's own job, so the caller can wrap its own cache/build steps around it. That single fact decides what became a workflow and what became a composite (details below). ## Under the hood (for developers) ### Reusable workflows | Workflow | Key inputs | Secrets | What the job runs | |---|---|---|---| | `python-gate.yml` | `working-directory` `.`, `python-version` `3.13`, `sync-args` `--locked` (must carry `--frozen`/`--locked`; opt out only via `--allow-unlocked`), `gate-task` `gate`, `upload-coverage` `false`, `coverage-file` `coverage.xml` | `CODECOV_TOKEN` (optional) | checkout → **setup-python-uv** → `uv run poe ` → optional Codecov upload | | `frontend-gate.yml` | `working-directory` `.`, `package-json-file`, `node-version` `24` / `node-version-file`, `cache-dependency-path` `pnpm-lock.yaml`, `install-args` `--frozen-lockfile`, `gate-command` `pnpm gate`, `install-playwright` `false`, `playwright-browsers` `chromium` | — | checkout → **setup-pnpm** → optional **setup-playwright** → `gate-command` | | `secret-scan.yml` | `runs-on` | uses `GITHUB_TOKEN` | checkout `fetch-depth:0` → `gitleaks-action` over full history | | `security-audit.yml` | `run-python-audit` `false`, `run-pnpm-audit` `false`, `python-working-directory` `.`, allowlisted `pip-audit-export-args`, `frontend-working-directory` `frontend`, `pnpm-audit-level` `low` | — | `pip-audit` job (validated export args → `pip-audit`) and/or `pnpm-audit` job (validated severity) | | `cloudflare-pages-deploy.yml` | `project-name`*, `dist-dir`*, `build-command`*, `install-working-directory` `.`, `pre-build-run` `""`, `node-version(-file)`, `cache-dependency-path`, `branch` `main`, `wrangler-version` `4.110.0` | `CLOUDFLARE_API_TOKEN`*, `CLOUDFLARE_ACCOUNT_ID`* | preflight (skip-clean if secrets absent) → guard → **setup-pnpm** → pre-build → build → **pages-deploy-dist** | | `python-publish.yml` (**same-repo only** — cross-repo consumers inline it) | `working-directory` `.`, `python-version` `3.13`, `sync-args` `--locked`, `gate-task` `gate`, `run-gate` `true`, `packages-dir` `dist`, `attestations` `true`, `environment` `""` | — (OIDC, token-free) | checkout → **setup-python-uv** → reuse gate → `uv build` → `gh-action-pypi-publish` (PyPI **OIDC Trusted Publishing**) | | `ts-publish.yml` | `working-directory` `.`, `node-version` `24`, `gate-command` `pnpm gate`, `build-command` `pnpm build`, `run-gate` `true`, `provenance` `true` (a **private** caller must pass `false` explicitly), `registry-url` `…npmjs.org`, `environment` `""` | `NPM_READ_TOKEN` (optional, private-dep installs only) | checkout → **setup-node** (registry for OIDC) → **setup-pnpm** → gate → build → `npm publish` (npm **OIDC Trusted Publishing**) | \* required. Every other input has a documented default — no version or path is a magic literal buried in a step; the gate's coverage floor is deliberately **not** an input (it lives in each repo's `pytest --cov-fail-under`, so CI can never pass a looser bar than local). **OIDC publishing (`python-publish` / `ts-publish`) — no stored token.** Both publish workflows carry `on: workflow_call` and are pinned by callers at a full commit SHA (a moving tag here would be a supply-chain hole — see [Version pinning](#version-pinning-full-commit-shas)); the caller owns the `on: push: tags: ['v*']` trigger and grants `id-token: write` on its publish job (the top-level stays read-only, as the security policy requires). The build and the OIDC identity run in one job, so nothing needs a `twine`/`NODE_AUTH_TOKEN` write token. Two one-time human bootstraps remain, both outside CI: registering the trusted publisher on PyPI/npm once per package, and — because npm has **no** "pending publisher" — a single token/OTP first-publish for each brand-new npm name before OIDC can take over. **Signing is the default; not signing is what you ask for.** `ts-publish`'s `provenance` and `python-publish`'s `attestations` both default **true** — `provenance` since `ci-v3.0.0`, which is the current release — and `tests/security-policy.sh` **rejects** any workflow or example that publishes without them — a PyPI upload missing `attestations: true`, an inline `npm`/`pnpm`/`yarn publish` missing `--provenance` (in a workflow *or* a composite action), a `ts-publish` caller setting `provenance: false`, a `python-publish` caller setting `attestations: false`, or a publishing job missing `id-token: write`. `provenance` defaulted false until 2026-07-25 as a private-repo hangover, and that was a silent trap: `npm publish --provenance` writes a public transparency-log entry and so requires a **public** source repo, but a caller who simply forgot the input got an unsigned release and a perfectly green run. A **private** caller must now pass `provenance: false` explicitly. Unchanged: each `package.json` `repository.url` must exactly match its GitHub repo or npm OIDC fails. Ready callers: [`examples/privacy-core/publish.yml`](./examples/privacy-core/publish.yml) (npm, cross-repo — live-proven by run 30173462035, which put a SLSA v1 provenance attestation on `@edgeproc/privacy-core` 0.2.2), [`examples/edge-proc/publish.yml`](./examples/edge-proc/publish.yml) and [`examples/edgeproc-core/publish.yml`](./examples/edgeproc-core/publish.yml) (inline PyPI job — the only shape PyPI Trusted Publishing permits from another repo), and [`examples/assay/publish.yml`](./examples/assay/publish.yml) (both at once — live-proven by run 29887096259). ### Composite actions | Action | Key inputs | What it runs | |---|---|---| | `setup-python-uv` | validated `python-version` `3.13`, allowlisted `sync-args` `--locked` (locked by default; explicit `--allow-unlocked` to opt out), `working-directory` `.`, `run-sync` `true` | install uv (cached) → `uv python install` → optional `uv sync` | | `setup-pnpm` | `package-json-file`, `node-version` `24` / `node-version-file`, `cache-dependency-path`, allowlisted `install-args` `--frozen-lockfile`, `working-directory` `.`, `install` `true` | `pnpm/action-setup` → `setup-node` (pnpm cache) → optional `pnpm install` | | `setup-playwright` | `cache-key`*, allowlisted `browsers` `chromium`, `working-directory` `.` | cache `~/.cache/ms-playwright` → install browsers (miss) or OS deps only (hit) | | `restore-model-cache` | `cache-path`*, `cache-key`*, `fetch-command`*, `working-directory` `.`, `always-fetch` `false` | cache the weights dir → run `fetch-command` only on a cache miss | | `pages-deploy-dist` | `project-name`*, `dist-dir`*, `cloudflare-api-token`*, `cloudflare-account-id`*, `branch` `main`, `wrangler-version` `4.110.0` | add a conservative `_headers` baseline when absent → `npx wrangler pages deploy` | Every composite assumes the caller **already ran `actions/checkout`** (a composite can't assume a working tree). Composites can't read the `secrets` context, so `pages-deploy-dist` takes the two Cloudflare secrets as inputs. The Pages baseline sets anti-framing, MIME-sniffing, referrer, browser-feature, HSTS, and narrow CSP controls. If a build already contains `_headers`, the action preserves it byte-for-byte so an application can own a stricter or intentionally different policy. Cloudflare applies `_headers` to static asset responses; Pages Functions must set equivalent headers in their own response code. **Composition, not duplication.** The reusable workflows don't re-implement setup — they *compose the same composites the bespoke jobs use*. `python-gate` composes `setup-python-uv`; `frontend-gate` composes `setup-pnpm` + `setup-playwright`; `security-audit` composes both (with sync/install switched off); `cloudflare-pages-deploy` composes `setup-pnpm` + `pages-deploy-dist`. So the uv pin, the pnpm/Node pins, the Playwright cache pattern, and the wrangler invocation each live in exactly one file. ### Version pinning: full commit SHAs Consumers pin a **full 40-character commit SHA**, with the release name in a trailing comment so Dependabot can bump it: uses: hseshadr/ci/.github/workflows/python-gate.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 Moving tags are **not** a supported pin, not even for first-party refs. `tests/security-policy.sh` fails the build on any `uses: hseshadr/ci/...@ci-vN`, in the workflows this repo runs and in the examples it publishes. **Why the stricter rule.** These refs used to ride the moving `@ci-v1` tag behind a `zizmor` suppression, and that left a real hole: a consumer that pinned `python-publish.yml` to a SHA still had the *nested* `setup-python-uv@ci-v1` resolved through a mutable tag at run time, so the pin was only skin-deep. The publish workflows run with `id-token: write` for OIDC Trusted Publishing, so moving `ci-v1` would have reached PyPI and npm across every consumer. Pinning the whole chain closes it. `ci-vX.Y.Z` and the moving `ci-vN` pointers still exist as human-readable release *names* — read them in [`CHANGELOG.md`](./CHANGELOG.md) to find the SHA you want. They are not what you put after the `@`. Add a Dependabot `github-actions` entry in each consumer so these pinned SHAs are tracked like any other dependency; upgrading is then a deliberate, reviewable commit rather than a tag someone else can move under you. ### Immutable third-party action pins Every executable third-party `uses:` reference is pinned to the full 40-character commit behind the selected release. The trailing release comment is intentional: Dependabot updates both the SHA and its readable `# v…` label. | Action | Release comment | Pin policy | |---|---|---| | `actions/checkout` | `# v7` | full commit SHA | | `actions/setup-node` | `# v6` | full commit SHA | | `actions/cache` | `# v6` | full commit SHA | | `pnpm/action-setup` | `# v6` | full commit SHA | | `astral-sh/setup-uv` | `# v8.3.2` | full commit SHA | | `codecov/codecov-action` | `# v7` | full commit SHA | | `gitleaks/gitleaks-action` | `# v3` | full commit SHA | First-party `hseshadr/ci/...` references get the **same** treatment — full commit SHA, no exceptions. First-party is not a synonym for trustworthy: a moving tag is a moving tag regardless of who owns it, and these run in workflows that hold `id-token: write`. The self-references can't be relative action paths (`./.github/actions/...`), because a reusable workflow executes against the *caller's* checkout, where that path would resolve to the consumer repository instead of this one — so a SHA is the only immutable form available, and `validate_first_party_pins` in `tests/security-policy.sh` enforces it with no carve-out. That last claim is load-bearing enough that we measured it rather than trusting it. A throwaway probe put an identically-pathed composite in both repositories, with different markers, and had a consumer call a reusable workflow here that referenced it as `./.github/actions/probe-origin`. [Run 29838733369](https://github.com/hseshadr/privacy-core/actions/runs/29838733369) printed the **consumer's** marker: PROBE_RESULT=RESOLVED_TO_CONSUMER_REPO_hseshadr_privacy_core action_path=/home/runner/work/privacy-core/privacy-core/./.github/actions/probe-origin and the no-checkout control failed with `Can't find 'action.yml' … under '/home/runner/work/privacy-core/privacy-core/.github/actions/probe-origin'. Did you forget to run actions/checkout before running your local action?`. So `./` is workspace-relative, not repository-relative: it would silently run whatever the consumer happens to have at that path, or nothing at all. It is not an option here. **A pin-shape check is not enough, which we learned the expensive way.** Every ref can be a valid 40-hex SHA and the tree can still be wrong: for a while every file in `examples/` pointed at `ci-v2.0.0` (`36bf999`), a real commit and a real ancestor — whose reusable workflows still contained nested `@ci-v1` moving tags. The shape check passed, so a consumer following this repo's own documented path inherited the exact hole the pin was supposed to close. `validate_first_party_release_lineage` now asserts *provenance* instead: every `hseshadr/ci` SHA must exist in this repository, be an ancestor of the newest `ci-vX.Y.Z` tag, and **be** that tag. A superseded-but-valid release now fails the build. ### The release-commit bootstrap There is exactly one state that rule cannot express, and it is forced by arithmetic rather than by taste: **a commit cannot contain its own SHA.** Our self-references are absolute SHAs (see above — `./` is not available), so at the moment we tag a release, every self-reference inside the tagged tree still names the *previous* release. There is no value we could have written that would name the new one. Under a strict "must be the newest tag" rule the tagged commit therefore failed its own guard. That was not hypothetical: dispatching CI at `ci-v2.0.2` ([run 29839090693](https://github.com/hseshadr/ci/actions/runs/29839090693)) went red with `first-party ref 9e8cf2e… is a superseded release, not ci-v2.0.2` across all 21 files — a release that could not re-run its own pipeline green. So the guard now allows one narrow thing: | Where the guard runs | What a first-party ref may name | |---|---| | The newest tag's own commit | that tag, **or** the release immediately before it | | Any other commit | that tag, and nothing else | Ancestry and existence are still checked everywhere, with no carve-out; only the *currency* clause relaxes, only at the tagged commit, and only by one release. **The residual gap, stated plainly.** A consumer pinning `ci-vX.Y.Z` gets that release's reusable workflows, but the composite actions nested *inside* those workflows come from `ci-vX.Y.(Z-1)`. Those nested refs are still immutable released SHAs — nothing moves under anyone — but they are one generation behind. When a release changes a composite's behavior, that change reaches consumers only at the following release. The CHANGELOG marks any release whose composites changed, and the re-pin commit on `main` immediately after each tag is what closes the gap for anyone tracking `main`. Because this repository's own history cannot produce a two-releases-behind tagged commit on demand, the exemption's *scope* is asserted against synthetic repositories in `tests/lineage-guard-cases.sh` — ten cases, **seven of which must keep failing**. It runs in CI as its own step, and `validate_self_ci` fails the build if that step is ever removed. ### Publish verification Both publish workflows ask the registry whether the release actually landed, instead of trusting the upload step's exit code. After `pypa/gh-action-pypi-publish` (or `npm publish`), the job derives the exact `name` + `version` it just shipped — from the sdist filename for PyPI, from `npm pkg get` for npm — and polls `https://pypi.org/pypi///json` or `npm view @`. Six attempts, ten seconds apart, roughly a minute. Propagation delay gets retries; a timeout is a **failure**, never a pass. This exists because a green upload and a published package turned out to be different facts. `edgeproc-core` (then named `shared-libs-python`) collected six green `Publish (PyPI, OIDC)` runs — one named `Release v0.2.0` — on top of a package that does not resolve on PyPI. The trusted-publisher bootstrap had never been completed, and no check in the pipeline was capable of noticing. The failure message says so directly and names that bootstrap as the first thing to check. ### Guards that run in CI Three suites run on every push and pull request, plus a daily sweep. Each answers a different question, and each is itself tested in **both** polarities — a guard that has never been shown saying NO is decoration. | Suite | Question it answers | Runs | |---|---|---| | `tests/security-policy.sh` | is *this repo's* YAML safe — pins, pin provenance, permissions, shell injection, signed publishes? | push / PR / weekly | | `tests/lint-examples.sh` | do the files consumers copy pass `actionlint` + `zizmor`, and do they still resolve? | push / PR / weekly | | `tests/consumer-drift.sh` | is a consumer hand-rolling a control we already publish? | daily + PR | Everything above is Ruby or Bash, and `.ruby-version` (3.4.10) pins the Ruby they run on — in CI too, via `ruby/setup-ruby`. Guards that decide whether a workflow is safe should not run on whatever Ruby a runner image happens to ship. #### Example fidelity: do the examples still fit their repos? `actionlint` and `zizmor` check an example's YAML shape and its workflow security. Neither opens the repository the example is *for*, so both stayed green while `examples/edge-reco/ci.yml` named `frontend/.node-version` — a file edge-reco has never had, and one `actions/setup-node` hard-fails on. That example was red as drafted and the gate shipped it. Since every convergence in this portfolio starts with "copy the example", an unchecked example is an unchecked migration. `tests/example-fidelity.sh` (with `tests/lib/example-references.rb`) closes that. For each example it resolves, against the consumer repository's **committed** default branch: - every file and directory path the example names, - every `package.json` script and node script it invokes, - every `poe` task it runs, - every `hseshadr/ci` brick it calls — **and every input name it passes to that brick.** Each reference gets one of three statuses: **OK**, **MISSING**, or **UNVERIFIABLE**. UNVERIFIABLE — no clone of that consumer, or too few references resolved to mean anything — is *never* a pass; it exits `2`, so "could not verify" can never be mistaken for "verified". Run it: tests/example-fidelity.sh # resolves against your ~/dev/oss clones tests/example-fidelity.sh --clone # shallow-clones the consumers (what CI does) It is wired into `tests/lint-examples.sh`, so CI runs it, and `tests/example-fidelity-cases.sh` drives it against synthetic examples to prove it can still fail. On its first run it found **8 broken references that actionlint and zizmor had both passed.** #### Consumer drift: what is still hand-rolled `tests/consumer-drift.sh` walks every consumer's workflows over the GitHub API, classifies each one by *behavior* (`tests/lib/classify-workflow.rb`), and reports any control a consumer hand-rolls that this repo already publishes. It exists because five consumers each carried their own Cloudflare Pages deploy while a reusable one sat here, and one of those five copies drifted into a fork-PR deploy hole. The bug was in the copy, not in the shared workflow — and nothing was comparing the two. Today's count: **29 hand-rolled controls across 7 repositories** (almamesh 6, aml-filter 5, edge-reco 5, assay 4, edge-proc 3, edgeproc-core 3, privacy-core 3). They are listed individually in `tests/consumer-drift-allowlist.txt`, which is a **convergence backlog, not an exemption list**: every entry requires a written reason, deleting one is free, and *new* drift with no entry fails the build. Two failure modes that used to read as success are now failures: a sweep that inspected **zero** repositories exits `2` rather than reporting a clean bill of health, and a scheduled run whose API token is missing **fails** instead of exiting 0 with a notice. (A fork pull request still warns and continues — a fork legitimately cannot see secrets, and the classifier fixtures are the real gate on that path.) ### Input trust boundary Data-shaped inputs are parsed as quoted argument arrays and constrained to documented values: Playwright browsers, pnpm install flags, Python versions, uv sync/export flags, Poe task names, and audit severity. They are never expanded directly into shell code. Three inputs are intentionally command-shaped: model `fetch-command`, Pages `build-command` / `pre-build-run`, and frontend `gate-command`. They accept only literal commands committed in a trusted caller workflow. Never derive them from event payloads, repository variables, workflow-dispatch text, or other untrusted data. The implementation passes them through environment variables before invoking an isolated Bash process, which prevents GitHub template expansion from turning input text into the surrounding script. Every checkout sets `persist-credentials: false`; every workflow declares explicit token permissions; Dependabot waits seven days before adopting new action releases. ### Required setup (read this first) **These repos are private, so callers 404 with "workflow was not found" until this repo allows them.** One time, on `hseshadr/ci`: Or via the CLI: gh api -X PUT repos/hseshadr/ci/actions/permissions/access -f access_level=user This governs both the reusable workflows *and* the composite actions in this repo (the workflows pull the composites from here at a pinned SHA), so it must be set once for everything to resolve. When the repo is public this is automatic. ## Standardization coverage (per repo) This is the **target** mapping — what each repo should call once migrated — not current adoption. Today only 6 call-sites exist, all on the publish path (see [Status](#hseshadrci--one-home-for-the-portfolios-cicd)), and the gap between this table and reality is measured: **29 hand-rolled controls across these 7 repos** (see [Consumer drift](#consumer-drift-what-is-still-hand-rolled)). Adopted cells are in **bold**; everything else is still the target. Reusable workflow = whole shared job. Composite = shared steps inside a repo's own job. Bespoke = the irreducible repo-specific build, which still composes the shared composites. | Repo | Reusable workflows | Composites (inside bespoke jobs) | Irreducibly bespoke | |---|---|---|---| | **edge-proc** | python-gate, secret-scan, security-audit | **setup-python-uv** (inside its inline PyPI publish job — adopted) | none | | **edgeproc-core** | python-gate (+coverage), secret-scan, security-audit | **setup-python-uv** (inside its inline PyPI publish job — adopted; cross-repo `python-publish.yml` is impossible for PyPI TP) | none | | **assay** | python-gate, frontend-gate, secret-scan, security-audit, **ts-publish** (npm OIDC — adopted, ×2) | **setup-python-uv** (inside its inline PyPI publish job — adopted) | none | | **privacy-core** | frontend-gate (+Playwright), **ts-publish** (npm OIDC — adopted), secret-scan, security-audit | — | none | | **edge-reco** | secret-scan, python-gate (backend), cloudflare-pages-deploy, security-audit | setup-pnpm, restore-model-cache, setup-playwright (frontend + e2e jobs) | the frontend/e2e *gate commands* only | | **aml-filter** | secret-scan, security-audit | setup-pnpm, restore-model-cache, setup-playwright (ci); setup-pnpm + **pages-deploy-dist** (deploy) | bundle sign/verify build; `publish-watchlist.yml` | | **almamesh** | security-audit (python) | (optional) setup-python-uv | Bun + Pyodide `test.yml`, `deploy.yml`, `nightly-e2e.yml`; key-custody gitleaks | | **ci** (this repo) | **secret-scan** (via a local `./` ref, so it runs against the commit being changed) | — | its own policy suite + actionlint + zizmor + example-fidelity + the daily consumer-drift sweep, weekly on a `schedule` as well as on push/PR | The point of the composites: even the "bespoke" jobs re-implement **zero** setup or caching — aml-filter's signing deploy still calls `pages-deploy-dist` for the wrangler step, so there is one deploy half across edge-reco, aml-filter, and almamesh. **This repo is on that list too, and for a while it wasn't.** `ci` published `secret-scan.yml` while running no gitleaks step of its own, and had no scheduled run at all — so its zizmor **online** audits, which check a *moving* advisory database, only ever told you the tree was clean the last time someone pushed. Both are fixed above. One gap remains and it is not fixable from a workflow file: **`ci` has no branch protection and no repository secret scanning**, which are repository settings. See [Owner actions](#owner-actions). ### Owner actions Settings this repository cannot configure for itself: | Setting | Why it matters here | |---|---| | **Branch protection on `main`** (require the CI check, no force-push, no deletion) | Every consumer pins a commit SHA from this repo's history. An unprotected `main` means the branch those SHAs descend from can be rewritten. | | **Repository secret scanning + push protection** | Complements the gitleaks job: gitleaks catches what is already committed, push protection stops the commit. | | **Cut the release after `ci-v3.0.0`** | The re-pin commit on `main` after the `ci-v3.0.0` tag is what makes this release's *composites* reachable through its reusable workflows. Until a tag exists at or after that commit, `ci-v3.0.0` callers keep getting `ci-v2.0.3` composites — see [The release-commit bootstrap](#the-release-commit-bootstrap). | ## Limits — where standardization genuinely can't reach Honest boundaries, not force-fits: - **`dependabot.yml`** is config, not a workflow — it can't be `uses:`-referenced. Each repo keeps its own; standardize by copy, not by reference. - **almamesh's toolchain** is Bun + in-tree vendored deps (a sanctioned exception), so its `test.yml` / `deploy.yml` / `nightly-e2e.yml` don't fit the pnpm/uv templates. Only its python-only security audit maps cleanly. - **almamesh's gitleaks job** carries an extra key-custody tree-guard step, so it keeps a bespoke secret-scan job rather than calling `secret-scan.yml`. - **Multi-step signing builds** (aml-filter's Ed25519 sanctions bundle, almamesh's Pyodide + prod-key + IndexNow) are irreducibly repo-specific — a reusable workflow can't accept injected steps. They share only the *deploy half* via `pages-deploy-dist`. - **Singletons** (`edgeproc-core` publish, `aml-filter` publish-watchlist, almamesh nightly) exist in exactly one repo — nothing to de-duplicate. ## Self-assessment An honest self-assessment against a publish-readiness checklist: - **Teen-readable front door + layered depth** — ✅ plain-language TL;DR (what / why / status) before any jargon; a separate "Under the hood" section carries the depth. - **One-command adopt on real inputs** — ✅ the caller under [Adopt it with one caller](#adopt-it-with-one-caller) is copy-paste as printed, SHA and all; `examples/` holds a ready file for every consumer repo. - **Arch maps 1:1 to tree** — ✅ the "What's in here" tree matches `.github/` and `tests/` exactly. - **No hardcoded config** — ✅ every version/path is a documented input default; the coverage floor is deliberately owned by each repo's gate, not a CI input. - **Status matches reality / tags match the story** — ✅ CHANGELOG top release = `ci-v3.0.0` (`2a575cd…`, 2026-07-30), and every release lists the SHA consumers actually pin. All **40** first-party refs in this tree pin `ci-v3.0.0`, and `validate_first_party_release_lineage` fails the build if one drifts off it. `main` sits ahead of the tag, and at least the first commit of that gap is structural rather than drift: the re-pin cannot be *in* the commit it names, because a commit cannot contain its own SHA. The tag is cut first, the re-pin follows. The guard accepts that one state at the tagged commit and nowhere else — see [The release-commit bootstrap](#the-release-commit-bootstrap), which also states the residual gap it leaves. - **Every YAML valid** — ✅ all 32 files parse. `actionlint` runs in CI over both our own workflows and, via `tests/lint-examples.sh`, over `examples/`; both are clean, with zizmor's **online** audits enabled on both surfaces. - **The examples actually fit the repos they name** — ✅ `tests/example-fidelity.sh` resolves every path, script, poe task, brick and brick input in `examples/` against the consumer's committed default branch; UNVERIFIABLE is a failure, not a pass. It caught 8 broken references that actionlint and zizmor passed. See [Guards that run in CI](#guards-that-run-in-ci). - **The gap to full adoption is measured, not guessed** — ⚠️ **6** call-sites across 4 repos today, all on the publish path, against **29** hand-rolled controls still standing across 7 repos. Every one of the 29 is itemized with a reason in `tests/consumer-drift-allowlist.txt`, and new drift fails the build. - **Live-validated end-to-end** — ✅ **for the publish path** (2026-07-22): privacy-core [run 29886074787](https://github.com/hseshadr/privacy-core/actions/runs/29886074787) (npm `v0.2.1` through cross-repo `ts-publish.yml`) and assay [run 29887096259](https://github.com/hseshadr/assay/actions/runs/29887096259) (`v0.1.1`: PyPI through the inline job composing `setup-python-uv`, plus npm through cross-repo `ts-publish.yml`) — both SUCCESS, both executing this repo's code inside real consumer releases at the SHA pinned that day, `ci-v2.0.3`. Those callers have since been re-pinned to `ci-v3.0.0`; whether a consumer release has run through **that** SHA is **unverified** here. ⛔ **Still open:** the gate, secret-scan, security-audit, frontend, and deploy templates have zero consumer runs, and cross-repo PyPI through `python-publish.yml` is structurally **impossible** (`job_workflow_ref` mismatch — documented above), not merely unverified; consumers inline that job instead.
标签:GitHub Actions, 自动化运维, 自动笔记