diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index e11f540..4343932 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/crates.yml b/.github/workflows/crates.yml index eca1857..250c960 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,27 @@ 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 --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" + 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-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 0000000..164ef26 --- /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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.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 9cd8bc2..5405f02 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 @@ -23,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 @@ -57,11 +61,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 +83,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: . @@ -94,16 +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 - 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 050e924..f160206 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,8 +6,8 @@ on: 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,16 +38,65 @@ on: registry-token: description: Token for pulling private images; omit for public images required: false + 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + 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: - contents: write + contents: read steps: + - id: app + 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -63,69 +112,36 @@ jobs: pattern: ${{ inputs.artifacts }} path: dist merge-multiple: true + - if: inputs.images != '[]' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: image-sbom-* + path: sbom + merge-multiple: true - uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.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) 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 - permissions: - contents: write - strategy: - matrix: - image: ${{ fromJSON(inputs.images) }} - steps: - - id: name - env: - IMAGE: ${{ matrix.image }} - run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${GITHUB_REF_NAME}.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.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: ${{ github.token }} - TAG: ${{ github.ref_name }} - FILE: ${{ steps.name.outputs.file }} - run: gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "$FILE" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1dadef6..ba22db0 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 }} diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 3f9dd3f..237ec49 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -5,24 +5,39 @@ 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 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, 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 v tag already exists, so a push that keeps the version publishes nothing + value: ${{ jobs.check.outputs.release }} permissions: contents: read @@ -30,20 +45,25 @@ permissions: jobs: check: runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + pull-requests: read 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 != 'refs/heads/stable' run: | - echo "::error::releases run on tag pushes only" + echo "::error::releases run from stable only" exit 1 - 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 }} @@ -51,15 +71,91 @@ jobs: path: .shared persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.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 }} + VERSION: ${{ steps.manifest.outputs.version }} + PYPI_NAME: ${{ steps.manifest.outputs.pypi_name }} + CRATE_NAME: ${{ steps.manifest.outputs.crate_name }} + IMAGE: ${{ inputs.image }} + run: | + 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" + 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" + # 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: + 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 + 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/.github/workflows/stable-guard.yml b/.github/workflows/stable-guard.yml new file mode 100644 index 0000000..7d1d565 --- /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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - env: + ROOT: ${{ inputs.working-directory }} + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" release diff --git a/scripts/manifest.py b/scripts/manifest.py index 054b66d..bc868c7 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -10,12 +10,12 @@ 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") +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): @@ -140,25 +140,59 @@ 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 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 unreleased(d)] + for group in project.get("optional-dependencies", {}).values(): + found += [d for d in group if unreleased(d)] + tool = pyproject.get("tool", {}) + 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 += git_specs(tool.get("poetry", {}).get(table, {})) + cargo = load(root / tool.get("maturin", {}).get("manifest-path", "Cargo.toml")) + 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) - if Version(declared) != Version(match["version"]): - sys.exit(f"tag {args.tag} does not match manifest version {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 != 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 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): @@ -177,16 +211,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 +228,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/provenance.py b/scripts/provenance.py new file mode 100644 index 0000000..0aa34e0 --- /dev/null +++ b/scripts/provenance.py @@ -0,0 +1,91 @@ +"""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 --repo OWNER/NAME [--base stable] [--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 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 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 merged_into(json.loads(out), base) + + 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("--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() + + 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", "--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, args.base), changed_files, args.allow) + for sha, files in bad: + 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(f"every commit arrived through a pull request merged into {args.base}, or touched only the allowed paths") + + +if __name__ == "__main__": + main() 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..4f6e348 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,81 @@ 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", + "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): + 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", "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): + 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( + "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', + '[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): + result = run(repo(pyproject), "release") + assert result.returncode != 0 and "radixtarget" in result.stderr + + +@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": 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 diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..cc1292e --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,113 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +from provenance import git, merged_into, 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) == [] + + +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