Reporting a Vulnerability
Please report security issues privately via GitHub’s “Report a vulnerability” form rather than opening a public issue. We’ll acknowledge within 5 business days.
Scope
repo-policy requires a GitHub token with repo (or fine-grained administration:write)
permissions to manage branch protection and rulesets. Treat that token with the same care as any
credential capable of changing repository security settings. The tool never stores the token —
it is read once per invocation from --token, GITHUB_TOKEN, GH_TOKEN, or, as a local-only
last resort, gh auth token (see “Authentication” below).
In GitHub Actions, this must be a real PAT stored as a repository secret — never the
automatically-generated secrets.GITHUB_TOKEN. Confirmed against a real workflow run:
GITHUB_TOKEN’s permission scopes don’t include repository administration under any
permissions: configuration, so it always fails with 403 Resource not accessible by integration on branch protection/ruleset endpoints, regardless of what the workflow grants it.
Threat Model
| Actor | Attack Vector | Impact | Mitigation |
|---|---|---|---|
| Attacker with repo write access but not admin | Modify policy.yml to weaken branch protection (e.g. drop required approvals), merge it, wait for apply to run in CI | Branch protection silently weakened | Require review on changes to policy.yml itself via branch protection on the repo that runs repo-policy — repo-policy cannot protect its own config file from a compromised reviewer |
| Attacker who obtains the CI-stored PAT | Full read/write on whatever the PAT’s scope covers — not limited to what policy.yml declares | Arbitrary branch protection/ruleset changes, or broader if the PAT has full repo scope | Scope the PAT as narrowly as GitHub allows (fine-grained PAT, Administration permission only, single-repository access); rotate it; never log it (repo-policy never prints the token) |
Malicious policy.yml in a fork’s PR, run via pull_request_target | Attacker-controlled config gets a privileged token via workflow misconfiguration | Same blast radius as PAT compromise above | Never run repo-policy’s apply mode on pull_request_target against untrusted input; audit/plan read-only are lower risk but still exercise real API calls with the token |
Naming collision: something else creates a ruleset named repo-policy:<branch> | strict mode’s prune logic would treat it as repo-policy-owned and could delete it | Loss of an unrelated ruleset | The naming convention is a documented hard constraint (see docs/adrs/0004-*) — don’t create rulesets with that prefix outside repo-policy |
Attacker who obtains RELEASE_BOT_TOKEN or RELEASE_BOT_SIGNING_KEY (Task 10) | Open/merge an arbitrary release PR as the release-bot, or forge a signature that verifies as the bot’s identity | A malicious, signature-”verified” release published under the bot’s name | Both live only as secrets scoped to the protected release GitHub Environment (required-reviewer approval, not a plain repository secret) — see “Release Signing” below; RELEASE_BOT_TOKEN is a classic PAT (not fine-grained — see “Secrets Management” below for why) scoped to public_repo only, the narrowest classic scope GitHub offers, issued from the shipsolid-release-bot account rather than the human owner’s; public_repo is coarser than a fine-grained PAT’s separately-toggled permissions would have been, but still never full repo scope, and the bot’s own collaborator access is Write, not Admin, so it can’t touch branch protection/ruleset settings even with the token in hand |
A consumer pins their workflow to the floating @v0 Action tag | A compromised or buggy release becomes live in every consuming workflow the moment it’s published, with no corresponding diff in the consumer’s own repository to review or hold back | Unreviewed supply-chain exposure on every release, for any consumer who chose the moving tag | Documented as a deliberate trade-off, not hidden: README’s GitHub Action section leads with the immutable full-commit-SHA form and calls out @v0 explicitly as movable and unsuitable wherever change control requires a pinned dependency — the same full-SHA-pinning convention this repository’s own workflows follow for every third-party Action they consume (every third-party uses: in .github/workflows/*.yml is pinned to a full commit SHA, not a tag — same-repository references like ci.yml’s own reusable-workflow call are a separate case; see docs/ci-cd.md). Consumers who need the convenience of automatic updates accept this exposure knowingly, as a choice, not a documentation gap |
Authentication
repo-policy authenticates to the GitHub REST API with a single bearer token, resolved in order
from --token, GITHUB_TOKEN, GH_TOKEN, then — as a last resort, local-CLI-only convenience —
gh auth token (cli._resolve_token/cli._gh_cli_token). There is no OAuth flow, no session,
and no credential caching — the token lives only in the process’s memory for the duration of one
invocation, regardless of which of the four sources it came from.
The fourth source is a new trust boundary worth naming explicitly: it shells out to whatever
binary named gh is first on PATH and trusts its stdout as a credential. This is only ever
reached when none of the first three sources are set (an explicit --token/env var always wins),
and it’s the exact same mechanism any other tool that shells out to gh auth token relies on —
but a PATH an attacker can influence (e.g. a compromised dev shell, a poisoned CI runner image)
could substitute a malicious gh and have repo-policy pick up a token of the attacker’s choosing.
GitHub Actions workflows are unaffected in practice: they never have an interactive gh auth login session to reuse, so this path is never reached there — Actions usage already requires an
explicit GITHUB_TOKEN/GH_TOKEN/--token regardless (see “In GitHub Actions” above).
Network Path: Proxy Support
repo-policy’s httpx.Client (github_client.py) is constructed with httpx’s own defaults —
trust_env is not overridden — so it honors the standard HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/
NO_PROXY environment variables exactly like any other well-behaved httpx/requests-based tool;
socks5:///socks5h:// proxy URLs also work, since httpx[socks] ships as a hard dependency (see
docs/troubleshooting.md for the corresponding config-error behavior when SOCKS support is
missing from a mirrored install). This means every request — including the Authorization: Bearer <token> header — is routed through whatever proxy your environment configures, the same as any
other HTTPS client running in that environment: an ordinary forward proxy using CONNECT tunneling
never sees inside the TLS session (the token stays opaque to it), but an organization that
terminates/inspects TLS at its proxy (a corporate MITM proxy with an injected root CA) can observe
everything a normal HTTPS request carries, including this token. This is standard behavior for any
HTTPS client, not something specific to how repo-policy handles the token — but it’s worth stating
explicitly here since it changes where the token is actually exposed in a given network topology,
which is relevant to the token-scoping guidance above.
Authorization
repo-policy performs no authorization of its own; it relies entirely on GitHub’s own permission model for the token it’s given. Whatever the token can do, repo-policy can do — it does not restrict itself to a subset. This is why token scoping (above) is the primary control.
Secrets Management
- The token is never written to disk, logged, or included in any error message.
policy.ymlis not an appropriate place to store secrets and repo-policy never expects one there — it contains only declarative policy, no credentials.- In CI, store the PAT as an encrypted repository (or organization) secret; never as a plaintext workflow env default or a committed file.
- A second, narrower-scoped PAT (
REPO_POLICY_E2E_TOKEN) exists for the automated E2E suite (tests/e2e/,.github/workflows/e2e.yml): fine-grained,Administration: Read and write, restricted to the single disposable fixture repo (shipsolid/repo-policy-e2e-fixture). Its blast radius is bounded to that one repo — a concrete instance of the “scope the PAT as narrowly as GitHub allows” mitigation already listed in the Threat Model below, not a new category of risk. - A third PAT,
POLICY_AUDIT_TOKEN, is expected by.github/workflows/policy-audit.yml: this repository’s own self-audit (see README’s “Self-governance”). Narrower still — fine-grained,Administration: Readonly (notRead and write), restricted to this single repository (shipsolid/repo-policy) — because that workflow only ever runsaudit, neverapply, so it has no legitimate need for write access at all. This secret is provisioned. Deliberately kept as a fine-grained PAT rather than a broader classicrepo-scoped one, even though a classic PAT would additionally exposedelete_branch_on_merge/allow_update_branch(see Known Limitations) — a classic PAT can’t be restricted to this one repository, so the trade would be a materially larger blast radius for two flat repo-setting fields this workflow already reports honestly as unavailable rather than silently guessing at. - A fourth PAT,
RELEASE_BOT_TOKEN(Task 10), belongs to the dedicatedshipsolid-release-botidentity and is what.github/workflows/release.yml’sreleasejob uses to push its version-bump branch, open and squash-merge the release pull request, push the signed release tag, and create the GitHub release — see “Release Signing” below for the full design. Classic PAT, not fine-grained: fine-grained PATs can only be issued by an account that is the repository’s owner or an org member with access, andshipsolid-release-botis a plain outside collaborator onshipsolid/repo-policy— a personal-account-owned repository, with no org membership concept to grant it through — so it structurally cannot create a fine-grained PAT scoped to this repo at all, confirmed against GitHub’s own fine-grained-PAT documentation. Scoped topublic_repoonly (this repository is currently public) — the narrowest classic scope GitHub offers; classic scopes are coarser than fine-grained’s separately-toggled repository permissions, so this is broader than the originally-specifiedContents: Read and write+Pull requests: Read and writewould have been, but still far short of fullreposcope, and the bot’s own collaborator access remains Write, not Admin, so branch protection/ruleset settings stay out of reach regardless of what the token itself can technically call.release.ymlretries a plaingh pr mergeon an interval rather than polling check-run status directly — this is a deliberate design choice (a classic PAT like this one CAN call the Checks API, so it isn’t a workaround for a permission gap), which happens to also sidestep a real, separate constraint should a future migration back to fine-grained ever become possible: fine-grained PATs currently cannot call the Checks API at all (confirmed against GitHub’s own fine-grained-PAT permissions reference — there is no selectable “Checks” repository permission). Relying instead on GitHub’s own server-side mergeability evaluation (which checksrequiredusing the repository’s branch-protection state, not the caller’s token scope) is simply a simpler dependency — seedocs/ci-cd.mdfor the full design reasoning. Stored as a secret on the protectedreleaseGitHub Environment, not as a repository secret, so it’s only materialized on the runner after a human approves that environment’s required-reviewer gate. Provisioned (issued from theshipsolid-release-botaccount, stored as arelease-environment secret); see the setup checklist below for what else still needs confirming before the first live release. RELEASE_BOT_SIGNING_KEY(Task 10) — the release-bot’s SSH private signing key, also stored as arelease-environment secret (same approval gate asRELEASE_BOT_TOKENabove), used byrelease.ymlto produce a signed commit and a signed, annotated release tag. The corresponding public key isRELEASE_BOT_SSH_PUBLIC_KEY, a plain (non-secret) repository variable — public keys don’t need encryption, and keeping it as a variable rather than a secret makes it visible in the Actions UI for anyone auditing what key the pipeline currently trusts. Both are now provisioned, and the public key is registered on the bot’s GitHub account as a Signing Key; see the setup checklist below for what else still needs confirming.
Vulnerability Management
Scan cadence
| Check | Tool | Runs | Scope |
|---|---|---|---|
| Dependency vulnerabilities | pip-audit | Every PR, every push to main, weekly (.github/workflows/security.yml) | This project’s own dependencies (pyproject.toml), the Docker Action’s locked, hash-pinned dependency set (requirements-action.txt), and the release pipeline’s locked, hash-pinned dependency set (requirements-release.txt) |
| Static code analysis | CodeQL (python, actions) | Every PR, every push to main, weekly | src/, tests/, and .github/workflows/*.yml |
| Workflow YAML security | zizmor --pedantic | Every PR, every push to main (ci.yml’s security job, PR-blocking), weekly again (security.yml, advisory) | .github/workflows/*.yml |
| Container image vulnerabilities | Trivy (Task 7) | Every PR, every push to main (ci.yml’s docker job) | The Docker Action image, CRITICAL blocking / CRITICAL,HIGH reported |
| Dependency update proposals | Dependabot (.github/dependabot.yml) | Weekly, grouped per ecosystem (pip, github-actions, docker), with a 7-day cooldown before a newly-published version is proposed | Every dependency this project or its Docker image declares |
| Repository-policy compliance | repo-policy audit | Daily, plus push to main touching the policy file or itself, plus manual dispatch (.github/workflows/policy-audit.yml, read-only) | This repository’s own live branch protection / security settings vs. .github/repository-policy.yml (Task 9 dogfooding, see README’s “Self-governance”) |
security.yml’s pip-audit/zizmor/CodeQL jobs are advisory, not PR-blocking — they are not part of
ci.yml’s required check. ci.yml’s own narrower security job (zizmor over workflow YAML
only, --min-severity high) remains the one PR-blocking security gate, unchanged by this. See
docs/ci-cd.md for the full pipeline architecture and the reasoning behind that split.
Severity response times
These are target response times for a finding surfaced by any of the scans above, not a contractual SLA:
| Severity | Response |
|---|---|
| Critical / High | Triaged within 5 business days; fixed or explicitly risk-accepted (with reasoning recorded here or in the relevant PR) before the next release |
| Medium | Triaged within 2 weeks; fixed opportunistically, typically bundled with the next fix:/chore: commit touching the same area |
| Low / Informational | Reviewed on a best-effort basis; commonly tolerated as documented, known findings (see docs/ci-cd.md’s zizmor discussion) rather than fixed individually |
Dependency-update policy
- Dependency upper bounds are pinned in
pyproject.tomlto bound the blast radius of an unreviewed transitive upgrade; Dependabot’s weekly, grouped PRs are the mechanism that actually proposes moving those pins forward, rather than upper bounds alone going stale indefinitely. - The 7-day cooldown (
dependabot.yml’scooldown.default-days) means a newly-published package version isn’t proposed the same day it lands on PyPI/the Actions marketplace/Docker Hub — a deliberate window against a compromised-release supply-chain attack landing in a merged PR before it’s been caught and yanked upstream. - A Dependabot PR against
requirements-action.txtspecifically may fail CI’sverify-action-lockcheck even when the underlying version bump is legitimate, because that file is auv --generate-hasheslockfile, not one Dependabot’s pip-compile-aware update path recognizes (seedependabot.yml’s own comment). Regenerate it by hand with the commandverify-action-lock.sh’s failure output prints; don’t merge Dependabot’s version of that file directly.
Verifying release artifacts
Starting with the first release cut after this SBOM/attestation tooling merged (check
CHANGELOG.md for the earliest entry mentioning SBOMs, or look for a release whose assets include
sbom-wheel.cdx.json/sbom-docker.cdx.json — as of this writing that tooling has not yet shipped
in a release; the current in-development version is 0.4.7), every release publishes, alongside
the PyPI package: a CycloneDX SBOM for the wheel’s install environment and one for the Docker
Action image (both attached to the GitHub release), plus GitHub artifact attestations
(Sigstore-backed build provenance) for the wheel, sdist, both SBOMs, and the Docker Action image
(by digest). To verify a downloaded artifact’s provenance:
# Verify the wheel/sdist/SBOM attestations (requires the GitHub CLI, `gh`, and repo read access)
gh attestation verify dist/repo_policy-<version>-py3-none-any.whl --owner shipsolid
gh attestation verify dist/repo_policy-<version>.tar.gz --owner shipsolid
gh attestation verify sbom-wheel.cdx.json --owner shipsolid
gh attestation verify sbom-docker.cdx.json --owner shipsolid
The SBOMs themselves (sbom-wheel.cdx.json, sbom-docker.cdx.json) are downloadable from each
GitHub release’s assets and are valid CycloneDX 1.6 JSON — validate structurally with any
CycloneDX-compliant tool, e.g. cyclonedx-py’s own --validate (on by default) or
cyclonedx-cli validate --input-file sbom-wheel.cdx.json.
Known limitation — the Docker Action image’s attestation cannot currently be verified with a
single command, and its digest is not reproducible across rebuilds. gh attestation verify
requires either a local file path or a registry-resolvable oci:// reference to recompute the
subject’s digest and compare it against the signed record (confirmed against gh’s own
documentation). This repository never pushes the Docker Action image to a registry — action.yml
builds it fresh from the pinned Dockerfile at consumption time (see Task 7) — so there is no
oci:// reference to verify against.
It’s also not enough to just rebuild locally and compare digests: the image’s digest is not
reproducible across independent builds, confirmed by building this exact commit twice
(docker build --no-cache) and comparing docker inspect --format='{{.Id}}' output — the two
builds produced different image IDs, with different RootFS.Layers digests on every layer that
touches src/, requirements-action.txt, or either pip install step. Only the base-image layers
(digest-pinned in the Dockerfile) matched. This is consistent with what Task 7’s own CI check
(ci.yml’s docker job) actually verifies: it diffs package and OS inventories (pip list --format=freeze, dpkg -l) between two clean builds, not image digests — Task 7 established
“the same packages, at the same versions, every time,” not “byte-identical image layers.” Treating
the two as equivalent (an earlier draft of this document did) is wrong and would send anyone who
tried to verify a release’s image digest straight into a false “tampering” conclusion.
What you can verify for a specific release’s Docker image, without trusting anything blindly:
- The attestation is a real, auditable record. It’s visible under the repository’s
Attestations tab on GitHub, and fetchable directly by digest via
GET /repos/shipsolid/repo-policy/attestations/<digest>— it proves some GitHub Actions run in this repository, at this commit, produced an image with that exact digest, signed via Sigstore. Treat it as an audit trail, not as something you locally re-derive. - The SBOM’s component list is reproducible even though the image digest isn’t. Rebuild the
image from the same release tag’s
Dockerfile, extract its package list (docker run --rm --entrypoint pip <image> list --format=freeze), and diff it against the release’ssbom-docker.cdx.jsoncomponents — the packages and versions installed are pinned byrequirements-action.txt’s hashes and are what should match, not the image digest. - Turning digest-level verification into a real one-command check would require either making the
build byte-for-byte reproducible (e.g.
SOURCE_DATE_EPOCHpinned to the commit timestamp plus auditing every remaining source of build-time nondeterminism, then proving it with repeated--no-cachebuilds) or publishing the image to a registry (ghcr.io/Docker Hub) sogh attestation verify oci://...has something to resolve against. Both are real scope beyond this SBOM/attestation-plumbing task and are open follow-up items, not done here.
Release Signing
Task 10 added a dedicated release identity (shipsolid-release-bot) and SSH-based commit/tag
signing to .github/workflows/release.yml. See docs/ci-cd.md’s “Release-bot identity,
commit/tag signing, and the PR-merge redesign” for the full research trail and design reasoning
(why the release tag, not the commit that lands on main, is the signed and verified artifact —
a hard GitHub platform constraint, not a design shortcut). This section covers what to do when
something about that signing setup needs to change.
What’s signed, and how to check it yourself
Every release tag (v{version}) is an annotated tag, SSH-signed by the release-bot’s key, pushed
directly (tags are outside branch protection’s scope). release.yml verifies it itself with
git verify-tag immediately before the floating major tag moves, the GitHub release is created, or
anything is published to PyPI — a failure there stops the whole pipeline before any of that happens.
To verify it yourself, from a checkout with the bot’s public key registered as a trusted signer:
# One-time, per machine: register the bot's public key as a trusted signer for its committer email
# (the same public key that should be registered on the bot's GitHub account as a Signing Key —
# see RELEASE_BOT_SSH_PUBLIC_KEY in "Secrets Management" above).
echo "amitsingh007s+repopolicybot@gmail.com <the bot's SSH public key>" >> ~/.ssh/allowed_signers
git config gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
git fetch --tags origin
git verify-tag v<version>
GitHub also shows its own “Verified” badge on the tag/release page once the bot’s public key is
registered there as a Signing Key — that’s the independent, third-party confirmation that the
signature really does belong to the shipsolid-release-bot account, not just that some SSH
signature validates locally. The commit the tag points at on main will also show as Verified —
GitHub signs commits made through its web interface/API (which is what a squash-merge is) with its
own key and shows them Verified (see https://github.com/web-flow.gpg and GitHub’s own
commit-signature-verification docs); that’s a different Verified signature from the tag’s, though:
GitHub’s own, attesting “GitHub performed this merge,” not the release-bot’s, attesting “the
release-bot produced this release.” The “no verification” behavior documented in docs/ci-cd.md is
specific to rebase-merge, not squash — GitHub’s docs state rebase-merge replays commits without
commit signature verification because GitHub never actually authors the resulting commit and so
can’t sign it; squash-merge is not that case. Both signatures are real and independently meaningful,
which is a stronger story than “the commit shows nothing”: the tag remains this pipeline’s
release-bot-attributed verified artifact for the reason above (there is still no way to get the
release-bot’s own signature onto the merge-synthesized commit object), not because the commit itself
goes unverified.
Key rotation
Rotate the release-bot’s SSH signing key on a routine schedule (annually, at minimum) or immediately after any suspected exposure:
- Generate a new SSH key pair (
ssh-keygen -t ed25519 -C "shipsolid-release-bot release signing", generated locally by whoever holdsrelease-environment admin access — never inside a workflow run, and never sent anywhere the private half could be logged). - Add the new public key to the bot’s GitHub account (Settings → SSH and GPG keys → New SSH key → key type Signing Key) alongside the old one, not replacing it yet — GitHub will verify a tag against whichever registered key actually signed it, so both can be valid simultaneously during the rotation window.
- Update the
release-environment secretRELEASE_BOT_SIGNING_KEYwith the new private key, and the repository variableRELEASE_BOT_SSH_PUBLIC_KEYwith the new public key. - Confirm the next release verifies correctly (
git verify-tag, and the GitHub “Verified” badge) against the new key. - Remove the old public key from the bot’s GitHub account. Past releases signed with it remain verifiable against GitHub’s historical record of what was registered when they were signed — removing it going forward does not retroactively invalidate already-published releases.
Key revocation (suspected compromise)
If the private signing key or RELEASE_BOT_TOKEN is suspected compromised, treat it as an incident,
not a routine rotation:
- Immediately remove the signing key from the bot’s GitHub account (Settings → SSH and GPG
keys → delete) and revoke/regenerate
RELEASE_BOT_TOKEN(GitHub → Developer settings → Personal access tokens → Tokens (classic) — this is a classic PAT, not a fine-grained one, see “Repository access and credentials” above — → regenerate or delete) from the bot’s account. This stops the key from producing any further “Verified” releases and stops the PAT from opening/merging any further PRs, immediately. - Delete both from the
releaseGitHub Environment’s secrets so a queued or in-flight workflow run can’t pick up the now-revoked credential. - Audit recent releases (
git log --show-signatureon release tags, or the GitHub UI’s Verified badges) for anything signed with the compromised key that wasn’t a legitimate release from this pipeline. Treat any unexplained signed tag as a confirmed compromise, not a false positive. - Follow the “Key rotation” steps above to provision a replacement identity before the next release
is attempted — until then,
release.ymlwill fail at thegit verify-tagstep (no valid key configured), which is the correct fail-closed behavior, not a bug to work around. - Record what happened, when the credential was live, and what (if anything) it was used for illegitimately — same audit-trail expectation as “Emergency Recovery” below.
Release-environment recovery
If the release GitHub Environment itself is misconfigured, deleted, or needs to be rebuilt (e.g.
the required-reviewer list needs to change, or the environment was accidentally removed):
- Re-create the environment (Settings → Environments →
release) with a required-reviewer protection rule — see the setup checklist below for the exact configuration. - Re-add
RELEASE_BOT_TOKENandRELEASE_BOT_SIGNING_KEYas environment secrets (not repository secrets — a repository secret bypasses the approval gate entirely, which defeats the point). - Until the environment exists again,
release.yml’sreleasejob simply cannot start — GitHub blocks a job from running against a namedenvironment:that doesn’t exist on the repository. This is a safe failure mode (no release runs at all) rather than an unsafe one (a release running without the approval gate).
Removing an unverified release before PyPI publication
release.yml is already structured so this should never be reachable in normal operation —
git verify-tag runs before the floating tag moves, before the GitHub release is created, and
before publish/sbom/release-assets run at all, so an unverified tag stops the pipeline before
anything ships. If it’s ever necessary to undo a release that slipped through anyway (e.g. a manual
git push of an unsigned tag outside this pipeline, or a workflow bug found after the fact):
- Do not publish to PyPI if that step hasn’t run yet — PyPI never allows removing or reusing a version number once it’s live, so preventing the publish is far cheaper than remediating after.
- Delete the GitHub release (Releases page → the release → Delete) and the tag itself
(
git push origin :refs/tags/v<version>— deletes the remote tag; also delete it locally withgit tag -d v<version>). Deleting a tag is not blocked by branch protection (tags are outside its scope, as established above) but is a destructive, unrecoverable action against the published ref — confirm with whoever else relies on this repository’s tags before doing it against anything that might already be in use. - If the floating major tag (
v0) was already moved to point at the bad release, move it back:git tag -f v0 <last-good-tag> && git push origin v0 --force. - If PyPI publication already happened before the problem was caught, this is no longer a “remove
an unverified release” scenario — follow
docs/ci-cd.md’s “Rollback” section instead (yank on PyPI, fix forward).
Security Baseline
- Dependencies pinned with upper bounds (
pyproject.toml). - PyPI publishing uses trusted publishing (OIDC) — no long-lived PyPI API token stored anywhere.
- No secrets, tokens, or credentials are ever persisted by repo-policy itself.
- Automated dependency-vulnerability scanning (
pip-audit), static analysis (CodeQL), and workflow security linting (zizmor) run on every PR, every push tomain, and weekly (.github/workflows/security.yml); Dependabot proposes grouped, weekly dependency updates with a 7-day cooldown (.github/dependabot.yml). See “Vulnerability Management” above. - Every release publishes CycloneDX SBOMs (wheel + Docker Action image) and GitHub artifact attestations (Sigstore-backed build provenance) for the wheel, sdist, both SBOMs, and the Docker image. See “Verifying release artifacts” above.
- Every release tag is SSH-signed by a dedicated release-bot identity and verified
(
git verify-tag) before the floating major tag moves, the GitHub release is created, or anything is published to PyPI — publication fails closed if that verification doesn’t pass. See “Release Signing” above. Live sincev0.4.9— independently re-confirmed by directly verifyingv0.4.9andv0.5.0’s tag signatures against the publishedRELEASE_BOT_SSH_PUBLIC_KEYrepository variable.
Repository Settings Declared in Policy, Confirmed Live
The following GitHub repository-settings toggles (Settings → Code security) are declared in
.github/repository-policy.yml’s repo_settings block
(secret_scanning: true, secret_scanning_push_protection: true) and are live on
shipsolid/repo-policy — confirmed directly via gh api repos/shipsolid/repo-policy, which reports
security_and_analysis.secret_scanning.status: "enabled" and
secret_scanning_push_protection.status: "enabled":
- Secret scanning — flags secrets matching known provider patterns that get committed to the repository.
- Push protection — enabled after secret scanning is on. Blocks a
git pushcontaining a detected secret before it ever lands in the repository’s history, rather than only flagging it after the fact.
Both are available on public repositories at no cost, and on private repositories with GitHub
Advanced Security. repo-policy manages both through the REST API’s PATCH /repos/{owner}/{repo}
security_and_analysis field (see github_client.py’s update_security_and_analysis) — the same
mechanism it uses for every other declared repo_settings toggle.
Release Pipeline Setup Checklist (Task 10)
.github/workflows/release.yml and pyproject.toml are already written for the design described in
“Release Signing” above and in docs/ci-cd.md. Items 2–7 below are now confirmed provisioned or
live; only item 1’s email-verification sub-bullet and item 8 remain not independently confirmable
through this repository’s own API surface (see each item below for why). This is the exhaustive list
of what needs to be true before the first live release under this design; everything below is a
live-repo state check or admin action outside what a code change can do on its own (same pattern as
POLICY_AUDIT_TOKEN above).
- The
shipsolid-release-botGitHub account (already created per the dispatch that produced this design):- Add
amitsingh007s+repopolicybot@gmail.comas a verified email on the account — SSH signature verification checks the signing commit/tag’s committer email against a verified email on the account that owns the registered signing key, so an unverified email means every release shows as unverified on GitHub even with a technically-valid signature. Strong indirect confirmation, not independently verifiable via API:git verify-tagonv0.4.9andv0.5.0both return a valid “Good signature,” and GitHub’s own web UI has shown both tags asVerified— but another account’s email-verification status isn’t exposed through this repository’s API surface, so first-hand confirmation still requires whoever administers theshipsolid-release-botaccount (GitHub Settings → Emails). - Add the bot’s SSH public key under Settings → SSH and GPG keys → New SSH key, with key type set to Signing Key (not “Authentication Key” — the two are registered separately on GitHub and only a Signing Key is checked against commit/tag signatures). Done — the bot’s public key is registered as a Signing Key on its account.
- Confirm the bot’s collaborator permission level on
shipsolid/repo-policyis Write, not Admin. Write is sufficient —RELEASE_BOT_TOKENonly needs to push branches, open PRs, and merge PRs that already satisfy branch protection’s requirements (approvals: 0,requiredgreen); it never touches branch protection/ruleset settings itself, so Admin would be unnecessary standing privilege on a repository that specifically avoids granting exactly that kind of unnecessary standing privilege (see this file’s Threat Model). Done — confirmed live (gh api repos/shipsolid/repo-policy/collaborators/shipsolid-release-bot/permission→"write").
- Add
RELEASE_BOT_TOKEN— a classic personal access token, not fine-grained, issued from the bot’s own account (not the human owner’s), scoped topublic_repoonly (this repository is currently public). Classic, not fine-grained, because fine-grained PATs can only be issued by an account that owns the target repository or belongs to the org that does;shipsolid-release-botis a plain outside collaborator onshipsolid/repo-policy, a personal-account-owned repository with no org membership path around that restriction, so a fine-grained PAT scoped to this repo is not something the bot’s account can create at all — confirmed against GitHub’s own fine-grained- PAT documentation.public_repois the narrowest classic scope available; it’s coarser than the originally-specified fine-grainedContents: Read and write+Pull requests: Read and writewould have been, but the bot’s Write-only collaborator access (item 1 above) still keeps branch protection/ruleset settings out of reach regardless. Done — stored as a secret on thereleaseGitHub Environment, not a repository or organization secret.RELEASE_BOT_SIGNING_KEY— the bot’s SSH private signing key (the one whose public half was added to the bot’s account in step 1), generated locally by whoever administers this — never pasted into a workflow run, an issue, or a chat transcript. Done — stored as a secret on thereleaseGitHub Environment.RELEASE_BOT_SSH_PUBLIC_KEY— the corresponding SSH public key, same value as registered on the bot’s GitHub account in step 1. Done — stored as a plain repository variable (Settings → Secrets and variables → Actions → Variables), not a secret — it’s not sensitive, and keeping it as a variable makes it visible in the Actions UI for anyone auditing which key the pipeline currently trusts.- The
releaseGitHub Environment (Settings → Environments, named exactlyreleaseto matchenvironment: releaseinrelease.yml) — must already exist, since items 2–3 above are stored as secrets scoped to it:- Add a required reviewers protection rule naming the repository owner (or whoever should
approve releases) — this is the human-in-the-loop gate from brief Step 2; every real release
pauses here for a manual approval click before the
releasejob’s first step runs. Done — confirmed live (gh api repos/shipsolid/repo-policy/environmentsshows thereleaseenvironment with arequired_reviewersprotection rule already configured). - Recommended, not required: restrict the environment’s allowed deployment branches to
main—release.yml’s only trigger is alreadypush: branches: [main], so this is defense in depth, not a functional requirement.
- Add a required reviewers protection rule naming the repository owner (or whoever should
approve releases) — this is the human-in-the-loop gate from brief Step 2; every real release
pauses here for a manual approval click before the
- “Allow squash merging” must be enabled in
shipsolid/repo-policy’s repository settings (Settings → General → Pull Requests).gh pr merge --squashhard-fails if it isn’t, and nothing in.github/repository-policy.ymldeclares or detects this setting (it’s a merge-method toggle, not somethingrepo-policymodels), so its absence wouldn’t surface as a clear error — it would show up only as the merge-retry loop’s 30-minute timeout, with a “not mergeable” message that looks identical to “required hasn’t finished yet.” Done — confirmed live (gh api repos/shipsolid/repo-policy --jq '.allow_squash_merge'→true). - Confirm no out-of-band tag-protection rule exists for
refs/tags/*on the live repository (Settings → Tags, Settings → Rules) that could block the release-bot’s direct tag push. This repo’s own self-policy declares neither a classic tag-protection rule nor a tag-scoped Ruleset (see docs/ci-cd.md’s “Tag protection vs. branch protection” research), but that only covers whatrepo-policyitself manages — it can’t rule out something added by hand outsiderepo-policy. Done — confirmed live (gh api repos/shipsolid/repo-policy/tags/protection→404, no rule). - Confirm
requiredactually reports as that exact status-check context on a real bot-authored PR before relying on it for the first live release. This design’s merge-retry loop (seerelease.yml’s “Wait for the PR’s required check and squash-merge it” step) entirely depends on GitHub evaluating mergeability against that exact context name; if the release-bot’s PR ever produces a differently-named or missing check for any reason, every release attempt will time out at 30 minutes with a misleading “not mergeable yet” message rather than a clear “wrong check name” error. Demonstrated in practice, not re-verified via a freshgh apicheck this session: thev0.4.9andv0.5.0releases both completed through this exact merge-retry loop (seedocs/release-readiness-v1.md’s Step 9), so therequiredcontext name has been confirmed correct at least twice in production.
Items 2–7 above are now confirmed provisioned or live. The one item that can’t be independently
confirmed through this repository’s own API surface is item 1’s email-verification sub-bullet —
whoever administers the shipsolid-release-bot account can close that out directly (GitHub
Settings → Emails). If it, or item 8’s required context-name assumption, were ever not true,
release.yml’s release job would either fail to start, fail on first use of a missing/
misconfigured piece, or — for item 8 — run for the full 30-minute merge-retry window before failing
with a timeout message that doesn’t point at the real cause. Both are safe failure modes (no release
ships), just not always a fast one.
Emergency Recovery
.github/repository-policy.yml declares main with enforce_admins: true — nobody, including
the repository owner, is exempted from requiring a passing required status check to merge —
and clear_restrictions: true, which resets any push-restriction allowlist GitHub might already
hold for the branch. That second field is a narrower guarantee than “no bypass actors”: it does
not touch bypass_pull_request_allowances, a separate GitHub setting that lets specific actors
skip required PR-approval counts, which repo-policy reads through from whatever is already live
on GitHub rather than clearing (src/repo_policy/policies/pull_requests.py) — a human-set
allowance there would silently survive every apply. This combination is deliberate (see Threat
Model above), but it creates one failure mode this policy cannot resolve on its own: if
required itself becomes permanently unable to pass — a broken step in ci.yml, an
expired/revoked pinned Action, a GitHub Actions outage — no PR can merge, including the PR that
would fix the breakage.
There is no policy field for “allow a bypass under condition X”; recovering from this is a manual,
audited, time-boxed repository-settings change, not something repo-policy itself performs:
- Confirm the required check is actually broken, not just failing correctly on real
problems — re-run the
requiredjob and read its logs before touching branch protection. - Temporarily relax the specific setting blocking the fix, via the GitHub UI (Settings →
Branches → the
mainprotection rule) or the REST API’s branch-protection endpoint — e.g. unchecking “Require status checks to pass” or “Include administrators” just long enough to merge the one PR that repairsrequired. Change the minimum needed, not the whole rule. - Merge the fix, confirm
requiredpasses again onmainfrom a fresh run (not the bypassed one). - Restore full protection immediately — re-enable whatever was relaxed in step 2. Don’t wait
for
policy-audit.yml’s next scheduled run to notice; confirm it yourself withrepo-policy audit --config .github/repository-policy.yml --repo shipsolid/repo-policy(0 = compliant again). - Open a follow-up issue the same day, recording: what broke and why, exactly what was temporarily relaxed and for how long, who performed the bypass, which PR/commit merged under it, and confirmation from step 4 that protection was restored. This is the audit trail for an event that, by definition, happened outside the normal PR-reviewed path.
Known Limitations
- No mTLS or certificate-based auth path — token-based auth only.
- No built-in secret scanning of
policy.yml— a user could technically put a secret in a custom field extension in the future; the current schema has no such field, so this is currently moot. - See the Threat Model above for the PAT-scope and
policy.yml-review gaps, which are organizational controls repo-policy cannot enforce on your behalf. bandit -q -r srcreports one Medium-severity finding,B506(yaml_load) atconfig.py:103, onyaml.load(raw_text, Loader=_StrictLoader). This is a documented false positive:_StrictLoader(config.py:17) is a subclass ofyaml.SafeLoader, notyaml.Loader— it only narrows two of SafeLoader’s own implicit-resolver surprises (the yes/no/on/off bool words, and octal/sexagesimal ints) and rejects duplicate mapping keys; it never adds a constructor capable of instantiating arbitrary Python objects, so it carries exactly the same safety guarantee asyaml.safe_load()itself. Bandit’sB506check flags anyyaml.load(..., Loader=...)call pattern regardless of which Loader class is actually passed, so it can’t distinguish this from a genuinely unsafeLoader=yaml.Loader. Not suppressed with# nosecin-source becausebanditisn’t wired into either CI workflow (seedocs/ci-cd.md’s “Security workflow” section for why CodeQL’s Python analysis is the automated static-analysis coverage instead) — there is no gate for a source-level suppression to silence, only this note for the next person who runsbanditlocally and sees the same finding.pip-audit -r requirements-release.txt --require-hashesreports one finding,PYSEC-2026-2132(click==8.1.8), suppressed in CI via--ignore-vuln PYSEC-2026-2132(.github/workflows/security.yml’s “Audit the release pipeline’s locked, hash-pinned dependency set” step). This is an accepted, upstream-blocked risk, not a false positive:click==8.1.8is genuinely vulnerable, butpython-semantic-release==9.21.2’s own wheel metadata declaresRequires-Dist: click~=8.1.0, which caps click below the8.3.3fix — this project cannot resolve a patched click intorequirements-release.txtwithout python-semantic-release itself relaxing that constraint. Revisit when python-semantic-release publishes a release that allows a patched click, or when this project moves offpython-semantic-release==9.21.2.policy-audit.yml’s scheduled run will permanently reportrepo settings: delete_branch_on_merge unavailable on this repositoryand the same forallow_update_branch, even though both are correctly set live (confirmed via TASK-002’s evidence). This is an accepted, structural limitation ofPOLICY_AUDIT_TOKEN’s deliberately narrow scope, not a bug and not drift:repo_settings.py’sdiff_flat_settingsdocuments (live-confirmed 2026-09-21) that GitHub omits both keys entirely from a fine-grained PAT’s response regardless of permission level — only a classic PAT exposes them. SwitchingPOLICY_AUDIT_TOKENto a classic PAT was evaluated (2026-09-22) and rejected: a classic PAT also needs its owning account to hold Admin on this repository to correctly read branch protection /security_and_analysis/ vulnerability-alert state at all — confirmed live by testing one issued fromshipsolid-release-bot(Write-only collaborator access), which readmainas entirely unprotected and Dependabot/secret-scanning as disabled, neither true. The only account with Admin here is the repository owner’s own, and issuing a broad, unrestrictable-to-one-repo classic PAT from that account into an unattended scheduled workflow was judged a worse trade than two permanently-unavailableflat fields.repo_settings.py:33’s fail-closed design means this keepspolicy-audit.ymlfrom ever reporting a fully cleanconclusion: "success"— a red run for exactly these two fields (and nothing else) is the expected, permanent steady state, not a signal to investigate.