Skip to content

feat(release): release on merge to the trunk, not on a tag - #11

Merged
pjhartlieb merged 9 commits into
mainfrom
feat/release-on-stable
Oct 27, 2026
Merged

pjhartlieb merged 9 commits into
mainfrom
feat/release-on-stable

Conversation

@singlerider

@singlerider singlerider commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Implements step 4 of the publishing policy hand-off (.github-config#16, policy in .github-config#17) in the shared workflows.

Change

  • release-check.yml: runs from stable only. Version from the merged manifest (manifest.py release), which accepts exact MAJOR.MINOR.PATCH only and refuses a Helm chart that disagrees and git or URL dependencies. release=false when the v<version> tag exists. Fails when the tag lookup errors, or when the version is already on PyPI, crates.io or Docker Hub (image input) with no tag. A credentials job in the release environment mints the App token before anything publishes, so a missing key fails first.
  • publish.yml: release environment. App ID and key are read from that environment (vars.BLS_RELEASE_APP_ID, secrets.BLS_RELEASE_PRIVATE_KEY). Callers pass nothing and never secrets: inherit. Image SBOMs are built outside the environment and attached in the one release job, which creates the tag and release last, on GITHUB_SHA.
  • crates.yml: release environment, trusted publishing (rust-lang/crates-io-auth-action), checks the crate whose manifest is in working-directory, skips a version already on crates.io.
  • docker.yml: release environment. DOCKER_USERNAME/DOCKER_TOKEN are declared and read from the environment, so callers pass none. The Hub description is pushed from the build job. No dev tag.
  • docker-dev.yml (new): on dev, pushes ghcr.io/<image>:dev and :dev-<sha> with the repository token and packages: write, outside the release environment.
  • stable-guard.yml (new): callers run it on pull requests into stable. release ready runs manifest.py release, so a version, chart or dependency that can't release fails review instead of the release run.
  • cla-reusable.yml and python-tests.yml declare the secrets they read (APP_ID, APP_PRIVATE_KEY, CODECOV_TOKEN, BBOT_IO_API_KEY), so callers pass them by name instead of secrets: inherit.
  • Removed manifest.py tag/caller and scripts/release.sh, which cut tags by hand.

Interface changes

  • release-check.yml outputs: version, tag, major, minor, release. prerelease and semver are gone. New image input.
  • publish.yml: release-app-id input and release-app-key secret removed.
  • Callers pass no App or Docker credentials, and no workflow needs secrets: inherit.

Validation

uv run --group dev pytest -q tests   29 passed
uv run --group dev actionlint        ok
uv run --group dev ruff check        ok
uv run --group dev ruff format --check   ok

Tag probe checked live: gh api -i repos/blacklanternsecurity/bbot/git/ref/tags/v3.0.2.177rc is 200, a missing tag is 404. Docker Hub v2/repositories/<image>/tags/<version> returns 200/404 the same way. The environment-scoped secrets and the GHCR push have not run yet: no release environment exists. Verify on the first cut-over.

Callers move in bbot#3355, baddns#949, asndb#39, radixtarget#96, blastdns#139, blasthttp#120 and webcap#132, all pinned to 07e14bc.

Refs blacklanternsecurity/.github-config#16

Implements the approved publishing policy (.github-config#17) in the
shared workflows:

- release-check runs on the trunk only, reads the version from the
  merged manifest, and reports release=false when that version already
  has a GitHub release, so a push that keeps the version publishes
  nothing. The new manifest.py release command refuses prerelease
  versions, a Helm chart that disagrees with the manifest, and git
  dependencies, so stable pins released versions.
- publish creates the tag and the GitHub release last, on the released
  commit, with a token from the release GitHub App, the only actor the
  release-tags-create ruleset lets create a v* tag.
- crates, docker and publish run in the release environment, which a
  bbot-leads member approves. crates.io uses trusted publishing and
  skips a version already published. docker drops the dev tag; dev
  images go to GHCR.
- The tag subcommand and release.sh, which cut tags by hand, are gone.
@singlerider singlerider self-assigned this Oct 21, 2026

@pjhartlieb pjhartlieb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: release on merge to the trunk

@singlerider, I reviewed this at 4894c4ad against main (0f515e02, which includes #8), checked against the step 4 spec on .github-config#16 (A1–A4 and the names table) and the publishing policy in .github-config#17. In a clone of the PR head, the 16 tests pass and actionlint is clean. I also ran manifest.py release against some hand-made manifests, checked the two new action pins against their tags, and read the seven caller PRs at their current heads, but only for how they call these workflows.

There's a lot here that's right, and some of it is stronger than the spec:

  • environment: release is hard-coded in docker.yml, crates.yml and both publish.yml jobs, where the spec sketched it as an input. That's the better choice. A caller can't pass environment: '' or some environment with no gate. Any job that reads a publish credential has to clear release's deployment-branch policy and reviewers, whatever workflow called it, and whatever branch it called from.
  • The repo token drops to contents: read in both publish jobs. Every github.ref_name is gone. The tag and the release are created together, on GITHUB_SHA, by the App token, and only after a gh release view check, so a re-run is a no-op. SBOMs and image SBOMs upload with --clobber under the same token.
  • The App token is minted with no owner/repositories, so it's scoped to the current repository, with permission-contents: write only.
  • crates.yml is done the way A4 asks: OIDC through crates-io-auth-action pinned to the v1.0.5 commit, id-token: write on the job, an existence check against the crates.io API, and no CARGO_REGISTRY_TOKEN fallback left.
  • Shared code is still checked out at job.workflow_sha. Inputs reach run: only through env:, never through ${{ }} interpolation. Docker publishes latest/stable/X.Y/X unconditionally, and has no dev tag.
  • manifest.py release refuses prereleases, chart mismatches and the common git-dependency forms, and has tests for each. Deleting release.sh fully undoes #8, which changed nothing else. Org code search finds no other callers of the removed pieces.

The blocking issue is how the App ID and key get into the workflow. The rest are smaller.

High

H1. With the App ID and key passed in from the caller, the key ends up at repo or org level, or never arrives.
publish.yml:5-8 takes release-app-id as an input, and :45-47 takes release-app-key as a workflow_call secret. Every caller PR passes them from the calling job: release-app-id: ${{ vars.RELEASE_APP_ID }} and release-app-key: ${{ secrets.RELEASE_APP_KEY }} (for example bbot#3355 publish.yml:63,68). That calling job has no environment, and it can't have one. So:

  • vars.RELEASE_APP_ID there reads repo and org variables only. The release environment's variable isn't visible to it.
  • For secrets, GitHub's documented route is that the reusable workflow's job sets environment:, and the secret is passed under the same name. release-app-key can't be that name, because secret names can't contain hyphens. Whether a secret renamed on the way in is resolved again inside the environment isn't documented, and I couldn't test it: no release environment exists yet. If it isn't, the key resolves to empty, and create-github-app-token fails in the last job, after Docker and PyPI have already published. The only reliable way to make the current wiring work is to store RELEASE_APP_KEY as a repo or org secret. The policy rules that out: every branch, dev included, can read a repo secret.
  • Independently of that, the names differ from the ones the spec fixed and that the cut-over plan and Terraform are built on: BLS_RELEASE_APP_ID (an environment variable set by Terraform in .github-config#1/#2) and BLS_RELEASE_PRIVATE_KEY (an environment secret, set by an owner at cut-over).

This isn't an equivalent design. Keeping it would mean changing the Terraform variable and the owners' cut-over steps, and would still leave the key-location problem.
Requested:

  • Drop the release-app-id input and the release-app-key secret.
  • In both jobs that mint a token, read app-id: ${{ vars.BLS_RELEASE_APP_ID }} and private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} inside environment: release. Declare BLS_RELEASE_PRIVATE_KEY under workflow_call.secrets with required: false, so a caller can't be forced to supply it.
  • Callers pass nothing for the App: no input, no secrets: entry, and not secrets: inherit, which would hand the shared workflow every repo-level publish token too. Then update the seven caller PRs to match. Terraform and the cut-over tasks stay as specified.
  • Prove it once on a test repo before any cut-over: a release environment holding the variable and secret, a caller that passes nothing, and a run that mints a token. GitHub documents that an environment secret is used in a reusable workflow's environment job, but not explicitly for a secret the caller didn't pass.
  • Check that both values are non-empty, or mint the token, in the first release job of the run, so a missing key fails before anything is published.

Medium

M1. release no longer enforces MAJOR.MINOR.PATCH.
cmd_release (scripts/manifest.py, the parsed.is_prerelease or parsed.is_devrelease or str(parsed) != declared check) rejects pre- and dev releases, but accepts 1.2, 1.2.3.4, 1.2.3.post1, 1!1.2.3 and 1.2.3+local. I ran each one: all exit 0 and emit tag=v1!1.2.3, tag=v1.2.3+local and so on. The old TAG regex refused all of these. Its error message still says MAJOR.MINOR.PATCH, and PyPI rejects local versions after Docker has already pushed.
Requested: require re.fullmatch(r"\d+\.\d+\.\d+", declared) (with parsed.release having three parts, or both), and add these five to test_release_refuses_a_prerelease_version or a sibling test.

M2. "Already released" is keyed on the GitHub release, so a version can be published from two commits.
release-check.yml:67-77 sets release=false only when gh release view succeeds. Any other outcome counts as new: no release, a deleted release, or an API error. That leaves two gaps:

  • A run that pushed Docker and PyPI and then failed before the release job is supposed to be re-run on the same commit. If another PR merges to stable first with the version unchanged, the new run sees no release, re-pushes vX Docker tags built from different code, skips PyPI (skip-existing), and tags the new commit. PyPI's X and Docker's X now come from different commits.
  • If someone deletes a release (the tag survives under the immutable ruleset), the next merge does the same thing. gh release create --target is then ignored, because the tag already exists.

On the spec's "fail: bump the version" versus this PR's no-op: I'd keep the no-op. Merges to stable that aren't releases are expected, such as the revert/re-merge steps in the hotfix flow (D7), and a red publish run on each of them would train people to ignore red. But the no-op should rest on the immutable record, and a half-finished release shouldn't be silently published over.
Requested: decide "already released" from the tag (gh api repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG), not from the release, and treat an API error as a failure rather than as "new". When the tag is absent but the version already exists on a registry (PyPI JSON API, crates.io, or the vX Docker tag), fail with "vX is partly published from another run; re-run that run or bump the version". A re-run of failed jobs reuses check's outputs and doesn't re-run check, so this doesn't block the legitimate re-run.

M3. The release branch is the default branch, not stable.
release-check.yml:40-45 compares github.ref to github.event.repository.default_branch. A1 and policy rule 2 say stable. For six of the seven repos that's the same today, but webcap's default branch is master, and webcap has no stable branch (webcap#132 triggers on master). The release environment that Terraform will create allows stable only, so webcap's release job would be refused at the environment. The check also moves if a repo admin changes the default branch, which is a settings change nobody reviews. The environment still holds in that case, but this check is meant to be the early, readable failure.
Requested: compare against refs/heads/stable, and raise webcap's master → stable rename as a cut-over item on .github-config#16. If you'd rather support a per-repo trunk, that's a policy change, and it goes in .github-config#17 with the Terraform deployment policy changed to match.

M4. The description says dev images go to GHCR, but nothing in this PR pushes to GHCR.
docker.yml only logs in to Docker Hub (:66-69). Nothing requests packages: write, and there's no ghcr.io reference in the PR or in any of the seven caller PRs' workflows. Dropping the dev tag is correct. The GHCR half of the dev channel (policy D8, step 7) isn't done anywhere yet.
Requested: correct the description. Then either add a GHCR path, a separate job or workflow that runs on dev with the repo token and packages: write and no release environment (no Docker Hub credentials involved), or say explicitly that bbot will build it itself, as spec B has it.

Low

  • L1. Git dependency forms that slip through. git_dependencies tests "git" in source for tool.uv.sources. A list of sources (uv's multi-source form with markers) is a list, so the check never sees the git key. Cargo's [target.'cfg(...)'.dependencies] and git deps declared in [workspace.dependencies] and pulled in with { workspace = true } are not read either. I ran all three and each exits 0. Non-git direct URLs (pkg @ https://…/archive/dev.zip) also pass. Worth covering, since the check is meant to be the stable guard the policy promises.
  • L2. The outputs changed. The spec asked to keep prerelease and semver. This drops them and adds tag and release. With no prereleases left, that's reasonable, and every caller PR already uses the new outputs. Please state it in the description as an interface change, so nobody bumps a pin without reading it.
  • L3. Approvals per release. docker.yml build and description, crates.yml, the callers' pypi job, and publish.yml release and image-sbom each set environment: release, and they run one after another. As I understand GitHub's behavior, each job that reaches a protected environment waits for its own review, so one release could take three or four approvals from bbot-leads. I haven't been able to verify this without an environment. Please check on the first cut-over. If it's true, merging jobs (description into build, image-sbom into release) would cut the count.
  • L4. crates.yml picks the first package. cargo metadata --no-deps | jq '.packages[0].name' is fine for a single crate. In a workspace it checks whichever member comes first. Either read the name from working-directory's own Cargo.toml, or document that this is single-crate only.

Noted, outside this PR

bbot#3355 docs/dev/releasing.md still describes tags pushed by people, release.sh and -rc.N from dev. That review covers it; I'm noting it here because this PR deletes the tool those docs point to.

Coverage against the spec

Item Status
A1 release-check Done differently: trunk = default branch, not stable (M3); version from the manifest; already-released is a no-op, not a failure (M2: keep the no-op, but key it on the tag); outputs changed (L2)
A2 publish.yml Done, except the App inputs and names (H1)
A3 docker.yml Done, with the environment hard-coded; the GHCR claim isn't implemented (M4)
A4 crates.yml Done

Verdict

Request changes: H1 before any repo cuts over, because it decides where the App key lives. M1 to M3 in this PR as well, each a few lines, and M4 either fixed or written up in the description. The lows are optional. Once H1 and the Mediums are in, I'll approve, and the seven caller PRs can then re-pin to that commit.

Next: we're making these changes ourselves in a follow-up PR, which will be linked here when it opens. This review is the record of what that PR addresses.

en0f
en0f previously approved these changes Oct 21, 2026
Read the release App ID and key from the release environment
(vars.BLS_RELEASE_APP_ID, secrets.BLS_RELEASE_PRIVATE_KEY) instead of
caller inputs, and mint a token in release-check before anything
publishes so a missing key fails first.

- release-check: release from stable only; decide "already released"
  from the v<version> tag; fail on an API error or a version already on
  PyPI, crates.io or Docker Hub with no tag
- manifest.py: require exact MAJOR.MINOR.PATCH; catch uv multi-source
  and url sources, PEP 508 direct URLs, Cargo target and workspace git
  deps; emit pypi_name and crate_name
- crates.yml: pick the package whose manifest is in working-directory
- docker.yml: declare Docker secrets so callers pass none; push the
  Hub description from the build job
- docker-dev.yml: push dev images to GHCR with the repository token,
  outside the release environment
- publish.yml: build image SBOMs outside the environment, attach them
  in the single release job
Callers can now pass CODECOV_TOKEN, BBOT_IO_API_KEY and the CLA App
credentials by name instead of secrets: inherit, which handed every
repository secret, publish tokens included, to test and CLA runs.
Runs manifest.py release before merge, so a promotion carrying a
prerelease version, a chart mismatch or a git dependency fails review
instead of the release run.

@pjhartlieb pjhartlieb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 07e14bcb. Everything from the first review is fixed: H1, M1–M4, L1, L3, L4.

Verify: bash verify-cla11.sh 07e14bcbba5e737e1b29bd9e3f9128fbf2483be9 ./actionlint gives 23 PASS, 0 FAIL (pytest 29 passed, actionlint 1.7.12 clean). As a negative control, the same script at 4894c4ad gives 17 FAIL, which are the first review's findings.

verify-cla11.sh
#!/usr/bin/env bash
# Reproducible check of CLA#11 at a given head against our 2026-10-21 review (H1, M1-M4, L1-L4).
# Clean clone at the SHA, tests via uv, manifest probes, static checks of the workflow files, actionlint
# (pinned, checksum-verified). Changes nothing outside a temp dir and nothing on GitHub.
# Negative control: run it at the reviewed head 4894c4ad; the M1/L1/H1/M3 checks must FAIL there.
# Usage: bash verify-cla11.sh <sha> [actionlint-binary]
set -uo pipefail
SHA=$1; AL=${2:-}
W=$(mktemp -d "${TMPDIR:-/tmp}/cla11.XXXXXX"); trap 'rm -rf "$W"' EXIT
git clone -q https://github.com/blacklanternsecurity/CLA "$W/CLA" && git -C "$W/CLA" fetch -q origin "$SHA" && git -C "$W/CLA" -c advice.detachedHead=false checkout -q "$SHA" || { echo "clone failed"; exit 2; }
cd "$W/CLA"; P=0; F=0
ok() { echo "PASS $1"; P=$((P+1)); }; no() { echo "FAIL $1"; F=$((F+1)); }
echo "head $(git rev-parse HEAD)"
if uv run -q --group dev pytest -q tests >"$W/pytest.log" 2>&1; then ok "pytest ($(tail -1 "$W/pytest.log"))"; else no "pytest ($(tail -1 "$W/pytest.log"))"; fi
probe() { # dir expect(refuse|accept) label
  local out; out=$(uv run -q --no-project --with packaging --with pyyaml python scripts/manifest.py --root "$1" release 2>&1); local rc=$?
  if { [ "$2" = refuse ] && [ $rc -ne 0 ]; } || { [ "$2" = accept ] && [ $rc -eq 0 ]; }; then ok "$3"; else no "$3 (rc=$rc: ${out##*$'\n'})"; fi; }
mk() { local d="$W/p/$1"; mkdir -p "$d"; git -C "$d" init -q; printf '%s\n' "$2" > "$d/pyproject.toml"; [ -z "${3:-}" ] || printf '%s\n' "$3" > "$d/Cargo.toml"; echo "$d"; }
for v in 1.2 1.2.3.4 1.2.3.post1 '1!1.2.3' 1.2.3+local 01.2.3 1.2.3rc1; do
  probe "$(mk "v$v" "[project]
name = \"x\"
version = \"$v\"")" refuse "M1 refuses version $v"; done
probe "$(mk ok '[project]
name = "x"
version = "1.2.3"')" accept "M1 accepts 1.2.3 (control)"
probe "$(mk l1a '[project]
name = "x"
version = "1.2.3"
dependencies = ["y"]
[tool.uv.sources]
y = [{ git = "https://example.invalid/y", marker = "sys_platform == '"'linux'"'" }]')" refuse "L1 uv.sources list form with git"
probe "$(mk l1b '[project]
name = "x"
version = "1.2.3"
dependencies = ["y @ https://example.invalid/y/archive/dev.zip"]')" refuse "L1 direct URL dependency"
probe "$(mk l1c '[project]
name = "x"
version = "1.2.3"' '[package]
name = "x"
version = "1.2.3"
[target.'"'cfg(unix)'"'.dependencies]
y = { git = "https://example.invalid/y" }')" refuse "L1 Cargo target.*.dependencies git"
probe "$(mk l1d '[project]
name = "x"
version = "1.2.3"' '[package]
name = "x"
version = "1.2.3"
[workspace.dependencies]
y = { git = "https://example.invalid/y" }')" refuse "L1 Cargo workspace.dependencies git"
WF=.github/workflows
grep -q 'release-app-id\|release-app-key' $WF/publish.yml && no "H1 caller-passed App input/secret removed" || ok "H1 caller-passed App input/secret removed"
grep -q 'vars.BLS_RELEASE_APP_ID' $WF/publish.yml && grep -q 'secrets.BLS_RELEASE_PRIVATE_KEY' $WF/publish.yml && ok "H1 publish.yml reads BLS_RELEASE_* from the environment" || no "H1 publish.yml reads BLS_RELEASE_* from the environment"
grep -q 'create-github-app-token' $WF/release-check.yml && ok "H1 token minted in the first release job (fail fast)" || no "H1 token minted in the first release job (fail fast)"
grep -q "github.ref != 'refs/heads/stable'" $WF/release-check.yml && ok "M3 release branch is refs/heads/stable" || no "M3 release branch is refs/heads/stable"
grep -q 'git/ref/tags/' $WF/release-check.yml && ok "M2 already-released keyed on the tag" || no "M2 already-released keyed on the tag"
grep -q 'partly published' $WF/release-check.yml && ok "M2 partial publish fails" || no "M2 partial publish fails"
[ -f $WF/docker-dev.yml ] && grep -q 'ghcr.io' $WF/docker-dev.yml && ok "M4 GHCR dev path exists" || no "M4 GHCR dev path exists"
grep -q 'manifest_path' $WF/crates.yml && ok "L4 crates.yml picks the working-directory crate" || no "L4 crates.yml picks the working-directory crate"
n=$(grep -l 'environment: release' $WF/*.yml | wc -l | tr -d ' '); echo "INFO files with environment: release: $n"
grep -rq 'secrets: inherit' $WF && no "no secrets: inherit in shared workflows" || ok "no secrets: inherit in shared workflows"
if [ -n "$AL" ]; then if "$AL" -no-color >"$W/al.log" 2>&1; then ok "actionlint clean"; else no "actionlint ($(wc -l <"$W/al.log" | tr -d ' ') lines)"; sed 's/^/  /' "$W/al.log" | head -20; fi; fi
echo "SUMMARY $P PASS, $F FAIL"
[ $F -eq 0 ]

Before merge:

  1. Conflicts with main in publish.yml and release-check.yml (#10 bumped the pins). After resolving them, the seven callers need to re-pin to the merge commit on main, not 07e14bcb.
  2. Release-time provenance check (policy, cloudcheck exception): every commit since the last v* tag has to come from a merged PR, except paths in a direct-push-paths input. cloudcheck needs this before it cuts over, and for the other repos it costs nothing. Our version is in #12, 31d7219 (scripts/provenance.py plus a release-check.yml step); take whatever is useful from it.

Suggested: an environment guard in check. It fails if release lets admins bypass it, or isn't limited to stable. GitHub auto-creates a missing environment with no protection, and the guard catches that before anything publishes. It's in #12, 6fc2cf5, and needs actions: read on the calling job.

I'll close #12 once these are in.

singlerider and others added 3 commits October 26, 2026 06:48
# Conflicts:
#	.github/workflows/publish.yml
#	.github/workflows/release-check.yml
A job that names a missing environment makes GitHub create it with no
protection. Before any job enters release, the check job now reads the
environment and fails unless it exists, admins can't bypass it,
deployments are limited to stable only, and, on public repositories, it
has required reviewers with prevent self-review. Anything unreadable
fails the run.

The check job needs actions: read; callers must grant it.

From pjhartlieb's 6fc2cf5 on #12.
Before a release, check each commit since the last v* tag against the
pull requests it belongs to (GitHub's commit-to-PR association), not
its author, which anyone can set. A commit that didn't arrive through a
merged pull request may touch only the paths in the new
direct-push-paths input (cloudcheck: its daily data files). With no
paths given, the default, any such commit fails the release. A first
release (no earlier v* tag) is skipped with a notice.

This is the release-time backstop the publishing policy requires for
the cloudcheck-update-bot exception.

The check job needs pull-requests: read; callers must grant it.

From pjhartlieb's 31d7219 on #12.

Refs blacklanternsecurity/.github-config#16, blacklanternsecurity/bbot-enterprise#406
@singlerider

Copy link
Copy Markdown
Contributor Author

@pjhartlieb addressed at 812db99:

  1. Merged main in (a2a88bd). Conflicts resolved, and the branch's own steps moved to the pins from build(deps): bump the github-actions group across 1 directory with 5 updates #10.
  2. Provenance check taken from feat(release): release on merge to stable, App credentials from the release environment #12 (31d7219) as 812db99. Callers grant pull-requests: read.
  3. Environment guard taken from feat(release): release on merge to stable, App credentials from the release environment #12 (6fc2cf5) as 0116ff6. Callers grant actions: read.

Validation: uv run --group dev pytest -q tests gives 37 passed. actionlint 1.7.12 is clean.

Callers re-pin to the merge commit on main once this lands.

@pjhartlieb pjhartlieb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 812db99a. The conflicts are resolved, the provenance check and the environment guard are in, and nothing from earlier regressed.

Verify: verify-cla11.sh 812db99a… ./actionlint gives 23 PASS, 0 FAIL (37 tests). verify-cla11-r3b.sh 812db99a… gives 7 PASS, 1 FAIL (F1). The control at 07e14bcb gives 8 FAIL.

F1 (Medium). This is my code from #12. provenance.py accepts a commit if any merged PR is associated with it, whatever that PR's base. For example, gh api repos/blacklanternsecurity/cloudcheck/commits/eb8b813ede280e4cae51ae6bbd19652196a50522/pulls returns a commit on stable whose only merged PR is into dev. So if a bypass actor pushes dev commits straight to stable, they pass without a stable PR. That actor could be the cloudcheck bot, or an admin.

Fix: walk git rev-list --first-parent <tag>..HEAD. Accept a commit only if it's associated with a merged PR whose base.ref is stable, or if it touches only direct-push-paths. Commits off the first-parent line came in through that PR. On cloudcheck, v11.2.0..stable (11 commits) is accepted under this rule: PR merges into stable, plus data-only bot commits.

Low. previous_tag returns None on any git describe error, which skips the check as if this were the first release. It should skip only when there are no v* tags at all.

After merge: callers re-pin to the merge commit on main, and give the check job contents, actions and pull-requests: read. asndb#39 already does.

I'll close #12 once F1 is settled.

verify-cla11-r3b.sh
#!/usr/bin/env bash
# Reproducible check of CLA#11's release-time provenance check (R3b) and environment guard, plus the
# base-branch gap in provenance.py. Clean clone at the SHA; GitHub reads are GET only.
# Negative control: at 07e14bcb (before R3b) the R3b and guard checks must FAIL.
# Expected at 812db99a: R3b/guard PASS; "F1" FAILs (the finding), its evidence line shows a commit on
# cloudcheck stable whose only merged PR has base=dev, which provenance.py accepts.
# Usage: bash verify-cla11-r3b.sh <sha>
set -uo pipefail
SHA=$1
W=$(mktemp -d "${TMPDIR:-/tmp}/cla11r3b.XXXXXX"); trap 'rm -rf "$W"' EXIT
git clone -q https://github.com/blacklanternsecurity/CLA "$W/CLA" && git -C "$W/CLA" fetch -q origin "$SHA" && git -C "$W/CLA" -c advice.detachedHead=false checkout -q "$SHA" || { echo "clone failed"; exit 2; }
cd "$W/CLA"; P=0; F=0
ok() { echo "PASS $1"; P=$((P+1)); }; no() { echo "FAIL $1"; F=$((F+1)); }
echo "head $(git rev-parse HEAD)"
RC=.github/workflows/release-check.yml
[ -f scripts/provenance.py ] && ok "R3b scripts/provenance.py present" || no "R3b scripts/provenance.py present"
grep -q 'direct-push-paths:' $RC && ok "R3b direct-push-paths input" || no "R3b direct-push-paths input"
grep -q 'provenance.py' $RC && ok "R3b release-check runs provenance.py" || no "R3b release-check runs provenance.py"
grep -q 'fetch-depth: 0' $RC && ok "R3b history fetched (fetch-depth 0)" || no "R3b history fetched (fetch-depth 0)"
if [ -f tests/test_provenance.py ] && uv run -q --group dev pytest -q tests/test_provenance.py >"$W/p.log" 2>&1; then ok "R3b tests ($(tail -1 "$W/p.log"))"; else no "R3b tests"; fi
grep -q 'environments/release' $RC && grep -q 'can_admins_bypass' $RC && grep -q '"branch:stable"' $RC && ok "guard: env readable, no admin bypass, stable only" || no "guard: env readable, no admin bypass, stable only"
awk '/^  check:/{f=1;next} f&&/^  [a-z-]+:$/{f=0} f' $RC | grep -q 'actions: read' && ok "guard: check job declares actions: read" || no "guard: check job declares actions: read"
# F1: does the rule tie a merged PR to the release branch (base ref, or a first-parent walk)?
if [ -f scripts/provenance.py ] && grep -qE "first-parent|base\"?\]?\.?\[?\"?ref|\[\"base\"\]" scripts/provenance.py; then ok "F1 provenance ties the PR to the release branch"; else no "F1 provenance ties the PR to the release branch (accepts a PR merged into any branch)"; fi
# Evidence for F1 (GET): a commit on cloudcheck stable whose associated merged PR has base dev.
gh api --method GET repos/blacklanternsecurity/cloudcheck/commits/eb8b813ede280e4cae51ae6bbd19652196a50522/pulls \
  --jq '"INFO F1 evidence: cloudcheck eb8b813e (on stable) -> " + ([.[]|"#\(.number) base=\(.base.ref) merged=\(.merged_at!=null)"]|join(" "))'
echo "SUMMARY $P PASS, $F FAIL"
[ $F -eq 0 ]

…ents

A commit associated with any merged pull request passed, whatever that
pull request's base. A dev commit pushed straight to stable by a bypass
actor therefore passed without a stable pull request (pjhartlieb, F1).
Accept a commit only when a merged pull request into --base (default
stable) carries it, and walk only the first-parent line, since commits
off it arrived through the merge that brought them.

previous_tag skipped the check on any git describe error, as if this
were a first release. It now skips only when no release tag exists, and
an unreachable tag fails the job.

@pjhartlieb pjhartlieb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 9c75e85c. F1 and the Low are fixed.

Verify: verify-cla11.sh 9c75e85c… ./actionlint gives 23 PASS, 0 FAIL (41 tests, actionlint clean). The new verify-cla11-r4.sh 9c75e85c… gives 10 PASS, 0 FAIL. It runs main() on test histories with a stubbed gh, and on cloudcheck stable. On cloudcheck, all 12 first-parent commits since v11.2.0 pass; with --base dev, it fails. The control at 812db99a fails F1 and the Low.

Nit, not blocking. test_only_the_first_parent_line_is_walked runs git rev-list directly, not provenance.py. If --first-parent is dropped from main(), all 41 tests still pass. Moving the rev-list call into a function and testing that would pin it.

Before merge: update the branch. main now has #13 and requires python / passed, actionlint, and branches up to date. The merge is clean and keeps #13's change in cla-reusable.yml.

verify-cla11-r4.sh
#!/usr/bin/env bash
# Reproducible check of CLA#11's F1 fix (provenance.py: PR must be merged into the release branch; first-parent
# walk) and the review-3 Low (describe error must not skip). Behavioural: runs provenance.py's main() against
# synthetic histories with a stub `gh`, and against real cloudcheck history with the real `gh` (GET only).
# Negative controls: the same synthetic F1 case run with OLD's provenance.py must be ACCEPTED (proves the case
# reproduces F1); real cloudcheck with --base dev must FAIL (proves the base check can reject).
# Usage: bash verify-cla11-r4.sh <full-head-sha> [old-full-sha] [actionlint-binary]
set -uo pipefail
SHA=$1; OLD=${2:-812db99a39f3244b7d6c50453f0299cfc07d0830}; AL=${3:-}
W=$(mktemp -d "${TMPDIR:-/tmp}/cla11r4.XXXXXX"); trap 'rm -rf "$W"' EXIT
git clone -q https://github.com/blacklanternsecurity/CLA "$W/CLA" && git -C "$W/CLA" fetch -q origin "$SHA" "$OLD" \
  && git -C "$W/CLA" -c advice.detachedHead=false checkout -q "$SHA" || { echo "clone failed"; exit 2; }
git -C "$W/CLA" show "$OLD:scripts/provenance.py" > "$W/provenance-old.py" || { echo "old provenance.py missing"; exit 2; }
NEW="$W/CLA/scripts/provenance.py"; P=0; F=0
ok() { echo "PASS $1"; P=$((P+1)); }; no() { echo "FAIL $1"; F=$((F+1)); }
echo "head $(git -C "$W/CLA" rev-parse HEAD)  old $OLD"

cd "$W/CLA"
if uv run -q --group dev pytest -q tests >"$W/pytest.log" 2>&1; then ok "pytest ($(tail -1 "$W/pytest.log"))"; else no "pytest ($(tail -1 "$W/pytest.log"))"; fi
cd "$W"

# Stub gh: prints $STUB_JSON for any call (provenance.py only calls commits/{sha}/pulls).
mkdir -p "$W/stub"; printf '#!/bin/sh\nprintf "%%s" "$STUB_JSON"\n' > "$W/stub/gh"; chmod +x "$W/stub/gh"
G() { git -C "$1" -c user.name=t -c user.email=t@t "${@:2}"; }
repo() { local d="$W/r/$1"; mkdir -p "$d"; git -C "$d" init -q -b stable; echo 1 > "$d/a"; G "$d" add -A; G "$d" commit -qm initial; echo "$d"; }
run() { # script repo stub_json [args...] -> exit code
  local s=$1 d=$2 j=$3; shift 3
  STUB_JSON=$j PATH="$W/stub:$PATH" python3 "$s" --root "$d" --repo o/r "$@" >"$W/last.log" 2>&1; echo $?; }
DEV='[{"merged_at":"t","base":{"ref":"dev"}}]'; STB='[{"merged_at":"t","base":{"ref":"stable"}}]'

# F1 case: a direct commit to stable whose only associated merged PR is into dev.
d=$(repo f1); G "$d" tag v1.0.0; mkdir -p "$d/src"; echo x > "$d/src/x.py"; G "$d" add -A; G "$d" commit -qm "dev work pushed to stable"
[ "$(run "$NEW" "$d" "$DEV")" != 0 ] && ok "F1 new: commit whose PR base=dev is rejected" || no "F1 new: commit whose PR base=dev is rejected ($(tail -1 "$W/last.log"))"
[ "$(run "$NEW" "$d" "$STB")" = 0 ] && ok "F1 new: same commit with PR base=stable is accepted (control)" || no "F1 new: base=stable accepted ($(tail -1 "$W/last.log"))"
[ "$(run "$W/provenance-old.py" "$d" "$DEV")" = 0 ] && ok "F1 control: OLD accepts the base=dev commit (case reproduces F1)" || no "F1 control: OLD accepts base=dev ($(tail -1 "$W/last.log"))"

# Bypass merge: dev merged into stable locally and pushed (no PR) must fail; data-only direct push must pass.
d=$(repo merge); G "$d" tag v1.0.0; G "$d" checkout -qb dev; mkdir -p "$d/src"; echo y > "$d/src/y.py"; G "$d" add -A; G "$d" commit -qm dev
G "$d" checkout -q stable; G "$d" merge -q --no-ff -m "merge dev" dev
[ "$(run "$NEW" "$d" '[]')" != 0 ] && ok "bypass merge commit without a PR is rejected" || no "bypass merge rejected ($(tail -1 "$W/last.log"))"
d=$(repo data); G "$d" tag v1.0.0; echo '{}' > "$d/cloud_providers_v3.json"; G "$d" add -A; G "$d" commit -qm data
[ "$(run "$NEW" "$d" '[]' --allow cloud_providers_v3.json README.md)" = 0 ] && ok "data-only direct push is accepted" || no "data-only accepted ($(tail -1 "$W/last.log"))"

# Low: tags exist but none reachable from HEAD must not be skipped as a first release.
d=$(repo low); G "$d" checkout -qb side; echo s > "$d/s"; G "$d" add -A; G "$d" commit -qm side; G "$d" tag v1.0.0; G "$d" checkout -q stable
rc=$(run "$NEW" "$d" "$STB"); if [ "$rc" != 0 ] && ! grep -q 'first release' "$W/last.log"; then ok "Low: unreachable tag fails instead of skipping"; else no "Low: unreachable tag (rc=$rc: $(tail -1 "$W/last.log"))"; fi

# Real history: cloudcheck stable since its last v* tag, real gh (GET).
git clone -q --filter=blob:none https://github.com/blacklanternsecurity/cloudcheck "$W/cc" 2>/dev/null && git -C "$W/cc" checkout -q stable
if python3 "$NEW" --root "$W/cc" --repo blacklanternsecurity/cloudcheck --allow cloud_providers_v3.json README.md >"$W/cc.log" 2>&1; then
  ok "cloudcheck stable passes ($(grep -m1 '^checking' "$W/cc.log"))"; else no "cloudcheck stable ($(tail -2 "$W/cc.log" | tr '\n' ' '))"; fi
if python3 "$NEW" --root "$W/cc" --repo blacklanternsecurity/cloudcheck --base dev --allow cloud_providers_v3.json README.md >"$W/ccd.log" 2>&1; then
  no "cloudcheck control: --base dev must fail"; else ok "cloudcheck control: --base dev fails ($(grep -c '::error::' "$W/ccd.log") rejected)"; fi

# Mutation probes: does the test suite pin each half of the fix?
mut() { # label sed-expr
  rm -rf "$W/m"; cp -R "$W/CLA" "$W/m"; sed -i '' "$2" "$W/m/scripts/provenance.py"
  if cmp -s "$NEW" "$W/m/scripts/provenance.py"; then echo "INFO mutation '$1' did not apply"; return; fi
  if (cd "$W/m" && uv run -q --group dev pytest -q tests >/dev/null 2>&1); then echo "INFO mutation '$1' SURVIVES the test suite"; else echo "INFO mutation '$1' killed by the test suite"; fi; }
mut "drop --first-parent in main()" 's/"rev-list", "--first-parent", /"rev-list", /'
mut "ignore PR base in merged_into" 's/ and pr.get("base", {}).get("ref") == base//'

if [ -n "$AL" ]; then if (cd "$W/CLA" && "$AL" -no-color >"$W/al.log" 2>&1); then ok "actionlint clean"; else no "actionlint"; sed 's/^/  /' "$W/al.log" | head -20; fi; fi
echo "SUMMARY $P PASS, $F FAIL"
[ $F -eq 0 ]

@en0f en0f left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. As was stated, just for record:

The branch is behind main by #13 (cla-reusable permissions fix) and #14 (CODEOWNERS for bbot-leads). Main requires branches to be up to date, so it needs an “Update branch.” The merge is clean. Updating will also bring in CODEOWNERS. If code-owner review is required, a bbot-leads approval will be needed after the update.

@singlerider
singlerider requested a review from a team as a code owner October 27, 2026 16:39

@pjhartlieb pjhartlieb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at ef3d473c. The update onto main is a clean merge: its tree is identical to 9c75e85c merged with aac1b8a8, so review 4's verification still applies.

@pjhartlieb
pjhartlieb merged commit 201b0b6 into main Oct 27, 2026
16 checks passed
@pjhartlieb
pjhartlieb deleted the feat/release-on-stable branch October 27, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants