When something happens in one component of a multi-repository project, other components often owe follow-up work. baton records those rules in one file and checks them on a schedule and on events. Each owed action stays open as an issue until baton can verify, from the repos or the running service, that it's done.
For Scorecard, an engine release owes three chains. The Action needs a bump, then a release. Infra needs a bump, a release, and a deployment. Allstar needs a bump, then a release. baton checks all of these today and replays them against history.
Preview. baton is pre-1.0.
applywrites issues only to a sandbox owner (see Running). The Scorecard example illustrates the format: its grace periods are illustrative, not Scorecard project policy, and it isn't affiliated with or endorsed by OpenSSF.
- The rules exist only in maintainers' heads. Scorecard's
RELEASE.mdcovers the engine release; it doesn't list the follow-up bumps and releases in the Action, infra, or Allstar.security-insights.ymllists the seven repos but not how they depend on each other. - Owed work is easy to lose track of. Scorecard v5.4.0 (2025-11-14) and v5.5.0 (2026-04-23) reached an Action release in v2.4.4 on 2026-07-23, 251 and 91 days later; a release request (ossf/scorecard-action#1679) was open in the meantime. As of 2026-11-01, Allstar hadn't picked up v5.6.0; its Dependabot bump (ossf/allstar#905, opened 2026-10-19) was failing CI.
- Existing tools stop at "a new version exists." Dependabot and Renovate open PRs one repo at a time. Nothing chains a merged bump to a release and then to a deploy, or checks that the running service has the new version.
The kinds of owed work baton is built to track. An obligation's
optional type names one of them, for reporting.
| Type | When A does this | B owes |
|---|---|---|
version-consumption |
Publishes a release | A bump of its pin |
release-chain |
Is bumped in B | A release of B, so B's users get A |
merged-not-deployed |
Merges a fix in a deployed component | A release and a deploy |
data-contract |
Changes a schema or result format | Updated copies, generated schemas, and consumers |
policy-coupling |
Changes what it accepts, or B changes its example | A matching change on the other side |
transitive-dependency |
Changes an indirect dependency | Code changes before it can take A |
toolchain-floor |
Raises its Go version or build image | A raised floor, CI images, and builders |
docs-mirror |
Changes content B copies | An updated copy |
endpoint-lifecycle |
Retires a domain, registry, or image | A move off it |
ownership-move |
Moves code, config, or issues to another repo | Repointed docs, templates, and routing |
security-advisory |
Fixes a vulnerability | A bump and a release, and a notice to operators |
| Piece | Where |
|---|---|
baton.yml: components, rollouts, invariants, waivers |
<project>-baton repo |
Reconciler (the baton tool) on a schedule and on events |
<project>-baton workflows |
| GitHub App: read all component repos, write issues | Installed on the org |
| Parent rollout issues | <project>-baton repo |
| Child issues, one per owed action | The repo that owes it |
| Release, deploy, and credentials | Each component's own repo |
| Repository list | security-insights.yml, unchanged |
Why a dedicated repo. Rollouts can start in any component: an
infra deploy owes checks for API consumers, and Allstar's results
format owes a monitor release. Keeping them out of ossf/scorecard's
tracker leaves that tracker to the engine.
CODEOWNERS in the baton repo let each component's maintainers review
what their component owes. Kubernetes does the same:
kubernetes/sig-release holds release-team tracking issues, separate
from kubernetes/kubernetes.
Why not a monorepo. GitHub Marketplace requires each Action's
action.yml at a repo root, Allstar imports
github.com/ossf/scorecard/v5, and infra holds deploy credentials.
Merging the repos would break consumers and widen access, and
external downstreams would remain separate anyway.
home: { repo: ossf/scorecard-baton }
inventory: { component: scorecard, path: security-insights.yml }
components:
scorecard: { repo: ossf/scorecard }
scorecard-action: { repo: ossf/scorecard-action, owners: [...] }
scorecard-infra:
{ repo: ossf/scorecard-infra, source: tags, since: 2026-08-05 }
allstar: { repo: ossf/allstar }
rollouts:
- id: scorecard-release
when: { release: scorecard }
obligations:
- id: action-source-updated
target: scorecard-action
action: Bump github.com/ossf/scorecard/v5 to {version}
verify: { gomod: { module: github.com/ossf/scorecard/v5 } }
grace: 7d
- id: action-released
target: scorecard-action
needs: [action-source-updated]
action: Release scorecard-action with scorecard {version}
verify: { release_gomod: { module: github.com/ossf/scorecard/v5 } }
grace: 14d
# infra-source-updated, infra-released, infra-deployed,
# allstar-source-updated, allstar-released ...
invariants:
- id: infra-schema-copy
target: scorecard-infra
action: Sync cron/internal/format/json.v2.schema with ossf/scorecard
verify:
files_equal:
source: { component: scorecard, path: pkg/scorecard/json.v2.schema }
copy:
{
component: scorecard-infra,
path: cron/internal/format/json.v2.schema,
}
waivers:
- obligation: scorecard-release/allstar-released
binding: scorecard@v5.6.0
approver: <maintainer>
reason: <why>
expires: 2026-12-01The full Scorecard file is examples/scorecard/baton.yml.
-
Inventory. Every component must be listed in the project's Security Insights file. baton reads that list and doesn't extend the format.
-
Releases. Each component says what counts as one of its releases, so projects that don't use SemVer, or tag in unusual ways, still work. The same definition serves triggers and probes such as
release_gomod.scorecard: repo: ossf/scorecard source: releases # forge releases (default) or tags releases: tag: '^v[0-9]+\.[0-9]+\.[0-9]+$' # only tags matching this prerelease: false # count prereleases (forge flag or -suffix) order: version # version (default), published, or forge
versionorders by SemVer and skips tags that aren't versions.publishedorders by publish date, for any tag scheme.forgeuses the release the maintainers marked as latest. The forge flag can't be read for a past date, so replay reports unknown for those components. Drafts never count. -
Triggers.
whennames the component whose releases owe work, and can narrow which ones do.level: minorskips patch releases, andlevel: majorskips minor ones too; it needsorder: version.tagadds a pattern of its own. A narrowed trigger doesn't change what counts as a release for probes. A patch release of the Action still satisfiesrelease_gomod. -
Rollouts. A trigger, plus the obligations it creates. Each rollout instance has a key made of the source repo, the tag, and the commit the tag points to, such as
ossf/scorecard:release:v5.6.0:61e84743…, so reruns and duplicate events update the same issues. The commit makes the key unique even where tags can move. A release recut at the same commit keeps its rollout. A tag moved to another commit starts a new rollout, which supersedes the old one.needsorders obligations within a rollout, and the validator rejects cycles. Only the latest release is owed: a newer one supersedes the older rollout. -
Rules digest. Every issue marker records
rules=and the first 12 hex digits of the SHA-256 ofbaton.yml, so each issue shows which rules produced it. The digest changes only when the file does, so commits elsewhere in the baton repo don't touch issues. -
Checks.
verifynames one probe, or a list of probes that must all pass. One step can then require several facts, such as the bump at the release tag, the published image, and a passing smoke test. Each list entry names exactly one probe. An error in any check makes the step an error. A check that fails makes the step pending or overdue, even if another check is unknown. The step is unknown only when every other check passes. Evidence lists each check's result. -
Invariants. Checks with no trigger, such as copied schemas, docs that mirror another repo, or references to a retired domain.
-
Waivers. These excuse an unmet obligation until they expire. Each needs an approver and a reason, and goes through PR review with the file. A waiver never hides an error. Steps that
needa waived step inherit the waiver, because they can't be done without it. -
Owners are optional. Issues land in the owing repo either way.
-
Strict parsing. A misspelled key is an error, not a silently ignored rule.
| State | Meaning | Issue |
|---|---|---|
| done | The check holds | Child closed |
| pending | Not done, within grace of its clock start |
Child open |
| overdue | Not done, past grace of its clock start |
Child open |
| blocked | An obligation it needs isn't done; its clock hasn't started |
No new child; an open one is kept and marked blocked |
| waived | Not done, with an active waiver | Child closed, waiver noted |
| unknown | Can't be checked for this date (live data in a replay) | No change |
| not-applicable | Target didn't exist yet (since) |
None |
| error | A probe or API call failed | No change; exit status 2 |
Each step has exactly one state. Signals that don't answer "is this done?", such as a failing canary, are notes on the step. A note never opens, closes, or blocks an issue.
Clock start. A step's grace runs from when it could first be
worked on. For a step with no needs, that's the trigger's publish
time. For a step with needs, it's the latest time a prerequisite
became done, as its probes report. A blocked step can't be overdue;
the overdue one is the prerequisite. When a prerequisite was waived or
not applicable, or its probes can't say when it became done, the
trigger time is used.
A rollout is complete when every obligation is done, waived, or not
applicable. Every obligation counts toward completion; there is no
non-blocking class. ready, running, and failed will be added
with dispatch (stage 2).
Each probe answers one question as of a date, so the same file replays against history.
| Probe | Done when | Done since | Types |
|---|---|---|---|
gomod |
target's default branch requires the module at ≥ {version} |
the oldest commit in the unbroken run of go.mod commits that satisfy it |
version-consumption, transitive-dependency, security-advisory |
release_gomod |
target's latest release or tag requires it at ≥ {version} |
the oldest release in the unbroken run of releases that satisfy it | release-chain, security-advisory |
deployed_gomod |
the commit a live endpoint reports requires it at ≥ {version}; live only |
not known | merged-not-deployed |
files_equal |
two files in two repos match | not known | data-contract, docs-mirror |
file_lacks |
a file doesn't contain a string | not known | docs-mirror, endpoint-lifecycle, ownership-move |
A step with several checks became done when its last check did.
gomod reads at most 100 go.mod commits back. If all of them
satisfy it, the oldest one read is used, which can start the next
step's clock late but never early.
Version comparison uses Go module SemVer, so a pseudo-version for a later commit counts as newer. Comparing what a release contains, not its version number, handles independent version lines (scorecard v5.6.0 shipped in Action v2.5.0).
Planned probes:
action_ref: a workflow pins an action at ≥{version}.go_directive: the target's Go floor is at least the source's.workflow_conclusion: a named workflow passed for a ref. This carries the released-Action smoke test and the production synthetic check, and turns the canary into evidence.
Canary results attach to obligations as evidence and never create them. Raw canary status can be noisy (rate limits, transient resolution failures), so a red canary alone isn't owed work.
- Schedule. Every six hours in the baton repo, in the template. The schedule is authoritative: it recomputes every obligation from what it observes, so missed events, manual fixes, and hand-closed issues all resolve.
- Events. Optional. A source repo's release workflow can send a
repository_dispatchto the baton repo so it runs at once. Each sender then needs a credential for the baton repo, so the template leaves it off. An event decides when baton looks; what it observes decides whether the work is done. - Issues. One parent per rollout instance
(
[rollout] scorecard v5.6.0), with a checklist that updates itself. Each pending or overdue obligation also gets a child issue in the owing repo, linked as a sub-issue. Every issue carries thebatonlabel and a hidden<!-- baton key=… -->marker. A hand-closed issue whose obligation is still unmet is reopened. - Identity. An org GitHub App, not a maintainer's token. It needs Contents read and Issues write, on every component and the baton repo. Each run mints a short-lived token with only those two permissions.
- Templates. templates/workflows/
holds the reconcile and validate workflows for a
<project>-batonrepo. They run a pinned baton release withgo run, which checks the module against the Go checksum database. - Preview. Until the write block is lifted,
applywrites only with--redirect-owner, which sends every issue to a sandbox owner, titled with the repo it's meant for and without assignees. The templates run that way, with the App installed on the sandbox org.planshows what baton would do in the real repos. Lifting the block is a separate release, made once the tool is trusted.
baton reads component repos and writes issues. Its one credential is the GitHub App's private key.
| Threat | Mitigation | Residual risk |
|---|---|---|
Someone edits baton.yml to drop an obligation or waive it |
Branch protection and CODEOWNERS on the baton repo; validate on every PR; waivers need an approver, a reason, and an expiry |
The approver field is text; PR review is the real control |
| The App key leaks | The key is in a baton environment limited to the default branch; PR workflows get no secrets and use pull_request, not pull_request_target; tokens last an hour |
A holder of the key can write issues and read repos where the App is installed, until the key is revoked |
| The App's scope grows | It asks for Contents read and Issues write only, so it can't push code, merge, or release | A future need for more scope must be argued in review |
| Text from repos, tags, or endpoints mentions people or injects Markdown | Evidence and bindings render as inline code, so @ mentions, # links, and Markdown don't take effect |
Action text from baton.yml, with {version} filled in, renders as Markdown |
| A live endpoint reports a false deployed commit | The URL is declared in reviewed baton.yml; the commit must exist and its go.mod must satisfy the rule |
baton trusts what the endpoint says is deployed |
| A tag moves or a release is recut | Rollout keys include the tag's commit, so a moved tag starts a new rollout | None beyond the forge's own history |
| A rule opens issues across many repos by mistake | plan previews every change; --redirect-owner sends writes to a test owner |
A merged bad rule writes on the next run |
| baton or its dependencies are compromised | Templates pin a release, checked against the Go checksum database; Actions pinned by commit; dependencies checked with Scorecard before adoption | A compromised release that is then pinned |
| Release events need a cross-repo credential | repository_dispatch is off by default; the schedule covers it |
Projects that turn it on hold one more credential per source repo |
baton is a Go CLI that follows SIG Release conventions
(kubernetes/release, release-utils, release-sdk):
| Command | Does |
|---|---|
baton validate FILE |
Checks the file offline; for PRs |
baton plan FILE [--as-of DATE] |
Evaluates and prints states and issue changes; read-only |
baton apply FILE --redirect-owner OWNER |
Makes the issue changes (sandbox only in the preview) |
baton version |
Build info via release-utils |
Exit status is 0 on success, 1 for bad usage or config, and 2 when
an obligation, API call, or inventory check failed. The code lives in
cmd/baton (cobra) and pkg/{config,source,probe,reconcile,plan,writer},
with the GitHub adapter (release-sdk plus go-github) in
internal/github. Build, lint, and test run through mage.
New dependencies must be actively maintained, checked with Scorecard's
Maintained check before they're added. Two build-time dependencies
score 0 there: magefile/mage (result of 2026-10-19) and
uwu-tools/magex (its latest published result, 2026-01-27). Neither ships in the baton binary. They stay
because mage is the SIG Release convention; magex is maintained by
uwu-tools itself.
- Detect and surface. baton evaluates, plans, and writes issues
(
baton apply), and the workflow templates run it on a schedule and validatebaton.ymlin PRs. It writes only with--redirect-owner, which sends every issue to a sandbox owner without assignees. Remaining: a project's baton repo adopting the templates, and lifting the write block. - Remediate. Each target repo exposes a
workflow_dispatchthat takesbaton_id,upstream_repository,upstream_version, andupstream_revision. baton calls it to open the bump PR, cut the release, or request the deploy. The target workflow owns its credentials and approvals; production deploys stay behind the target's protected environment. Addworkflow_conclusionprobes and theready,running, andfailedstates. - Broaden. Add contract triggers (API or schema version changes) for components such as a web app or monitor, and deploy-triggered rollouts for API consumers. Report lag metrics (release to bump, bump to release, release to deploy); the replay mode already computes them.
- Probes cover what can be checked mechanically. A step like "announce the deprecation" needs a human-closed issue as its probe.
- Replay reads current repos at past commits. Deleted releases and
force-pushed history can't be replayed, and live probes report
unknown. - Tags have no publish time, so baton dates them by commit time.
- Grace periods are policy. The file makes them explicit but doesn't choose them.