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..3fa3b75 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,15 @@ on: description: Push README.md to the Docker Hub repository description type: boolean default: false + # Read from the caller repository's release environment; callers pass nothing (and never + # secrets: inherit, which would hand this workflow every repository-level secret too). + secrets: + DOCKER_USERNAME: + description: Never passed by a caller. The release environment's secret of this name. + required: false + DOCKER_TOKEN: + description: Never passed by a caller. The release environment's secret of this name. + required: false outputs: images: description: JSON list of pushed version references, for publish.yml images @@ -57,11 +63,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 +85,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 +103,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..1692327 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,6 +38,12 @@ on: registry-token: description: Token for pulling private images; omit for public images required: false + # The release App's ID and key are read from the caller repository's release environment + # (vars.BLS_RELEASE_APP_ID, secrets.BLS_RELEASE_PRIVATE_KEY). Callers never pass them; the + # key is held nowhere else. Declared here only so the environment's secret can be read. + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. The release environment's secret of this name. + required: false permissions: contents: read @@ -45,9 +51,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: ${{ 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 @@ -67,26 +80,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 +108,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 +118,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: ${{ vars.BLS_RELEASE_APP_ID }} + private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} + permission-contents: write - id: auth env: TOKEN: ${{ secrets.registry-token }} @@ -125,7 +144,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..ed4e6f3 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -5,24 +5,35 @@ 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: "" + secrets: + BLS_RELEASE_PRIVATE_KEY: + description: Never passed by a caller. Declared so the preflight job can read the release environment's secret of this name. + required: false outputs: version: - description: Manifest version, 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 +41,26 @@ permissions: jobs: check: runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read # read the release environment's protection rules (the caller must grant it too) + pull-requests: read # match each commit to the pull request it arrived in (the caller must grant it too) 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' + # The release environment also allows only stable; this fails earlier, before anything is built. + - if: github.ref != 'refs/heads/stable' run: | - echo "::error::releases run 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 # the history since the last release tag, for the provenance check - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} @@ -51,15 +68,97 @@ 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" + # The tag is created last and can't be moved, so it is the record that a version was released. + # A version already on a registry with no tag means an earlier release stopped part way, or the + # version predates this workflow: releasing it again would tag a commit the artifacts weren't + # built from. Re-run the original release, or bump the version. + - id: new + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.manifest.outputs.tag }} + VERSION: ${{ steps.manifest.outputs.version }} + PYPI_NAME: ${{ steps.manifest.outputs.pypi_name }} + CRATE_NAME: ${{ steps.manifest.outputs.crate_name }} + run: | + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then + echo "::notice::$TAG exists, nothing to publish" + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + found=() + if [ -n "$PYPI_NAME" ] && curl -fsS -o /dev/null "https://pypi.org/pypi/$PYPI_NAME/$VERSION/json"; then + found+=("PyPI") + fi + if [ -n "$CRATE_NAME" ] && curl -fsS -o /dev/null -A "blacklanternsecurity-release-check" "https://crates.io/api/v1/crates/$CRATE_NAME/$VERSION"; then + found+=("crates.io") + fi + if [ "${#found[@]}" -gt 0 ]; then + echo "::error::$VERSION is already on ${found[*]} but $TAG doesn't exist. Re-run the release that published it, or bump the version." + exit 1 + fi + echo "release=true" >> "$GITHUB_OUTPUT" + # Every commit being released must have arrived through a merged pull request, judged by + # pull-request association rather than commit author (anyone can set an author). A commit that + # bypassed review may touch only inputs.direct-push-paths, or the release fails. + - if: steps.new.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + ROOT: ${{ inputs.working-directory }} + ALLOW: ${{ inputs.direct-push-paths }} + run: | + read -ra allow <<<"$ALLOW" + python3 .shared/scripts/provenance.py --root "$ROOT" --repo "$GITHUB_REPOSITORY" --allow "${allow[@]}" + # A job that names a missing environment makes GitHub create it with no protection, so check the + # release environment from outside it before any job enters it. Fails closed: anything unreadable + # or unexpected stops the run. Private repositories on the Team plan can't have environment + # reviewers, so for them only the branch policy and the admin bypass are checked. + - if: steps.new.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + PRIVATE: ${{ github.event.repository.private }} + run: | + fail() { echo "::error::release environment: $1"; exit 1; } + env_json=$(gh api "repos/$GITHUB_REPOSITORY/environments/release" 2>&1) || fail "can't be read ($env_json). Create and protect it before releasing." + [ "$(jq -r '.can_admins_bypass' <<<"$env_json")" = "false" ] || fail "admins can bypass it; turn that off" + [ "$(jq -r '.deployment_branch_policy.custom_branch_policies' <<<"$env_json")" = "true" ] || fail "deployments aren't limited to selected branches" + policies=$(gh api "repos/$GITHUB_REPOSITORY/environments/release/deployment-branch-policies" --jq '[.branch_policies[] | "\(.type // "branch"):\(.name)"] | sort | join(",")') || fail "its branch policies can't be read" + [ "$policies" = "branch:stable" ] || fail "it must allow the stable branch only, not '$policies'" + if [ "$PRIVATE" != "true" ]; then + reviewers=$(jq '[.protection_rules[] | select(.type == "required_reviewers")][0]' <<<"$env_json") + [ "$(jq '.reviewers | length' <<<"$reviewers")" -gt 0 ] 2>/dev/null || fail "it has no required reviewers" + [ "$(jq -r '.prevent_self_review' <<<"$reviewers")" = "true" ] || fail "prevent self-review is off" + fi + echo "::notice::release environment is protected" + + # Fails before anything is published if the release App can't be used: the environment's variable + # or secret is missing, the key is wrong, or the App isn't installed on this repository. Runs in the + # release environment, the only place those values exist. + preflight: + needs: check + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-24.04 + environment: release + permissions: {} + steps: + - env: + APP_ID: ${{ vars.BLS_RELEASE_APP_ID }} + KEY_SET: ${{ secrets.BLS_RELEASE_PRIVATE_KEY != '' }} + run: | + [ -n "$APP_ID" ] || { echo "::error::release environment variable BLS_RELEASE_APP_ID is not set"; exit 1; } + [ "$KEY_SET" = "true" ] || { echo "::error::release environment secret BLS_RELEASE_PRIVATE_KEY is not set"; exit 1; } + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.BLS_RELEASE_APP_ID }} + private-key: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} + permission-contents: read diff --git a/scripts/manifest.py b/scripts/manifest.py index 054b66d..e877352 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,49 @@ 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 + + +RELEASE_VERSION = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") + + +def package_names(root): + """Registry names the release check looks the version up under: the PyPI project and, for Rust, the crate.""" + pyproject = load(root / "pyproject.toml") + pypi = pyproject.get("project", {}).get("name") or pyproject.get("tool", {}).get("poetry", {}).get("name") or "" + manifest = pyproject.get("tool", {}).get("maturin", {}).get("manifest-path", "Cargo.toml") + crate = load(root / manifest).get("package", {}).get("name") or "" + return pypi, crate + + +def cmd_release(args): declared = version(args.root) - if Version(declared) != Version(match["version"]): - sys.exit(f"tag {args.tag} does not match manifest version {declared}") + # Exactly MAJOR.MINOR.PATCH: packaging also accepts 1.2, 1.2.3.4, .postN, epochs and +local, none of which is a release here. + if not RELEASE_VERSION.fullmatch(declared): + sys.exit(f"{declared} is not a MAJOR.MINOR.PATCH release version") + parsed = Version(declared) for chart in charts(args.root): for field, value in chart_fields(chart).items(): - if value != 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)}") + 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 +199,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 +216,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..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/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..b4c1e87 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,63 @@ 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_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", "v1.2.3"] +) +def test_release_refuses_a_prerelease_version(repo, declared): + assert run(repo(f'[project]\nname = "x"\nversion = "{declared}"\n'), "release").returncode != 0 + + +@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 + + +def test_release_names_the_crate_for_maturin(repo): + root = repo( + '[build-system]\nbuild-backend = "maturin"\n[project]\nname = "radixtarget"\ndynamic = ["version"]\n', + {"Cargo.toml": '[package]\nname = "radixtarget"\nversion = "4.4.0"\n'}, + ) + out = outputs(run(root, "release")) + assert (out["pypi_name"], out["crate_name"]) == ("radixtarget", "radixtarget") 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) == []