Reusable GitHub Actions workflows shared across bounded-systems.
This repo is public on purpose. Private-repo workflows and actions never resolve into
public callers, and no org setting overrides that. The same constraint already forced the
approval gate into its own public repo
(bounded-systems/await-approval).
With ~87 public repos to serve, a shared workflow has to live somewhere public.
| workflow | what it does |
|---|---|
.github/workflows/osv-scan.yml |
Scans a repo's dependency lockfiles against the OSV database. Hard-fails on a known vulnerability. |
.github/workflows/env-check-drift.yml |
Fails when a caller's vendored .claude/hooks/cloud-env-check.mjs has drifted from tools/cloud-env-check.mjs here. |
.github/workflows/env-record.yml |
Fails when a caller's .claude/cloud-environment.json records a handshake digest that disagrees with its own contents. |
env-check-drift and env-record are complements, not alternatives: one asks whether the
script is canonical, the other whether the record is internally honest. A repo can
pass either and fail the other.
Got a red osv check? → REMEDIATION.md. A four-rung ladder —
relock, bump the parent, override, accept with an expiry — ordered by how much opinion
each embeds in the tree. It exists because ~45 repos were cleared ad hoc during the
2026-07-30 rollout and the inconsistency was itself a defect: two repos stalled as
"needs a decision" when rung 3 answered them, and adoption grace masked a CVSS 8.8.
required-baseline.yml in the org's .github repo describes itself as "the enforced
security FLOOR for every repo … injected on every repo's PRs by an org ruleset (the
workflows rule)". It is not injected, and it has never run. Verified 2026-07-30:
- the only org rulesets reaching
front-desk-scheduleraredefault-branch-protection(rule typesdeletion,non_fast_forward,pull_request,required_linear_history,required_signatures) andprotect important repos(repository_delete,repository_transfer). Neither contains aworkflowsrule. required-baselineappears zero times in that repo's run history.
The likely cause is the plan: ruleset-injected required workflows are a GitHub Enterprise
feature and this org is on Free — the same class of limit that already blocked environment
required-reviewers (infra/github-admin/README.md). That makes the injection mechanism
unavailable rather than misconfigured.
That is why this repo exists as a per-repo caller rather than an org injection. Until
the plan changes, adoption has to be an explicit uses: in each repo. required-baseline.yml
should be wired up, deleted, or clearly marked inert — as written it reads like a control
protecting every repo, and it is not. Tracked in
infra#104.
The two also disagree on posture: required-baseline is deliberately report-only
(continue-on-error: true); this workflow hard-fails. If the baseline is ever wired up,
reconcile them rather than running both.
The standardized way: copy templates/deps.yml byte-identical to
.github/workflows/deps.yml in the adopting repo. Identical callers everywhere means
the next scanner bump is one review of one diff, applied fleet-wide by the same sed. The
template includes the weekly Tuesday rescan (one day after this repo's Monday self-test
canary, so a broken digest is caught here before the fleet rescans on it). Hand-rolled
callers are for repos that genuinely need different triggers:
name: deps
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
osv:
uses: bounded-systems/ci-workflows/.github/workflows/osv-scan.yml@<full-sha> # vX.Y.ZPin the full commit SHA, not a tag or branch — the org's Actions policy requires it, and it is the whole reason a shared workflow is safe to fan out this widely.
| input | default | notes |
|---|---|---|
runs-on |
ubuntu-latest |
Runner label. |
scan-path |
. |
Directory to scan, relative to the repo root. |
ref |
(caller's ref) | Set only when scanning something other than the triggering commit. |
The scanner version and digest are not inputs — they are a matched pair, and a caller
able to override either one could silently defeat the pin. Bumping the scanner is an edit
to osv-scan.yml, reviewed once, inherited by every caller.
The lane goes red. To accept a finding, commit an osv-scanner.toml beside the lockfile:
[[IgnoredVulns]]
id = "GHSA-xxxx-xxxx-xxxx"
reason = "Not reachable: we never call the affected codepath. Revisit when upstream ships a fix."It is picked up automatically, and it leaves the reason in the tree as a reviewable diff rather than quietly weakening the lane.
scan source -r walks the tree and selects its own extractors, so npm, Cargo, Go, Python,
Maven and the other ecosystems OSV-Scanner supports are all discovered without
configuration.
deno.lock has no osv-scanner extractor, but the lane covers its npm subset anyway
(#1): a v4/v5 lock already
contains the fully-resolved npm graph, so a convert step re-encodes it as a CycloneDX SBOM
(tools/deno-lock-cdx.py, embedded in the workflow) that the scan picks up by filename.
Concretely, on front-desk-scheduler that turned 0 scanned deno.lock packages into 103.
What a Deno-first repo's green check means, bucket by bucket — the scan log prints these counts per lock:
| bucket | example | scanned? |
|---|---|---|
| npm-registry deps | mysql2@3.13.0, @hono/node-server |
✅ via the generated SBOM |
JSR deps via npm-compat (@jsr/*) |
@jsr/bounded-systems__verbspec |
❌ served from npm.jsr.io, not npmjs — no OSV advisory can exist under that purl, so listing them would inflate the count without adding coverage |
| JSR-native / remote deps | jsr:@bounded-systems/verbspec |
❌ OSV has no JSR ecosystem — nothing can scan these today |
The converter is a pure function: no timestamp, no random serialNumber, sorted
components, byte-identical output for identical input — enforced by golden tests
(test/), a heredoc↔file drift check (test/check_embed_sync.py), and reproducible
locally with nix flake check. Unsupported lock versions (v3 and older) produce a
::warning:: and are skipped, not a red X — they were previously scanned as nothing at
all, and adoption should not force lock migrations.
A repo with nothing scannable passes (--allow-no-lockfiles) rather than red-lining.
Scans run on PR/push only. An advisory published after a lock merges goes unnoticed
until the next change. Cheap fix, per caller: add a schedule: cron to the caller
workflow so the repo rescans weekly. (A central scanning service was considered and
rejected for now — see #1: it would add an availability dependency and a standing GitHub
credential, against the zero-standing-grants property that justified this lane.)
Copy templates/env-check.yml to .github/workflows/env-check.yml
in the adopting repo, and pin the full SHA. Same standardization argument as deps.yml.
cloud-env-check.mjs reconciles the hand-configured claude.ai/code environment dialog
against a repo's checked-in record of it, and --verify-domains probes the allowlist
rather than trusting the digest. It is repo-agnostic: everything repo-specific lives in a
handshake block in .claude/cloud-environment.json.
"handshake": { "variable": "INFRA_ENV_CONFIG", "prefix": "INFRA_" }Adoption is four things, not the two the script's header claims — the script, the config, an invocation from a SessionStart hook, and the variable in the dialog. Steps 3 and 4 live outside both files, and without them nothing runs. This lane covers step 1 only.
Tempting to put it on JSR next to @bounded-systems/guest-room. It is the wrong shape: a
SessionStart hook runs before dependencies are installed, so importing it would fetch
it over the network using the very allowlist it exists to validate. If the allowlist is
broken the check cannot run, precisely when you need it. (Same reason the script imports
nothing but node stdlib and shells out to curl — curl honours HTTPS_PROXY and the
proxy CA bundle without the script knowing either exists.)
So every adopter keeps a copy on disk, and the cost of that is drift. This lane makes the
drift loud — the same trade tools/deno-lock-cdx.py already makes against its embedded
twin, and the pattern infra's proofs/check-sync.mjs uses.
A reusable workflow's steps run in the caller's checkout, so tools/ is not on disk
there and the canonical bytes cannot be diffed against directly. Fetching them would put a
network dependency inside a check about network configuration — the same circularity the
vendoring exists to avoid. The pin is CANONICAL_SHA256 in env-check-drift.yml, and
test/check_env_check_digest.py fails if it disagrees with tools/cloud-env-check.mjs, so
a half-done bump reddens this repo before any caller.
Like the scanner digest, it is not a caller input: a caller able to override it could declare its own drifted copy canonical and silently defeat the check.
- Edit
tools/cloud-env-check.mjs - Update
CANONICAL_SHA256in.github/workflows/env-check-drift.yml(sha256sum tools/cloud-env-check.mjs) - Merge —
self-testproves the two agree - Re-vendor into each adopting repo and bump its caller pin
Steps 1–3 are one review. Step 4 is per-repo and is what the lane makes visible: a repo that skips it goes red on its next relevant PR instead of drifting unnoticed.
The same shape, with one step that exists because skipping it is what #10 is about:
- Edit
.github/workflows/osv-scan.yml - Merge, and re-pin
templates/deps.ymlto the merge commit —release-tag.ymlmints the version tag on that push automatically - Let the callers converge, then verify. Dependabot (
templates/dependabot.yml, vendored per repo as.github/dependabot.yml) opens each caller's re-pin PR on its weekly cycle; a human merges. The Mondaycaller-pins.ymlcensus proves the fleet actually converged.
The tag in step 2 is load-bearing, not ceremony: Dependabot resolves a SHA-pinned
uses: via the referenced repo's tags, so an untagged repo is invisible to it.
Measured, not assumed — guest-room ran this exact Dependabot config weekly since June,
and its refreshed group PR (guest-room#61) bumped checkout/codeql/scorecard pins
across three files while leaving deps.yml's osv-scan pin untouched at 8b7d8a8,
three template moves behind. Same engine, same config; the only difference was tags.
Step 3 matters because uses: …@<sha> resolves the reusable workflow at that commit:
a caller's pin decides which scanner that repo actually runs, a lane improvement that
stops at step 2 is merged and deployed to nobody, and — unlike the canonical-script
gate above — nothing makes a stale caller go red on its own. The 2026-08-03 census
measured 43 of 58 callers behind for exactly this reason (ci-workflows#10).
The division of labour is deliberate, and it is the OpenTofu shape: the template is the
desired state, the census is the plan (an ancestry diff — merge-base --is-ancestor — because a stale pin is a real commit, so existence checks cannot see
it; 62990dd resolves perfectly and is four behind), Dependabot is the apply, and
merging stays human. Once the fleet reads current, flip caller-pins' fail-on-lag
to true: from then on the plan must be empty, and a caller that lags a week reds the
Monday lane instead of drifting silently.
Why Dependabot rather than a broker-credentialed re-pin bot: the engine is
GitHub-maintained (nothing bespoke to keep working); a custom actor would need
org-wide contents:write plus the workflows permission — GitHub rejects
workflow-file pushes without it — minted from an entry that would make this repo's
main a lever over every repo's contents; and Dependabot also covers what the census
cannot see: consumers that call the lane from inside a combined workflow (infra's
_infra-test.yml) and every other action pin going stale the same way.
Two copies, currently in step. infra and front-desk-scheduler both carry
c530b86a…, byte-identical to canonical as of adoption — so this gate was introduced
green and only ever fires on real future divergence.
Open a PR to fix the drift. It fails, the same way check_embed_sync.py and
schema-drift.yml do, and that has been enough. Nor does it fix the dialog — the dialog is
UI-configured and outside any session's control.
OSV-Scanner runs as a pinned release binary, not via google/osv-scanner-action. A
third-party action can invoke nested actions by tag — the proofs lane hit exactly this
when lean-action reached for actions/cache/restore@v5 — and SHA-pinning the parent
cannot exempt its children. The org's full-SHA-pin policy rejects that. A release binary
has no children.
Current pin:
| version | v2.4.0 |
| asset | osv-scanner_linux_amd64 |
| sha256 | 15314940c10d26af9c6649f150b8a47c1262e8fc7e17b1d1029b0e479e8ed8a0 |
infra#104 flagged that the original digest was captured inside a Claude Code session whose egress runs through a TLS-intercepting proxy — "pinned to what we saw", not a vendor attestation — and asked for it to be re-derived before becoming an org-wide control.
Upstream publishes no checksums.txt, but it does publish a SLSA provenance bundle,
which is stronger than a checksums file. Verified 2026-07-30:
- the bundle's DSSE signature verifies against its embedded Fulcio certificate;
- the signed in-toto statement lists the digest above as the subject for
osv-scanner_linux_amd64; - the signing identity is the SLSA generator, not an ad-hoc key:
| field | value |
|---|---|
| SAN | slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.1.0 |
| OIDC issuer | https://token.actions.githubusercontent.com |
| source | github.com/google/osv-scanner @ refs/tags/v2.4.0 |
| commit | b56b5191101d5f27d4787d5583d8d01e9518a7af |
| build run | google/osv-scanner run 27762425209 |
Residual gap, stated plainly: the certificate was not chained to the Sigstore root out-of-band, and Rekor was unreachable from the verifying session (egress policy), so the transparency-log inclusion proof was not checked. A sufficiently capable interceptor holding a forged Fulcio-chained certificate could still have produced what was observed — a far higher bar than swapping a binary, but not zero.
Reproduce anywhere with open egress:
gh attestation verify --owner google \
--bundle multiple.intoto.jsonl osv-scanner_linux_amd64Or without gh, checking the signature and the subject directly:
V=v2.4.0
base=https://github.com/google/osv-scanner/releases/download/$V
curl -sSfLO $base/osv-scanner_linux_amd64
curl -sSfLO $base/multiple.intoto.jsonl
sha256sum osv-scanner_linux_amd64
python3 - <<'PY'
import json, base64
b = json.loads(open("multiple.intoto.jsonl").read().strip())
env = b["dsseEnvelope"]
open("cert.der","wb").write(base64.b64decode(b["verificationMaterial"]["certificate"]["rawBytes"]))
payload, pt = base64.b64decode(env["payload"]), env["payloadType"].encode()
open("pae.bin","wb").write(
b"DSSEv1 " + str(len(pt)).encode() + b" " + pt + b" "
+ str(len(payload)).encode() + b" " + payload)
open("sig.der","wb").write(base64.b64decode(env["signatures"][0]["sig"]))
for s in json.loads(payload)["subject"]:
print(s["digest"]["sha256"], s["name"])
PY
openssl x509 -in cert.der -inform DER -out cert.pem
openssl x509 -in cert.pem -noout -pubkey > pub.pem
openssl dgst -sha256 -verify pub.pem -signature sig.der pae.bin # -> Verified OK
openssl x509 -in cert.pem -noout -text | grep -A2 "Subject Alternative Name"Re-run this when bumping the version, and update both the digest and the identity table. A wrong-but-stable digest fails loudly; an unpinned binary changes silently.