From 4894c4adb0c98b978c7c621903e1f3205fc446b7 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Wed, 21 Oct 2026 05:43:04 -0700 Subject: [PATCH 1/4] feat(release): release on merge to the trunk, not on a tag 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. --- .github/workflows/crates.yml | 30 ++++++++--- .github/workflows/docker.yml | 16 +++--- .github/workflows/publish.yml | 46 ++++++++++++----- .github/workflows/release-check.yml | 48 +++++++++++------- scripts/manifest.py | 59 ++++++++++----------- scripts/release.sh | 44 ---------------- tests/test_manifest.py | 79 ++++++++++++++++------------- 7 files changed, 163 insertions(+), 159 deletions(-) delete mode 100755 scripts/release.sh diff --git a/.github/workflows/crates.yml b/.github/workflows/crates.yml index eca1857..af958ab 100644 --- a/.github/workflows/crates.yml +++ b/.github/workflows/crates.yml @@ -9,11 +9,9 @@ on: description: Repository script run before cargo publish. It may append to $GITHUB_ENV. type: string default: "" - environment: + version: + description: version output of release-check.yml type: string - default: release - secrets: - CARGO_REGISTRY_TOKEN: required: true permissions: @@ -22,7 +20,10 @@ permissions: jobs: crates: runs-on: ubuntu-24.04 - environment: ${{ inputs.environment }} + environment: release + permissions: + contents: read + id-token: write defaults: run: working-directory: ${{ inputs.working-directory }} @@ -31,10 +32,23 @@ jobs: with: persist-credentials: false - run: rustup show active-toolchain || rustup toolchain install - - if: inputs.setup-script != '' + - id: published + env: + VERSION: ${{ inputs.version }} + run: | + crate=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name') + if curl -sf -o /dev/null -H "User-Agent: $GITHUB_REPOSITORY" "https://crates.io/api/v1/crates/$crate/$VERSION"; then + echo "::notice::$crate $VERSION is already on crates.io" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - if: steps.published.outputs.skip != 'true' && inputs.setup-script != '' env: SCRIPT: ${{ inputs.setup-script }} run: '"./$SCRIPT"' - - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + - if: steps.published.outputs.skip != 'true' + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + - if: steps.published.outputs.skip != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} run: cargo publish --locked diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0fc865d..7045b81 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -10,9 +10,6 @@ on: description: version output of release-check.yml type: string required: true - prerelease: - type: string - required: true major: type: string required: true @@ -57,11 +54,10 @@ jobs: build: needs: plan runs-on: ubuntu-24.04 + environment: release strategy: matrix: build: ${{ fromJSON(needs.plan.outputs.builds) }} - env: - RELEASE: ${{ inputs.prerelease == 'false' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -80,11 +76,10 @@ jobs: suffix=${{ matrix.build.suffix }} tags: | type=raw,value=${{ inputs.version }} - type=raw,value=dev,enable=${{ env.RELEASE == 'false' }} - type=raw,value=latest,enable=${{ env.RELEASE }} - type=raw,value=stable,enable=${{ env.RELEASE }} - type=raw,value=${{ inputs.major }}.${{ inputs.minor }},enable=${{ env.RELEASE }} - type=raw,value=${{ inputs.major }},enable=${{ env.RELEASE }} + type=raw,value=latest + type=raw,value=stable + type=raw,value=${{ inputs.major }}.${{ inputs.minor }} + type=raw,value=${{ inputs.major }} - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . @@ -99,6 +94,7 @@ jobs: if: inputs.description needs: build runs-on: ubuntu-24.04 + environment: release steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6181479..5954cf9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,12 +2,16 @@ name: Publish release on: workflow_call: inputs: + release-app-id: + description: App ID of the release GitHub App + type: string + required: true version: description: version output of release-check.yml, which the caller runs first type: string required: true - prerelease: - description: prerelease output of release-check.yml + tag: + description: tag output of release-check.yml. Created here, on the commit being released, after every artifact is published. type: string required: true images: @@ -38,6 +42,9 @@ on: registry-token: description: Token for pulling private images; omit for public images required: false + release-app-key: + description: Private key of the release GitHub App, the only actor the tag rulesets let create a v* tag + required: true permissions: contents: read @@ -45,9 +52,16 @@ permissions: jobs: release: runs-on: ubuntu-24.04 + environment: release permissions: - contents: write + contents: read steps: + - id: app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.release-app-id }} + private-key: ${{ secrets.release-app-key }} + permission-contents: write - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -67,26 +81,24 @@ jobs: with: path: . format: spdx-json - output-file: sbom/${{ github.event.repository.name }}-${{ github.ref_name }}.spdx.json + output-file: sbom/${{ github.event.repository.name }}-${{ inputs.tag }}.spdx.json upload-artifact: false upload-release-assets: false - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ steps.app.outputs.token }} + TAG: ${{ inputs.tag }} VERSION: ${{ inputs.version }} - PRERELEASE: ${{ inputs.prerelease }} CHANGELOG: ${{ inputs.changelog }} ASSETS: ${{ inputs.assets }} run: | - flags=(--verify-tag) + flags=(--target "$GITHUB_SHA") if [ -n "$CHANGELOG" ]; then uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py notes "$CHANGELOG" "$VERSION" > "$RUNNER_TEMP/notes.md" flags+=(--notes-file "$RUNNER_TEMP/notes.md") else flags+=(--generate-notes) fi - [ "$PRERELEASE" = true ] && flags+=(--prerelease) gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1 || gh release create "$TAG" -R "$GITHUB_REPOSITORY" "${flags[@]}" shopt -s globstar nullglob files=(sbom/*.spdx.json) @@ -97,8 +109,9 @@ jobs: if: inputs.images != '[]' needs: release runs-on: ubuntu-24.04 + environment: release permissions: - contents: write + contents: read strategy: matrix: image: ${{ fromJSON(inputs.images) }} @@ -106,7 +119,14 @@ jobs: - id: name env: IMAGE: ${{ matrix.image }} - run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_OUTPUT" + TAG: ${{ inputs.tag }} + run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${TAG}.spdx.json" >> "$GITHUB_OUTPUT" + - id: app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.release-app-id }} + private-key: ${{ secrets.release-app-key }} + permission-contents: write - id: auth env: TOKEN: ${{ secrets.registry-token }} @@ -125,7 +145,7 @@ jobs: upload-artifact: false upload-release-assets: false - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ steps.app.outputs.token }} + TAG: ${{ inputs.tag }} FILE: ${{ steps.name.outputs.file }} run: gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "$FILE" diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 04497ff..4383721 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -11,18 +11,18 @@ on: default: "" outputs: version: - description: Manifest version, equal to the tag. Every tracked Helm Chart.yaml version and appVersion must equal it too. + description: Manifest version of the merged commit. Every tracked Helm Chart.yaml version and appVersion must equal it. value: ${{ jobs.check.outputs.version }} - prerelease: - description: true for vX.Y.Z-rc.N tags - value: ${{ jobs.check.outputs.prerelease }} - semver: - description: The tag without its v prefix - value: ${{ jobs.check.outputs.semver }} + tag: + description: v, created by publish.yml once everything is published + value: ${{ jobs.check.outputs.tag }} major: value: ${{ jobs.check.outputs.major }} minor: value: ${{ jobs.check.outputs.minor }} + release: + description: false when the version already has a GitHub release, so a push that keeps the version publishes nothing + value: ${{ jobs.check.outputs.release }} permissions: contents: read @@ -31,15 +31,17 @@ jobs: check: runs-on: ubuntu-24.04 outputs: - version: ${{ steps.tag.outputs.version }} - prerelease: ${{ steps.tag.outputs.prerelease }} - semver: ${{ steps.tag.outputs.semver }} - major: ${{ steps.tag.outputs.major }} - minor: ${{ steps.tag.outputs.minor }} + version: ${{ steps.manifest.outputs.version }} + tag: ${{ steps.manifest.outputs.tag }} + major: ${{ steps.manifest.outputs.major }} + minor: ${{ steps.manifest.outputs.minor }} + release: ${{ steps.new.outputs.release }} steps: - - if: github.ref_type != 'tag' + - if: github.ref != format('refs/heads/{0}', github.event.repository.default_branch) + env: + TRUNK: ${{ github.event.repository.default_branch }} run: | - echo "::error::releases run on tag pushes only" + echo "::error::releases run from $TRUNK only" exit 1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -51,15 +53,25 @@ jobs: path: .shared persist-credentials: false - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - id: tag + - id: manifest env: ROOT: ${{ inputs.working-directory }} - TAG: ${{ github.ref_name }} - run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" tag "$TAG" + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" release - if: inputs.check-script != '' env: SCRIPT: ${{ inputs.check-script }} - TAG: ${{ github.ref_name }} + TAG: ${{ steps.manifest.outputs.tag }} run: | read -ra cmd <<<"$SCRIPT" "./${cmd[0]}" "${cmd[@]:1}" "$TAG" + - id: new + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.manifest.outputs.tag }} + run: | + if gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::notice::$TAG is already released, nothing to publish" + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi diff --git a/scripts/manifest.py b/scripts/manifest.py index 054b66d..fb8883d 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -10,11 +10,9 @@ from enum import Enum from pathlib import Path -import yaml from packaging.specifiers import SpecifierSet from packaging.version import Version -TAG = re.compile(r"^v(?P\d+\.\d+\.\d+(?:-rc\.(?P\d+))?)$") MANIFESTS = ("pyproject.toml", "Cargo.toml", "package.json") @@ -140,25 +138,35 @@ def cmd_matrix(args): ) -def cmd_tag(args): - match = TAG.match(args.tag) - if not match: - sys.exit(f"tag {args.tag} is not vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N") +def git_dependencies(root): + pyproject = load(root / "pyproject.toml") + project = pyproject.get("project", {}) + found = [d for d in project.get("dependencies", []) if "git+" in d] + for group in project.get("optional-dependencies", {}).values(): + found += [d for d in group if "git+" in d] + tool = pyproject.get("tool", {}) + found += [name for name, source in tool.get("uv", {}).get("sources", {}).items() if "git" in source] + for table in ("dependencies", "dev-dependencies"): + found += [name for name, spec in tool.get("poetry", {}).get(table, {}).items() if isinstance(spec, dict) and "git" in spec] + cargo = load(root / tool.get("maturin", {}).get("manifest-path", "Cargo.toml")) + for table in ("dependencies", "build-dependencies"): + found += [name for name, spec in cargo.get(table, {}).items() if isinstance(spec, dict) and "git" in spec] + return found + + +def cmd_release(args): declared = version(args.root) - if Version(declared) != Version(match["version"]): - sys.exit(f"tag {args.tag} does not match manifest version {declared}") + parsed = Version(declared) + if parsed.is_prerelease or parsed.is_devrelease or str(parsed) != declared: + sys.exit(f"{declared} is not a MAJOR.MINOR.PATCH release version") for chart in charts(args.root): for field, value in chart_fields(chart).items(): - if value != match["version"]: - sys.exit(f"tag {args.tag} does not match {chart} {field} {value}") - major, minor, _ = match["version"].split("-")[0].split(".") - emit( - version=declared, - semver=match["version"], - major=major, - minor=minor, - prerelease=str(match["rc"] is not None).lower(), - ) + if value != declared: + sys.exit(f"{chart} {field} {value} does not match manifest version {declared}") + pinned = git_dependencies(args.root) + if pinned: + sys.exit(f"stable must pin released versions, not git sources: {', '.join(pinned)}") + emit(version=declared, tag=f"v{declared}", major=str(parsed.major), minor=str(parsed.minor)) def cmd_notes(args): @@ -177,16 +185,6 @@ def cmd_notes(args): print("\n".join(section).strip()) -def cmd_caller(args): - for path in sorted((args.root / ".github" / "workflows").glob("*.y*ml")): - for job in (yaml.safe_load(path.read_text()) or {}).get("jobs", {}).values(): - if "/.github/workflows/release-check.yml@" in str(job.get("uses", "")): - given = job.get("with", {}) - print(json.dumps({"working-directory": given.get("working-directory", "."), "check-script": given.get("check-script", "")})) - return - sys.exit(f"no job in {args.root}/.github/workflows calls release-check.yml") - - def cmd_images(args): listed = subprocess.run(["git", "ls-files", "-z", "--", "Dockerfile*"], cwd=args.root, capture_output=True, text=True, check=True) builds = [] @@ -204,14 +202,11 @@ def main(): parser.add_argument("--root", type=Path, default=Path(".")) sub = parser.add_subparsers(required=True) sub.add_parser("matrix").set_defaults(func=cmd_matrix) - tag = sub.add_parser("tag") - tag.add_argument("tag") - tag.set_defaults(func=cmd_tag) + sub.add_parser("release").set_defaults(func=cmd_release) notes = sub.add_parser("notes") notes.add_argument("changelog", type=Path) notes.add_argument("version") notes.set_defaults(func=cmd_notes) - sub.add_parser("caller").set_defaults(func=cmd_caller) images = sub.add_parser("images") images.add_argument("image") images.add_argument("version") diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 9b8954e..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# Cut a release tag from the repository in the current directory. Usage: release.sh vMAJOR.MINOR.PATCH[-rc.N] -set -euo pipefail - -die() { echo "release: $*" >&2; exit 1; } - -[ $# -eq 1 ] || die "usage: $0 vMAJOR.MINOR.PATCH[-rc.N]" -tag=$1 -remote=${RELEASE_REMOTE:-origin} -here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) - -git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository" -[ -z "$(git status --porcelain)" ] || die "working tree is dirty" -git remote set-head "$remote" --auto >/dev/null -trunk=$(git symbolic-ref --short "refs/remotes/$remote/HEAD") -trunk=${trunk#"$remote/"} -source=$trunk -[[ $tag == *-rc.* ]] && source=dev -branch=$(git symbolic-ref --quiet --short HEAD || true) -[ "$branch" = "$source" ] || die "on '${branch:-detached HEAD}', $tag is cut from '$source'" - -git fetch --quiet --tags "$remote" "$source" -[ "$(git rev-parse HEAD)" = "$(git rev-parse "$remote/$source")" ] || die "local $source differs from $remote/$source" -git rev-parse -q --verify "refs/tags/$tag" >/dev/null && die "tag $tag already exists" -manifest() { uv run --quiet --no-project --with packaging --with pyyaml python "$here/manifest.py" "$@"; } -caller=$(manifest caller) || die "no workflow here calls release-check.yml" -root=$(jq -r '.["working-directory"]' <<<"$caller") -check=$(jq -r '.["check-script"]' <<<"$caller") -manifest --root "$root" tag "$tag" >/dev/null || die "merge the version bump first" -if [ -n "$check" ]; then - read -ra cmd <<<"$check" - "./${cmd[0]}" "${cmd[@]:1}" "$tag" || die "$check refused $tag" -fi - -prev=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true) -echo "Repository: $(git remote get-url "$remote")" -echo "Commit: $(git log -1 --format='%h %s')" -echo "Tag: $tag${prev:+ (previous $prev)}" -[ -n "$prev" ] && git log --oneline "$prev..HEAD" -read -r -p "Create and push $tag? [y/N] " answer -[ "$answer" = y ] || [ "$answer" = Y ] || die "aborted" - -git tag -a "$tag" -m "$tag" -git push "$remote" "refs/tags/$tag" diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 1d5cdc3..e4cf9d6 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -48,7 +48,7 @@ def test_maturin_version_comes_from_cargo(repo): {"Cargo.toml": '[package]\nname = "x"\nversion = "2.0.0"\n'}, ) assert outputs(run(root, "matrix"))["maturin"] == "true" - assert outputs(run(root, "tag", "v2.0.0"))["version"] == "2.0.0" + assert outputs(run(root, "release"))["version"] == "2.0.0" def test_hatch_version_path(repo): @@ -56,19 +56,7 @@ def test_hatch_version_path(repo): '[build-system]\nbuild-backend = "hatchling.build"\n[project]\nname = "x"\ndynamic = ["version"]\n[tool.hatch.version]\npath = "x/__version__.py"\n', {"x/__version__.py": '__version__ = "1.2.3"\n'}, ) - assert outputs(run(root, "tag", "v1.2.3"))["version"] == "1.2.3" - - -@pytest.mark.parametrize("declared", ["1.2.3rc4", "1.2.3-rc.4"]) -def test_rc_tag_matches_either_spelling(repo, declared): - root = repo(f'[project]\nname = "x"\nversion = "{declared}"\n') - assert outputs(run(root, "tag", "v1.2.3-rc.4"))["prerelease"] == "true" - - -@pytest.mark.parametrize("tag", ["1.2.3", "v1.2", "v1.2.3rc4", "v1.2.3-beta.1", "v1.2.4"]) -def test_bad_tags_refused(repo, tag): - root = repo('[project]\nname = "x"\nversion = "1.2.3"\n') - assert run(root, "tag", tag).returncode != 0 + assert outputs(run(root, "release"))["version"] == "1.2.3" def test_notes_extracts_one_section(tmp_path): @@ -79,16 +67,6 @@ def test_notes_extracts_one_section(tmp_path): assert run(tmp_path, "notes", str(changelog), "9.9.9").returncode != 0 -def test_tracked_charts_must_match_tag(repo): - root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"helm/Chart.yaml": 'name: x\nversion: 1.2.3\nappVersion: "1.2.2"\n'}) - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "."], cwd=root, check=True) - result = run(root, "tag", "v1.2.3") - assert result.returncode != 0 and "appVersion" in result.stderr - (root / "helm/Chart.yaml").write_text('name: x\nversion: 1.2.3\nappVersion: "1.2.3"\n') - assert outputs(run(root, "tag", "v1.2.3")) == {"version": "1.2.3", "semver": "1.2.3", "major": "1", "minor": "2", "prerelease": "false"} - - def test_chart_under_nested_package_is_not_ours(repo): root = repo( '[project]\nname = "x"\nversion = "1.2.3"\n', @@ -96,16 +74,7 @@ def test_chart_under_nested_package_is_not_ours(repo): ) subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "add", "."], cwd=root, check=True) - assert outputs(run(root, "tag", "v1.2.3"))["version"] == "1.2.3" - - -def test_caller_reads_release_check_inputs(repo): - workflow = ( - "jobs:\n check:\n uses: blacklanternsecurity/CLA/.github/workflows/release-check.yml@abc\n" - " with:\n working-directory: backend\n check-script: scripts/v.py --check\n" - ) - root = repo('[project]\nname = "x"\nversion = "1.0.0"\n', {".github/workflows/publish.yml": workflow}) - assert json.loads(run(root, "caller").stdout) == {"working-directory": "backend", "check-script": "scripts/v.py --check"} + assert outputs(run(root, "release"))["version"] == "1.2.3" def test_images_from_tracked_root_dockerfiles(repo): @@ -117,3 +86,45 @@ def test_images_from_tracked_root_dockerfiles(repo): out = outputs(run(root, "images", "org/x", "1.0.0")) assert json.loads(out["images"]) == ["org/x:1.0.0", "org/x:1.0.0-full"] assert [b["file"] for b in json.loads(out["builds"])] == ["Dockerfile", "Dockerfile.full"] + + +def test_release_reads_the_manifest_version(repo): + root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"helm/Chart.yaml": 'name: x\nversion: 1.2.3\nappVersion: "1.2.3"\n'}) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + assert outputs(run(root, "release")) == {"version": "1.2.3", "tag": "v1.2.3", "major": "1", "minor": "2"} + + +def test_release_refuses_a_chart_behind_the_manifest(repo): + root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"helm/Chart.yaml": 'name: x\nversion: 1.2.3\nappVersion: "1.2.2"\n'}) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + result = run(root, "release") + assert result.returncode != 0 and "appVersion" in result.stderr + + +@pytest.mark.parametrize("declared", ["1.2.3rc4", "1.2.3.dev1", "1.2.3-beta.1"]) +def test_release_refuses_a_prerelease_version(repo, declared): + assert run(repo(f'[project]\nname = "x"\nversion = "{declared}"\n'), "release").returncode != 0 + + +@pytest.mark.parametrize( + "pyproject", + [ + '[project]\nname = "x"\nversion = "1.0.0"\ndependencies = ["radixtarget @ git+https://github.com/blacklanternsecurity/radixtarget@dev"]\n', + '[project]\nname = "x"\nversion = "1.0.0"\n[tool.uv.sources]\nradixtarget = { git = "https://github.com/blacklanternsecurity/radixtarget", branch = "dev" }\n', + '[tool.poetry]\nname = "x"\nversion = "1.0.0"\n[tool.poetry.dependencies]\nradixtarget = { git = "https://github.com/blacklanternsecurity/radixtarget.git", branch = "dev" }\n', + ], +) +def test_release_refuses_git_dependencies(repo, pyproject): + result = run(repo(pyproject), "release") + assert result.returncode != 0 and "radixtarget" in result.stderr + + +def test_release_refuses_git_crates(repo): + root = repo( + '[build-system]\nbuild-backend = "maturin"\n[project]\nname = "x"\ndynamic = ["version"]\n', + {"Cargo.toml": '[package]\nname = "x"\nversion = "1.0.0"\n[dependencies]\nblastdns = { git = "https://github.com/blacklanternsecurity/blastdns" }\n'}, + ) + result = run(root, "release") + assert result.returncode != 0 and "blastdns" in result.stderr From f6816f0b4b6eae0200eaef0547ddb0e84c4e45df Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Wed, 21 Oct 2026 11:36:24 -0400 Subject: [PATCH 2/4] fix(release): App credentials from the release environment; stricter release check Builds on 4894c4a (CLA#11). Addresses the review on CLA#11: - H1: the release App ID and key are read from the caller repository's release environment (vars.BLS_RELEASE_APP_ID, secrets.BLS_RELEASE_PRIVATE_KEY). Callers pass nothing; the release-app-id input and release-app-key secret are removed, so the key can't end up at repo or org level. docker.yml reads DOCKER_* the same way, so callers don't need secrets: inherit. - Fail fast: a preflight job in release-check.yml, inside the release environment, checks the variable and secret exist and mints a token (proving the key and the installation) before anything publishes. - M1: the manifest version must be exactly MAJOR.MINOR.PATCH (rejects 1.2, 1.2.3.4, .postN, epochs, +local, leading zeros, a v prefix). - M2: "already released" is the immutable v tag, not the GitHub release. A version already on PyPI or crates.io without its tag fails the check instead of tagging a commit the artifacts weren't built from. - M3: releases run from refs/heads/stable explicitly, not "the default branch". Refs blacklanternsecurity/.github-config#16 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker.yml | 9 +++++ .github/workflows/publish.yml | 21 +++++----- .github/workflows/release-check.yml | 61 ++++++++++++++++++++++++----- scripts/manifest.py | 20 ++++++++-- tests/test_manifest.py | 22 ++++++++++- 5 files changed, 108 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7045b81..3fa3b75 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,6 +20,15 @@ on: description: Push README.md to the Docker Hub repository description type: boolean default: false + # Read from the caller repository's release environment; callers pass nothing (and never + # secrets: inherit, which would hand this workflow every repository-level secret too). + secrets: + DOCKER_USERNAME: + description: Never passed by a caller. The release environment's secret of this name. + required: false + DOCKER_TOKEN: + description: Never passed by a caller. The release environment's secret of this name. + required: false outputs: images: description: JSON list of pushed version references, for publish.yml images diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5954cf9..1692327 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,10 +2,6 @@ name: Publish release on: workflow_call: inputs: - release-app-id: - description: App ID of the release GitHub App - type: string - required: true version: description: version output of release-check.yml, which the caller runs first type: string @@ -42,9 +38,12 @@ on: registry-token: description: Token for pulling private images; omit for public images required: false - release-app-key: - description: Private key of the release GitHub App, the only actor the tag rulesets let create a v* tag - required: true + # The release App's ID and key are read from the caller repository's release environment + # (vars.BLS_RELEASE_APP_ID, secrets.BLS_RELEASE_PRIVATE_KEY). Callers never pass them; the + # key is held nowhere else. Declared here only so the environment's secret can be read. + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. The release environment's secret of this name. + required: false permissions: contents: read @@ -59,8 +58,8 @@ jobs: - id: app uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: - app-id: ${{ inputs.release-app-id }} - private-key: ${{ secrets.release-app-key }} + app-id: ${{ vars.BLS_RELEASE_APP_ID }} + private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} permission-contents: write - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -124,8 +123,8 @@ jobs: - id: app uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: - app-id: ${{ inputs.release-app-id }} - private-key: ${{ secrets.release-app-key }} + app-id: ${{ vars.BLS_RELEASE_APP_ID }} + private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} permission-contents: write - id: auth env: diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 4383721..82fd01b 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -9,6 +9,10 @@ on: description: Repository script and arguments, run with the tag appended, for versions stated outside the manifest type: string default: "" + secrets: + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. Declared so the preflight job can read the release environment's secret of this name. + required: false outputs: version: description: Manifest version of the merged commit. Every tracked Helm Chart.yaml version and appVersion must equal it. @@ -21,7 +25,7 @@ on: minor: value: ${{ jobs.check.outputs.minor }} release: - description: false when the version already has a GitHub release, so a push that keeps the version publishes nothing + description: false when the v tag already exists, so a push that keeps the version publishes nothing value: ${{ jobs.check.outputs.release }} permissions: @@ -37,11 +41,10 @@ jobs: minor: ${{ steps.manifest.outputs.minor }} release: ${{ steps.new.outputs.release }} steps: - - if: github.ref != format('refs/heads/{0}', github.event.repository.default_branch) - env: - TRUNK: ${{ github.event.repository.default_branch }} + # The release environment also allows only stable; this fails earlier, before anything is built. + - if: github.ref != 'refs/heads/stable' run: | - echo "::error::releases run from $TRUNK only" + echo "::error::releases run from stable only" exit 1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -64,14 +67,54 @@ jobs: run: | read -ra cmd <<<"$SCRIPT" "./${cmd[0]}" "${cmd[@]:1}" "$TAG" + # The tag is created last and can't be moved, so it is the record that a version was released. + # A version already on a registry with no tag means an earlier release stopped part way, or the + # version predates this workflow: releasing it again would tag a commit the artifacts weren't + # built from. Re-run the original release, or bump the version. - id: new env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.manifest.outputs.tag }} + VERSION: ${{ steps.manifest.outputs.version }} + PYPI_NAME: ${{ steps.manifest.outputs.pypi_name }} + CRATE_NAME: ${{ steps.manifest.outputs.crate_name }} run: | - if gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::notice::$TAG is already released, nothing to publish" + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then + echo "::notice::$TAG exists, nothing to publish" echo "release=false" >> "$GITHUB_OUTPUT" - else - echo "release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + found=() + if [ -n "$PYPI_NAME" ] && curl -fsS -o /dev/null "https://pypi.org/pypi/$PYPI_NAME/$VERSION/json"; then + found+=("PyPI") + fi + if [ -n "$CRATE_NAME" ] && curl -fsS -o /dev/null -A "blacklanternsecurity-release-check" "https://crates.io/api/v1/crates/$CRATE_NAME/$VERSION"; then + found+=("crates.io") fi + if [ "${#found[@]}" -gt 0 ]; then + echo "::error::$VERSION is already on ${found[*]} but $TAG doesn't exist. Re-run the release that published it, or bump the version." + exit 1 + fi + echo "release=true" >> "$GITHUB_OUTPUT" + + # Fails before anything is published if the release App can't be used: the environment's variable + # or secret is missing, the key is wrong, or the App isn't installed on this repository. Runs in the + # release environment, the only place those values exist. + preflight: + needs: check + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-24.04 + environment: release + permissions: {} + steps: + - env: + APP_ID: ${{ vars.BLS_RELEASE_APP_ID }} + KEY_SET: ${{ secrets.BLS_RELEASE_PRIVATE_KEY != '' }} + run: | + [ -n "$APP_ID" ] || { echo "::error::release environment variable BLS_RELEASE_APP_ID is not set"; exit 1; } + [ "$KEY_SET" = "true" ] || { echo "::error::release environment secret BLS_RELEASE_PRIVATE_KEY is not set"; exit 1; } + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.BLS_RELEASE_APP_ID }} + private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} + permission-contents: read diff --git a/scripts/manifest.py b/scripts/manifest.py index fb8883d..e877352 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -154,11 +154,24 @@ def git_dependencies(root): return found +RELEASE_VERSION = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") + + +def package_names(root): + """Registry names the release check looks the version up under: the PyPI project and, for Rust, the crate.""" + pyproject = load(root / "pyproject.toml") + pypi = pyproject.get("project", {}).get("name") or pyproject.get("tool", {}).get("poetry", {}).get("name") or "" + manifest = pyproject.get("tool", {}).get("maturin", {}).get("manifest-path", "Cargo.toml") + crate = load(root / manifest).get("package", {}).get("name") or "" + return pypi, crate + + def cmd_release(args): declared = version(args.root) - parsed = Version(declared) - if parsed.is_prerelease or parsed.is_devrelease or str(parsed) != declared: + # Exactly MAJOR.MINOR.PATCH: packaging also accepts 1.2, 1.2.3.4, .postN, epochs and +local, none of which is a release here. + if not RELEASE_VERSION.fullmatch(declared): sys.exit(f"{declared} is not a MAJOR.MINOR.PATCH release version") + parsed = Version(declared) for chart in charts(args.root): for field, value in chart_fields(chart).items(): if value != declared: @@ -166,7 +179,8 @@ def cmd_release(args): pinned = git_dependencies(args.root) if pinned: sys.exit(f"stable must pin released versions, not git sources: {', '.join(pinned)}") - emit(version=declared, tag=f"v{declared}", major=str(parsed.major), minor=str(parsed.minor)) + pypi, crate = package_names(args.root) + emit(version=declared, tag=f"v{declared}", major=str(parsed.major), minor=str(parsed.minor), pypi_name=pypi, crate_name=crate) def cmd_notes(args): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index e4cf9d6..b4c1e87 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -92,7 +92,14 @@ def test_release_reads_the_manifest_version(repo): root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"helm/Chart.yaml": 'name: x\nversion: 1.2.3\nappVersion: "1.2.3"\n'}) subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "add", "."], cwd=root, check=True) - assert outputs(run(root, "release")) == {"version": "1.2.3", "tag": "v1.2.3", "major": "1", "minor": "2"} + assert outputs(run(root, "release")) == { + "version": "1.2.3", + "tag": "v1.2.3", + "major": "1", + "minor": "2", + "pypi_name": "x", + "crate_name": "", + } def test_release_refuses_a_chart_behind_the_manifest(repo): @@ -103,7 +110,9 @@ def test_release_refuses_a_chart_behind_the_manifest(repo): assert result.returncode != 0 and "appVersion" in result.stderr -@pytest.mark.parametrize("declared", ["1.2.3rc4", "1.2.3.dev1", "1.2.3-beta.1"]) +@pytest.mark.parametrize( + "declared", ["1.2.3rc4", "1.2.3.dev1", "1.2.3-beta.1", "1.2", "1.2.3.4", "1.2.3.post1", "1!1.2.3", "1.2.3+local", "01.2.3", "v1.2.3"] +) def test_release_refuses_a_prerelease_version(repo, declared): assert run(repo(f'[project]\nname = "x"\nversion = "{declared}"\n'), "release").returncode != 0 @@ -128,3 +137,12 @@ def test_release_refuses_git_crates(repo): ) result = run(root, "release") assert result.returncode != 0 and "blastdns" in result.stderr + + +def test_release_names_the_crate_for_maturin(repo): + root = repo( + '[build-system]\nbuild-backend = "maturin"\n[project]\nname = "radixtarget"\ndynamic = ["version"]\n', + {"Cargo.toml": '[package]\nname = "radixtarget"\nversion = "4.4.0"\n'}, + ) + out = outputs(run(root, "release")) + assert (out["pypi_name"], out["crate_name"]) == ("radixtarget", "radixtarget") From 6fc2cf5fe9b8458452c494e8f3285d4aa250e076 Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Wed, 21 Oct 2026 12:08:13 -0400 Subject: [PATCH 3/4] fix(release-check): refuse to release into an unprotected environment A job that names a missing environment makes GitHub create it with no protection. The sandbox's first publish run did exactly that (preflight failed closed and nothing was published, but release was created with no reviewers, no branch policy and admin bypass on). The check job now reads the release environment from outside it, before any job enters it, and fails unless: it exists; admins can't bypass it; deployments are limited to the stable branch only; and, on public repositories, it has required reviewers with prevent self-review (private repositories on Team can't have environment reviewers). Anything unreadable fails the run. The check job needs actions: read; callers must grant it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-check.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 82fd01b..b430c13 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -34,6 +34,9 @@ permissions: jobs: check: runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read # read the release environment's protection rules (the caller must grant it too) outputs: version: ${{ steps.manifest.outputs.version }} tag: ${{ steps.manifest.outputs.tag }} @@ -96,6 +99,27 @@ jobs: exit 1 fi echo "release=true" >> "$GITHUB_OUTPUT" + # A job that names a missing environment makes GitHub create it with no protection, so check the + # release environment from outside it before any job enters it. Fails closed: anything unreadable + # or unexpected stops the run. Private repositories on the Team plan can't have environment + # reviewers, so for them only the branch policy and the admin bypass are checked. + - if: steps.new.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + PRIVATE: ${{ github.event.repository.private }} + run: | + fail() { echo "::error::release environment: $1"; exit 1; } + env_json=$(gh api "repos/$GITHUB_REPOSITORY/environments/release" 2>&1) || fail "can't be read ($env_json). Create and protect it before releasing." + [ "$(jq -r '.can_admins_bypass' <<<"$env_json")" = "false" ] || fail "admins can bypass it; turn that off" + [ "$(jq -r '.deployment_branch_policy.custom_branch_policies' <<<"$env_json")" = "true" ] || fail "deployments aren't limited to selected branches" + policies=$(gh api "repos/$GITHUB_REPOSITORY/environments/release/deployment-branch-policies" --jq '[.branch_policies[] | "\(.type // "branch"):\(.name)"] | sort | join(",")') || fail "its branch policies can't be read" + [ "$policies" = "branch:stable" ] || fail "it must allow the stable branch only, not '$policies'" + if [ "$PRIVATE" != "true" ]; then + reviewers=$(jq '[.protection_rules[] | select(.type == "required_reviewers")][0]' <<<"$env_json") + [ "$(jq '.reviewers | length' <<<"$reviewers")" -gt 0 ] 2>/dev/null || fail "it has no required reviewers" + [ "$(jq -r '.prevent_self_review' <<<"$reviewers")" = "true" ] || fail "prevent self-review is off" + fi + echo "::notice::release environment is protected" # Fails before anything is published if the release App can't be used: the environment's variable # or secret is missing, the key is wrong, or the App isn't installed on this repository. Runs in the From 31d721990c676a274e4edbc55dfe841ddf6995b6 Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Wed, 21 Oct 2026 17:21:00 -0400 Subject: [PATCH 4/4] feat(release-check): every released commit must come through a merged PR 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 two 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, and a free extra control everywhere else, where direct pushes to stable shouldn't exist at all. The check job needs pull-requests: read; callers must grant it. Verified: 8 new tests (32 in total); 3 mutations of the rule each fail them; on cloudcheck's real v11.1.0..v11.2.0 history (158 commits: 112 bot data pushes, 46 through PRs) it passes with the data paths allowed, and on stable it fails with none allowed. Refs blacklanternsecurity/.github-config#16, blacklanternsecurity/bbot-enterprise#406 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-check.yml | 20 +++++++ scripts/provenance.py | 83 +++++++++++++++++++++++++++++ tests/test_provenance.py | 73 +++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 scripts/provenance.py create mode 100644 tests/test_provenance.py diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index b430c13..ed4e6f3 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -5,6 +5,13 @@ on: working-directory: type: string default: . + direct-push-paths: + description: >- + Space-separated paths a commit that didn't arrive through a merged pull request may touch + (cloudcheck's daily data files). Empty, the default: every commit since the last release + must come through a merged pull request. + type: string + default: "" check-script: description: Repository script and arguments, run with the tag appended, for versions stated outside the manifest type: string @@ -37,6 +44,7 @@ jobs: permissions: contents: read actions: read # read the release environment's protection rules (the caller must grant it too) + pull-requests: read # match each commit to the pull request it arrived in (the caller must grant it too) outputs: version: ${{ steps.manifest.outputs.version }} tag: ${{ steps.manifest.outputs.tag }} @@ -52,6 +60,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 # the history since the last release tag, for the provenance check - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} @@ -99,6 +108,17 @@ jobs: exit 1 fi echo "release=true" >> "$GITHUB_OUTPUT" + # Every commit being released must have arrived through a merged pull request, judged by + # pull-request association rather than commit author (anyone can set an author). A commit that + # bypassed review may touch only inputs.direct-push-paths, or the release fails. + - if: steps.new.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + ROOT: ${{ inputs.working-directory }} + ALLOW: ${{ inputs.direct-push-paths }} + run: | + read -ra allow <<<"$ALLOW" + python3 .shared/scripts/provenance.py --root "$ROOT" --repo "$GITHUB_REPOSITORY" --allow "${allow[@]}" # A job that names a missing environment makes GitHub create it with no protection, so check the # release environment from outside it before any job enters it. Fails closed: anything unreadable # or unexpected stops the run. Private repositories on the Team plan can't have environment diff --git a/scripts/provenance.py b/scripts/provenance.py new file mode 100644 index 0000000..0572bb2 --- /dev/null +++ b/scripts/provenance.py @@ -0,0 +1,83 @@ +"""Check that every commit being released arrived through a merged pull request. + +Judged by pull-request association, not by commit author, which anyone can set. +A commit that didn't come through a merged pull request is allowed only if it +touches nothing but the paths the caller lists (cloudcheck's daily data files); +with no paths listed, any such commit fails the release. + +Usage (in release-check.yml): provenance.py --root ROOT --since-tag-pattern 'v*' [--allow PATH ...] +Needs git history (fetch-depth 0) and `gh` authenticated with contents and +pull-requests read. +""" + +import argparse +import json +import subprocess +import sys + + +def violations(commits, merged_pr, changed_files, allowed): + """Return (sha, files) for each commit that broke the rule. + + commits: shas to check. merged_pr(sha) -> bool. changed_files(sha) -> list of paths. + allowed: paths a commit outside a merged pull request may touch. + """ + found = [] + allowed = set(allowed) + for sha in commits: + if merged_pr(sha): + continue + files = changed_files(sha) + outside = sorted(f for f in files if f not in allowed) + if not allowed or outside or not files: + found.append((sha, outside or files)) + return found + + +def git(root, *args): + return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True).stdout + + +def previous_tag(root, pattern): + """The most recent release tag reachable from HEAD, or None on a first release.""" + result = subprocess.run(["git", "describe", "--tags", "--abbrev=0", "--match", pattern, "HEAD"], cwd=root, capture_output=True, text=True) + return result.stdout.strip() or None + + +def gh_merged_pr(repo): + def lookup(sha): + out = subprocess.run(["gh", "api", f"repos/{repo}/commits/{sha}/pulls"], check=True, capture_output=True, text=True).stdout + return any(pr.get("merged_at") for pr in json.loads(out)) + + return lookup + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--repo", required=True, help="owner/name, for the pull-request lookups") + parser.add_argument("--since-tag-pattern", default="v*") + parser.add_argument("--allow", nargs="*", default=[], help="paths a commit outside a merged pull request may touch") + args = parser.parse_args() + + tag = previous_tag(args.root, args.since_tag_pattern) + if tag is None: + print(f"::notice::no earlier {args.since_tag_pattern} tag: first release, provenance not checked") + return + commits = git(args.root, "rev-list", f"{tag}..HEAD").split() + print(f"checking {len(commits)} commit(s) since {tag}") + + def changed_files(sha): + return [f for f in git(args.root, "diff-tree", "--no-commit-id", "--name-only", "-r", "-m", sha).splitlines() if f] + + bad = violations(commits, gh_merged_pr(args.repo), changed_files, args.allow) + for sha, files in bad: + print(f"::error::{sha[:12]} didn't arrive through a merged pull request and touches {', '.join(files) or 'nothing'}") + if bad: + allowed = ", ".join(args.allow) or "no paths" + sys.exit(f"{len(bad)} commit(s) since {tag} bypassed review outside the allowed paths ({allowed})") + print("every commit arrived through a merged pull request, or touched only the allowed paths") + + +if __name__ == "__main__": + main() diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..2094ad9 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,73 @@ +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +from provenance import git, previous_tag, violations # noqa: E402 + +DATA = ["cloud_providers_v3.json", "README.md"] + + +def lookup(table): + return lambda sha: table[sha] + + +def test_every_commit_through_a_merged_pr_passes(): + assert violations(["a", "b"], lambda s: True, lambda s: ["src/lib.rs"], []) == [] + + +def test_direct_push_of_data_only_passes_when_allowed(): + files = lookup({"a": ["cloud_providers_v3.json", "README.md"]}) + assert violations(["a"], lambda s: False, files, DATA) == [] + + +def test_direct_push_touching_code_fails_even_with_data_allowed(): + files = lookup({"a": ["cloud_providers_v3.json", "src/lib.rs"]}) + assert violations(["a"], lambda s: False, files, DATA) == [("a", ["src/lib.rs"])] + + +def test_direct_push_fails_when_no_paths_are_allowed(): + files = lookup({"a": ["cloud_providers_v3.json"]}) + assert violations(["a"], lambda s: False, files, []) == [("a", ["cloud_providers_v3.json"])] + + +def test_a_workflow_change_outside_a_pr_fails(): + files = lookup({"a": [".github/workflows/daily-update.yml"]}) + assert violations(["a"], lambda s: False, files, DATA) == [("a", [".github/workflows/daily-update.yml"])] + + +def test_empty_direct_commit_fails(): + assert violations(["a"], lambda s: False, lambda s: [], DATA) == [("a", [])] + + +def test_mixed_history_reports_only_the_bad_commits(): + merged = lookup({"pr1": True, "bot1": False, "evil": False, "pr2": True}) + files = lookup({"bot1": ["cloud_providers_v3.json"], "evil": ["Cargo.toml"]}) + assert violations(["pr1", "bot1", "evil", "pr2"], merged, files, DATA) == [("evil", ["Cargo.toml"])] + + +def commit(root, path, text, message): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(text) + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", message], cwd=root, check=True) + return git(root, "rev-parse", "HEAD").strip() + + +def test_history_since_the_previous_release_tag(tmp_path): + subprocess.run(["git", "init", "-q", "-b", "stable"], cwd=tmp_path, check=True) + first = commit(tmp_path, "src/lib.rs", "1", "initial") + assert previous_tag(tmp_path, "v*") is None # first release: nothing to compare against + subprocess.run(["git", "tag", "v1.0.0", first], cwd=tmp_path, check=True) + subprocess.run(["git", "tag", "not-a-release", first], cwd=tmp_path, check=True) + data = commit(tmp_path, "cloud_providers_v3.json", "{}", "daily data") + code = commit(tmp_path, "src/lib.rs", "2", "sneaky code") + assert previous_tag(tmp_path, "v*") == "v1.0.0" + commits = git(tmp_path, "rev-list", "v1.0.0..HEAD").split() + assert set(commits) == {data, code} + + def changed(sha): + return [f for f in git(tmp_path, "diff-tree", "--no-commit-id", "--name-only", "-r", "-m", sha).splitlines() if f] + + assert violations(commits, lambda s: False, changed, DATA) == [(code, ["src/lib.rs"])] + assert violations(commits, lambda s: s == code, changed, DATA) == []