From 4894c4adb0c98b978c7c621903e1f3205fc446b7 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Wed, 21 Oct 2026 05:43:04 -0700 Subject: [PATCH 1/7] 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 b9336cdb8d15b7c41ff6e02d23ac653992c8d348 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Fri, 23 Oct 2026 04:02:51 -0700 Subject: [PATCH 2/7] fix(release): address review on release-on-merge 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 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 --- .github/workflows/crates.yml | 6 +- .github/workflows/docker-dev.yml | 63 +++++++++++++++++ .github/workflows/docker.yml | 20 +++--- .github/workflows/publish.yml | 104 +++++++++++++--------------- .github/workflows/release-check.yml | 63 ++++++++++++++--- scripts/manifest.py | 46 +++++++++--- tests/test_manifest.py | 46 ++++++++++-- 7 files changed, 258 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/docker-dev.yml diff --git a/.github/workflows/crates.yml b/.github/workflows/crates.yml index af958ab..250c960 100644 --- a/.github/workflows/crates.yml +++ b/.github/workflows/crates.yml @@ -36,7 +36,11 @@ jobs: env: VERSION: ${{ inputs.version }} run: | - crate=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name') + crate=$(cargo metadata --no-deps --format-version 1 | jq -r --arg m "$PWD/Cargo.toml" '.packages[] | select(.manifest_path == $m) | .name') + if [ -z "$crate" ]; then + echo "::error::no [package] in $PWD/Cargo.toml" + exit 1 + fi 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" diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 0000000..8498daa --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,63 @@ +name: Docker dev +on: + workflow_call: + inputs: + image: + description: GHCR repository under ghcr.io, such as blacklanternsecurity/bbot + type: string + required: true + +permissions: + contents: read + +jobs: + plan: + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-24.04 + outputs: + builds: ${{ steps.read.outputs.builds }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - id: read + env: + IMAGE: ghcr.io/${{ inputs.image }} + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py images "$IMAGE" dev + + build: + needs: plan + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + strategy: + matrix: + build: ${{ fromJSON(needs.plan.outputs.builds) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: ${{ matrix.build.file }} + push: true + tags: | + ${{ matrix.build.ref }} + ghcr.io/${{ inputs.image }}:dev-${{ github.sha }}${{ matrix.build.suffix }} + cache-from: type=gha,scope=dev-${{ matrix.build.file }} + cache-to: type=gha,mode=max,scope=dev-${{ matrix.build.file }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7045b81..71b9031 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,6 +20,13 @@ on: description: Push README.md to the Docker Hub repository description type: boolean default: false + 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 @@ -89,17 +96,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.build.file }} cache-to: type=gha,mode=max,scope=${{ matrix.build.file }} - - description: - if: inputs.description - needs: build - runs-on: ubuntu-24.04 - environment: release - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + - if: inputs.description && matrix.build.suffix == '' + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5954cf9..6e86569 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,15 +38,54 @@ 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 + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. Declared so the release environment's secret of this name resolves. + required: false permissions: contents: read jobs: + image-sbom: + if: inputs.images != '[]' + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + matrix: + image: ${{ fromJSON(inputs.images) }} + steps: + - id: name + env: + IMAGE: ${{ matrix.image }} + TAG: ${{ inputs.tag }} + run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${TAG}.spdx.json" >> "$GITHUB_OUTPUT" + - id: auth + env: + TOKEN: ${{ secrets.registry-token }} + run: echo "login=$([ -n "$TOKEN" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - if: steps.auth.outputs.login == 'true' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.registry-username }} + password: ${{ secrets.registry-token }} + - uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + with: + image: ${{ matrix.image }} + format: spdx-json + output-file: ${{ steps.name.outputs.file }} + upload-artifact: false + upload-release-assets: false + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: image-sbom-${{ steps.name.outputs.file }} + path: ${{ steps.name.outputs.file }} + if-no-files-found: error + release: + needs: image-sbom + if: ${{ !cancelled() && needs.image-sbom.result != 'failure' }} runs-on: ubuntu-24.04 environment: release permissions: @@ -59,8 +94,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: @@ -77,6 +112,12 @@ jobs: pattern: ${{ inputs.artifacts }} path: dist merge-multiple: true + - if: inputs.images != '[]' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: image-sbom-* + path: sbom + merge-multiple: true - uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 with: path: . @@ -104,48 +145,3 @@ jobs: files=(sbom/*.spdx.json) for asset in $ASSETS; do files+=("$asset"); done gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "${files[@]}" - - image-sbom: - if: inputs.images != '[]' - needs: release - runs-on: ubuntu-24.04 - environment: release - permissions: - contents: read - strategy: - matrix: - image: ${{ fromJSON(inputs.images) }} - steps: - - id: name - env: - IMAGE: ${{ matrix.image }} - 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 }} - run: echo "login=$([ -n "$TOKEN" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - if: steps.auth.outputs.login == 'true' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ${{ inputs.registry }} - username: ${{ inputs.registry-username }} - password: ${{ secrets.registry-token }} - - uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 - with: - image: ${{ matrix.image }} - format: spdx-json - output-file: ${{ steps.name.outputs.file }} - upload-artifact: false - upload-release-assets: false - - env: - 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 4383721..8399e23 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -9,6 +9,14 @@ on: description: Repository script and arguments, run with the tag appended, for versions stated outside the manifest type: string default: "" + image: + description: Docker Hub repository this release pushes, so a version already pushed without its tag is caught + type: string + default: "" + secrets: + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. Declared so the release environment's secret of this name resolves. + required: false outputs: version: description: Manifest version of the merged commit. Every tracked Helm Chart.yaml version and appVersion must equal it. @@ -21,7 +29,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 +45,9 @@ 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 }} + - 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: @@ -68,10 +74,49 @@ jobs: 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 }} + IMAGE: ${{ inputs.image }} run: | - if gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::notice::$TAG is already released, nothing to publish" + status=$(gh api -i "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" 2>/dev/null | head -1 | cut -d' ' -f2 || true) + if [ "$status" = 200 ]; then + echo "::notice::$TAG exists, nothing to publish" echo "release=false" >> "$GITHUB_OUTPUT" - else - echo "release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$status" != 404 ]; then + echo "::error::could not read tag $TAG (HTTP ${status:-none})" + exit 1 + fi + registry() { + code=$(curl -s -o /dev/null -w '%{http_code}' -A "$GITHUB_REPOSITORY" "$1") + case "$code" in + 200) found+=("$2") ;; + 404) ;; + *) echo "::error::could not query $2 (HTTP $code)"; exit 1 ;; + esac + } + found=() + [ -z "$PYPI_NAME" ] || registry "https://pypi.org/pypi/$PYPI_NAME/$VERSION/json" PyPI + [ -z "$CRATE_NAME" ] || registry "https://crates.io/api/v1/crates/$CRATE_NAME/$VERSION" crates.io + [ -z "$IMAGE" ] || registry "https://hub.docker.com/v2/repositories/$IMAGE/tags/$VERSION" "Docker Hub" + if [ ${#found[@]} -gt 0 ]; then + echo "::error::$TAG is partly published from another run (${found[*]}); re-run that run or bump the version" + exit 1 fi + echo "release=true" >> "$GITHUB_OUTPUT" + + credentials: + needs: check + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-24.04 + environment: release + permissions: + contents: read + steps: + - 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: write diff --git a/scripts/manifest.py b/scripts/manifest.py index fb8883d..bc868c7 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -14,6 +14,8 @@ from packaging.version import Version MANIFESTS = ("pyproject.toml", "Cargo.toml", "package.json") +RELEASE_VERSION = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") +CARGO_TABLES = ("dependencies", "build-dependencies") class Backend(Enum): @@ -138,35 +140,59 @@ def cmd_matrix(args): ) +def unreleased(requirement): + """A PEP 508 direct reference (`name @ url`) installs whatever the URL serves, never a registry release.""" + return "@" in requirement.split(";", 1)[0] + + +def git_specs(table): + return [name for name, spec in table.items() if isinstance(spec, dict) and "git" in spec] + + 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] + found = [d for d in project.get("dependencies", []) if unreleased(d)] for group in project.get("optional-dependencies", {}).values(): - found += [d for d in group if "git+" in d] + found += [d for d in group if unreleased(d)] tool = pyproject.get("tool", {}) - found += [name for name, source in tool.get("uv", {}).get("sources", {}).items() if "git" in source] + for name, source in tool.get("uv", {}).get("sources", {}).items(): + sources = source if isinstance(source, list) else [source] + if any(isinstance(s, dict) and ("git" in s or "url" in s) for s in sources): + found.append(name) 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] + found += git_specs(tool.get("poetry", {}).get(table, {})) 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] + tables = [cargo.get(t, {}) for t in CARGO_TABLES] + tables += [target.get(t, {}) for target in cargo.get("target", {}).values() for t in CARGO_TABLES] + tables.append(cargo.get("workspace", {}).get("dependencies", {})) + for table in tables: + found += git_specs(table) return found +def package_names(root): + 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: + 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: 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)) + sys.exit(f"stable must pin released versions, not git or URL sources: {', '.join(pinned)}") + 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..4f6e348 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -92,7 +92,23 @@ 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_names_the_crate(repo): + root = repo( + '[build-system]\nbuild-backend = "maturin"\n[project]\nname = "x-py"\ndynamic = ["version"]\n', + {"Cargo.toml": '[package]\nname = "x"\nversion = "1.0.0"\n'}, + ) + out = outputs(run(root, "release")) + assert (out["pypi_name"], out["crate_name"]) == ("x-py", "x") def test_release_refuses_a_chart_behind_the_manifest(repo): @@ -103,9 +119,13 @@ 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"], +) def test_release_refuses_a_prerelease_version(repo, declared): - assert run(repo(f'[project]\nname = "x"\nversion = "{declared}"\n'), "release").returncode != 0 + result = run(repo(f'[project]\nname = "x"\nversion = "{declared}"\n'), "release") + assert result.returncode != 0 and "MAJOR.MINOR.PATCH" in result.stderr @pytest.mark.parametrize( @@ -114,6 +134,9 @@ def test_release_refuses_a_prerelease_version(repo, declared): '[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', + '[project]\nname = "x"\nversion = "1.0.0"\n[tool.uv.sources]\nradixtarget = [{ git = "https://github.com/blacklanternsecurity/radixtarget", marker = "sys_platform == \'linux\'" }, { index = "pypi" }]\n', + '[project]\nname = "x"\nversion = "1.0.0"\ndependencies = ["radixtarget @ https://github.com/blacklanternsecurity/radixtarget/archive/dev.zip"]\n', + '[project]\nname = "x"\nversion = "1.0.0"\n[tool.uv.sources]\nradixtarget = { url = "https://github.com/blacklanternsecurity/radixtarget/archive/dev.zip" }\n', ], ) def test_release_refuses_git_dependencies(repo, pyproject): @@ -121,10 +144,23 @@ def test_release_refuses_git_dependencies(repo, pyproject): assert result.returncode != 0 and "radixtarget" in result.stderr -def test_release_refuses_git_crates(repo): +@pytest.mark.parametrize( + "cargo", + [ + '[package]\nname = "x"\nversion = "1.0.0"\n[dependencies]\nblastdns = { git = "https://github.com/blacklanternsecurity/blastdns" }\n', + '[package]\nname = "x"\nversion = "1.0.0"\n[target.\'cfg(unix)\'.dependencies]\nblastdns = { git = "https://github.com/blacklanternsecurity/blastdns" }\n', + '[package]\nname = "x"\nversion = "1.0.0"\n[dependencies]\nblastdns = { workspace = true }\n[workspace.dependencies]\nblastdns = { git = "https://github.com/blacklanternsecurity/blastdns" }\n', + ], +) +def test_release_refuses_git_crates(repo, cargo): 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'}, + {"Cargo.toml": cargo}, ) result = run(root, "release") assert result.returncode != 0 and "blastdns" in result.stderr + + +def test_release_allows_registry_dependencies_with_markers(repo): + root = repo('[project]\nname = "x"\nversion = "1.0.0"\ndependencies = ["radixtarget>=3; python_version >= \'3.11\'", "y[extra]~=1.0"]\n') + assert run(root, "release").returncode == 0 From 8a97ed910c1c073f292f716e3f1eacc35a35ef0c Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Fri, 23 Oct 2026 04:19:02 -0700 Subject: [PATCH 3/7] fix(ci): declare the secrets shared workflows read 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. --- .github/workflows/cla-reusable.yml | 5 +++++ .github/workflows/python-tests.yml | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index 4cd68af..85b569b 100644 --- a/.github/workflows/cla-reusable.yml +++ b/.github/workflows/cla-reusable.yml @@ -1,6 +1,11 @@ name: "CLA Assistant (Reusable)" on: workflow_call: + secrets: + APP_ID: + required: true + APP_PRIVATE_KEY: + required: true permissions: {} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3fc056e..29dbbf0 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -23,7 +23,7 @@ on: type: string default: "{}" secret-env: - description: JSON list of caller secret names exported under the same name. Requires secrets inherit. + description: JSON list of secret names, each declared below and passed by name, exported under the same name type: string default: "[]" coverage: @@ -37,6 +37,11 @@ on: lint: type: boolean default: true + secrets: + CODECOV_TOKEN: + required: false + BBOT_IO_API_KEY: + required: false outputs: python-versions: value: ${{ jobs.matrix.outputs.versions }} From 07e14bcbba5e737e1b29bd9e3f9128fbf2483be9 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Fri, 23 Oct 2026 04:22:13 -0700 Subject: [PATCH 4/7] feat(ci): add a stable guard callers run on pull requests into stable 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. --- .github/workflows/stable-guard.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/stable-guard.yml diff --git a/.github/workflows/stable-guard.yml b/.github/workflows/stable-guard.yml new file mode 100644 index 0000000..e3071de --- /dev/null +++ b/.github/workflows/stable-guard.yml @@ -0,0 +1,29 @@ +name: Stable guard +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + +permissions: + contents: read + +jobs: + release-ready: + name: release ready + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - env: + ROOT: ${{ inputs.working-directory }} + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" release From 0116ff630968be720ff95d34bd3d813bc4014ce3 Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Mon, 26 Oct 2026 06:49:22 -0700 Subject: [PATCH 5/7] fix(release-check): refuse to release into an unprotected environment 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. --- .github/workflows/release-check.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index b5feed2..090d0a1 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -38,6 +38,9 @@ permissions: jobs: check: runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read outputs: version: ${{ steps.manifest.outputs.version }} tag: ${{ steps.manifest.outputs.tag }} @@ -106,6 +109,24 @@ jobs: exit 1 fi echo "release=true" >> "$GITHUB_OUTPUT" + # GitHub auto-creates a missing environment unprotected; Team-plan private repos can't have reviewers. + - 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" credentials: needs: check From 812db99a39f3244b7d6c50453f0299cfc07d0830 Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Mon, 26 Oct 2026 06:50:28 -0700 Subject: [PATCH 6/7] 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 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 --- .github/workflows/release-check.yml | 18 +++++++ scripts/provenance.py | 83 +++++++++++++++++++++++++++++ tests/test_provenance.py | 73 +++++++++++++++++++++++++ 3 files changed, 174 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 090d0a1..237ec49 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 @@ -41,6 +48,7 @@ jobs: permissions: contents: read actions: read + pull-requests: read outputs: version: ${{ steps.manifest.outputs.version }} tag: ${{ steps.manifest.outputs.tag }} @@ -55,6 +63,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} @@ -109,6 +118,15 @@ jobs: exit 1 fi echo "release=true" >> "$GITHUB_OUTPUT" + # Judged by PR association, not commit author, which anyone can set. + - 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[@]}" # GitHub auto-creates a missing environment unprotected; Team-plan private repos can't have reviewers. - if: steps.new.outputs.release == 'true' env: 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) == [] From 9c75e85c3252b8235d8e22d25a883b42eb65177f Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Mon, 26 Oct 2026 09:34:33 -0700 Subject: [PATCH 7/7] fix(provenance): require the PR be merged into stable, walk first parents 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. --- scripts/provenance.py | 30 +++++++++++++++++----------- tests/test_provenance.py | 42 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/scripts/provenance.py b/scripts/provenance.py index 0572bb2..0aa34e0 100644 --- a/scripts/provenance.py +++ b/scripts/provenance.py @@ -1,11 +1,13 @@ -"""Check that every commit being released arrived through a merged pull request. +"""Check that every commit being released arrived through a pull request merged into the release branch. Judged by pull-request association, not by commit author, which anyone can set. +Only the first-parent line is walked: commits off it came in through the merge +that brought them. A pull request merged into another branch doesn't count. 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 ...] +Usage (in release-check.yml): provenance.py --root ROOT --repo OWNER/NAME [--base stable] [--allow PATH ...] Needs git history (fetch-depth 0) and `gh` authenticated with contents and pull-requests read. """ @@ -39,15 +41,20 @@ def git(root, *args): 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 + """The most recent release tag reachable from HEAD, or None when the repo has no release tag at all.""" + if not git(root, "tag", "--list", pattern).strip(): + return None + return git(root, "describe", "--tags", "--abbrev=0", "--match", pattern, "HEAD").strip() -def gh_merged_pr(repo): +def merged_into(pulls, base): + return any(pr.get("merged_at") and pr.get("base", {}).get("ref") == base for pr in pulls) + + +def gh_merged_pr(repo, base): 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 merged_into(json.loads(out), base) return lookup @@ -57,6 +64,7 @@ def main(): 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("--base", default="stable", help="the branch a pull request must have been merged into") parser.add_argument("--allow", nargs="*", default=[], help="paths a commit outside a merged pull request may touch") args = parser.parse_args() @@ -64,19 +72,19 @@ def main(): 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() + commits = git(args.root, "rev-list", "--first-parent", 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) + bad = violations(commits, gh_merged_pr(args.repo, args.base), 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'}") + print(f"::error::{sha[:12]} didn't arrive through a pull request merged into {args.base} 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") + print(f"every commit arrived through a pull request merged into {args.base}, or touched only the allowed paths") if __name__ == "__main__": diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 2094ad9..cc1292e 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -2,8 +2,10 @@ import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) -from provenance import git, previous_tag, violations # noqa: E402 +from provenance import git, merged_into, previous_tag, violations # noqa: E402 DATA = ["cloud_providers_v3.json", "README.md"] @@ -71,3 +73,41 @@ def changed(sha): assert violations(commits, lambda s: False, changed, DATA) == [(code, ["src/lib.rs"])] assert violations(commits, lambda s: s == code, changed, DATA) == [] + + +def test_a_pr_merged_into_another_branch_does_not_count(): + assert not merged_into([{"merged_at": "t", "base": {"ref": "dev"}}], "stable") + assert not merged_into([{"merged_at": None, "base": {"ref": "stable"}}], "stable") + assert merged_into([{"merged_at": "t", "base": {"ref": "dev"}}, {"merged_at": "t", "base": {"ref": "stable"}}], "stable") + + +def test_no_release_tag_at_all_is_a_first_release(tmp_path): + subprocess.run(["git", "init", "-q", "-b", "stable"], cwd=tmp_path, check=True) + commit(tmp_path, "a", "1", "initial") + subprocess.run(["git", "tag", "not-a-release"], cwd=tmp_path, check=True) + assert previous_tag(tmp_path, "v*") is None + + +def test_a_release_tag_not_reachable_from_head_is_an_error(tmp_path): + subprocess.run(["git", "init", "-q", "-b", "stable"], cwd=tmp_path, check=True) + commit(tmp_path, "a", "1", "initial") + subprocess.run(["git", "checkout", "-q", "-b", "side"], cwd=tmp_path, check=True) + commit(tmp_path, "b", "1", "side") + subprocess.run(["git", "tag", "v1.0.0"], cwd=tmp_path, check=True) + subprocess.run(["git", "checkout", "-q", "stable"], cwd=tmp_path, check=True) + with pytest.raises(subprocess.CalledProcessError): + previous_tag(tmp_path, "v*") + + +def test_only_the_first_parent_line_is_walked(tmp_path): + subprocess.run(["git", "init", "-q", "-b", "stable"], cwd=tmp_path, check=True) + base = commit(tmp_path, "a", "1", "initial") + subprocess.run(["git", "tag", "v1.0.0", base], cwd=tmp_path, check=True) + subprocess.run(["git", "checkout", "-q", "-b", "dev"], cwd=tmp_path, check=True) + side = commit(tmp_path, "b", "1", "dev work") + subprocess.run(["git", "checkout", "-q", "stable"], cwd=tmp_path, check=True) + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", "merge", "-q", "--no-ff", "-m", "merge dev", "dev"], cwd=tmp_path, check=True) + merge = git(tmp_path, "rev-parse", "HEAD").strip() + commits = git(tmp_path, "rev-list", "--first-parent", "v1.0.0..HEAD").split() + assert commits == [merge] + assert side not in commits