From ea9fb18014d8197a3dbd51af8dba5f199322df69 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:15:53 -0700 Subject: [PATCH 01/12] feat(ci): host shared test, release, and publish workflows The org .github repository is private, so public repositories cannot call workflows from it. CLA is public and already hosts the CLA reusable workflow, so the shared workflows live here instead. Every per-repository fact is read from the caller's own manifest by scripts/manifest.py: the Python test matrix from requires-python (or the poetry python constraint) intersected with released CPython minors uv knows, maturin detection from build-backend, and the release version from project.version, hatch version path, poetry version, or Cargo.toml. No workflow carries a version list. - python-versions.yml: matrix and maturin outputs. - python-tests.yml: lint, test (X.Y), passed. Hooks for setup and teardown scripts, env, secret-env, coverage, and artifacts. - rust-tests.yml: lint, test, passed, with setup and teardown hooks. - release-check.yml: run first on a tag, refuses non-conforming tags and manifest mismatch, outputs version and prerelease. - publish.yml: run last, creates the release with changelog or generated notes, attaches caller artifacts and SPDX SBOMs of the source tree and each pushed image. - pypi.yml: uv build and trusted publishing in a named environment. - scripts/release.sh: the one tag-cutting tool, trunk read from the remote HEAD and version checked through manifest.py. Also drops em dashes from cla-reusable.yml log lines. --- .github/actionlint.yaml | 5 + .github/workflows/cla-reusable.yml | 10 +- .github/workflows/publish.yml | 110 +++++++++++++++++ .github/workflows/pypi.yml | 31 +++++ .github/workflows/python-tests.yml | 148 +++++++++++++++++++++++ .github/workflows/python-versions.yml | 39 +++++++ .github/workflows/release-check.yml | 53 +++++++++ .github/workflows/rust-tests.yml | 81 +++++++++++++ .github/workflows/tests.yml | 23 ++++ .gitignore | 4 + pyproject.toml | 14 +++ scripts/manifest.py | 162 ++++++++++++++++++++++++++ scripts/release.sh | 34 ++++++ tests/test_manifest.py | 79 +++++++++++++ uv.lock | 120 +++++++++++++++++++ 15 files changed, 908 insertions(+), 5 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/pypi.yml create mode 100644 .github/workflows/python-tests.yml create mode 100644 .github/workflows/python-versions.yml create mode 100644 .github/workflows/release-check.yml create mode 100644 .github/workflows/rust-tests.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 scripts/manifest.py create mode 100755 scripts/release.sh create mode 100644 tests/test_manifest.py create mode 100644 uv.lock diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..1b41853 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +paths: + .github/workflows/**/*.yml: + ignore: + - 'property "workflow_(repository|sha)" is not defined' + - 'reusable workflow call "\$/' diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index 9e32e07..24e2128 100644 --- a/.github/workflows/cla-reusable.yml +++ b/.github/workflows/cla-reusable.yml @@ -31,7 +31,7 @@ jobs: for LOGIN in $COMMITTERS; do if [ -z "$LOGIN" ] || [ "$LOGIN" = "null" ]; then - echo "Unknown committer (no GitHub login) — not exempt" + echo "Unknown committer (no GitHub login): not exempt" ALL_EXEMPT=false continue fi @@ -40,13 +40,13 @@ jobs: AUTHOR_TYPE=$(gh api "users/${LOGIN}" --jq '.type' 2>/dev/null || echo "Unknown") if [ "$AUTHOR_TYPE" = "Bot" ]; then - echo "$LOGIN is a Bot account — exempt" + echo "$LOGIN is a Bot account: exempt" EXEMPT=true fi if [ "$EXEMPT" = "false" ]; then if gh api "orgs/blacklanternsecurity/members/$LOGIN" > /dev/null 2>&1; then - echo "$LOGIN is an org member — exempt" + echo "$LOGIN is an org member: exempt" EXEMPT=true fi fi @@ -54,7 +54,7 @@ jobs: if [ "$EXEMPT" = "true" ]; then EXEMPT_LOGINS="${EXEMPT_LOGINS:+$EXEMPT_LOGINS,}$LOGIN" else - echo "$LOGIN is not exempt — CLA required" + echo "$LOGIN is not exempt: CLA required" ALL_EXEMPT=false fi done @@ -74,7 +74,7 @@ jobs: gh api --method POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \ -f state=success \ -f context="CLAAssistant" \ - -f description="CLA check skipped — all committers are org members or bots" + -f description="CLA check skipped: all committers are org members or bots" - name: "CLA Assistant" if: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7890ede --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,110 @@ +name: Publish release +on: + workflow_call: + inputs: + images: + description: JSON list of pushed image references to attach SBOMs for + type: string + default: "[]" + changelog: + description: Keep a Changelog file whose section for this version becomes the notes. Empty generates notes from commits. + type: string + default: "" + artifacts: + description: Artifact name pattern from earlier jobs in this run, downloaded into dist/ + type: string + default: "" + assets: + description: Glob of files attached to the release, relative to the repository root + type: string + default: "" + working-directory: + type: string + default: . + +permissions: + contents: read + +jobs: + check: + uses: $/.github/workflows/release-check.yml + with: + working-directory: ${{ inputs.working-directory }} + + release: + needs: check + runs-on: ubuntu-24.04 + permissions: + contents: write + 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 + - if: inputs.artifacts != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ inputs.artifacts }} + path: dist + 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 + 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 }} + VERSION: ${{ needs.check.outputs.version }} + PRERELEASE: ${{ needs.check.outputs.prerelease }} + CHANGELOG: ${{ inputs.changelog }} + ASSETS: ${{ inputs.assets }} + run: | + flags=(--verify-tag) + if [ -n "$CHANGELOG" ]; then + uv run --no-project --with packaging 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) + [ -n "$ASSETS" ] && files+=($ASSETS) + 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" + - 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/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..3eb106c --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,31 @@ +name: PyPI +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + environment: + type: string + default: pypi + +permissions: + contents: read + +jobs: + pypi: + runs-on: ubuntu-24.04 + environment: ${{ inputs.environment }} + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - run: uv build --out-dir "$GITHUB_WORKSPACE/dist" + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..20e17b6 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,148 @@ +name: Python tests +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + uv-sync-args: + type: string + default: --all-extras + pytest-args: + type: string + default: "" + setup-script: + description: Repository script run in every test leg before sync, for system packages or compose services. It may append to $GITHUB_ENV. + type: string + default: "" + teardown-script: + type: string + default: "" + env: + description: JSON object of plain environment variables for the test job + type: string + default: "{}" + secret-env: + description: JSON list of caller secret names exported under the same name. Requires secrets inherit. + type: string + default: "[]" + coverage: + description: Upload cov.xml to Codecov with the CODECOV_TOKEN secret + type: boolean + default: false + artifacts: + description: Path uploaded from every test leg, pass or fail + type: string + default: "" + lint: + type: boolean + default: true + outputs: + python-versions: + value: ${{ jobs.matrix.outputs.versions }} + +permissions: + contents: read + +jobs: + matrix: + uses: $/.github/workflows/python-versions.yml + with: + working-directory: ${{ inputs.working-directory }} + + lint: + if: inputs.lint + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - env: + SYNC_ARGS: ${{ inputs.uv-sync-args }} + run: | + read -ra args <<<"$SYNC_ARGS" + uv sync --no-install-project "${args[@]}" + - run: uv run --no-sync ruff check + - run: uv run --no-sync ruff format --check + + test: + needs: matrix + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(needs.matrix.outputs.versions) }} + env: ${{ fromJSON(inputs.env) }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + python-version: ${{ matrix.python-version }} + - if: inputs.secret-env != '[]' + env: + SECRETS: ${{ toJSON(secrets) }} + NAMES: ${{ inputs.secret-env }} + run: | + python3 - <<'PY' + import json, os, secrets + values, names = json.loads(os.environ["SECRETS"]), json.loads(os.environ["NAMES"]) + missing = [n for n in names if n not in values] + if missing: + raise SystemExit(f"secrets not passed by caller: {missing}") + with open(os.environ["GITHUB_ENV"], "a") as env: + for n in names: + delim = secrets.token_hex(16) + env.write(f"{n}<<{delim}\n{values[n]}\n{delim}\n") + PY + - if: inputs.setup-script != '' + env: + SCRIPT: ${{ inputs.setup-script }} + run: '"./$SCRIPT"' + - env: + SYNC_ARGS: ${{ inputs.uv-sync-args }} + run: | + read -ra args <<<"$SYNC_ARGS" + uv sync "${args[@]}" + - if: needs.matrix.outputs.maturin == 'true' + run: uv run --no-sync maturin develop --release + - env: + PYTEST_ARGS: ${{ inputs.pytest-args }} + run: | + read -ra args <<<"$PYTEST_ARGS" + uv run --no-sync pytest "${args[@]}" + - if: inputs.coverage + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ inputs.working-directory }}/cov.xml + fail_ci_if_error: false + - if: always() && inputs.artifacts != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-${{ matrix.python-version }} + path: ${{ inputs.working-directory }}/${{ inputs.artifacts }} + if-no-files-found: ignore + - if: always() && inputs.teardown-script != '' + env: + SCRIPT: ${{ inputs.teardown-script }} + run: '"./$SCRIPT"' + + passed: + if: always() + needs: [matrix, lint, test] + runs-on: ubuntu-24.04 + steps: + - env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + ! grep -qE 'failure|cancelled' <<<"$RESULTS" diff --git a/.github/workflows/python-versions.yml b/.github/workflows/python-versions.yml new file mode 100644 index 0000000..bdb2e90 --- /dev/null +++ b/.github/workflows/python-versions.yml @@ -0,0 +1,39 @@ +name: Python versions +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + outputs: + versions: + description: JSON list of released CPython minors that satisfy the manifest's requires-python + value: ${{ jobs.read.outputs.versions }} + maturin: + description: true when the build backend is maturin + value: ${{ jobs.read.outputs.maturin }} + +permissions: + contents: read + +jobs: + read: + runs-on: ubuntu-24.04 + outputs: + versions: ${{ steps.read.outputs.versions }} + maturin: ${{ steps.read.outputs.maturin }} + 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: + ROOT: ${{ inputs.working-directory }} + run: uv run --no-project --with packaging python .shared/scripts/manifest.py --root "$ROOT" matrix diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..36c7fdb --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,53 @@ +name: Release check +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + check-script: + description: Extra repository script run with the tag as its argument, for versions stated outside the manifest + type: string + default: "" + outputs: + version: + description: Manifest version, equal to the tag + value: ${{ jobs.check.outputs.version }} + prerelease: + description: true for vX.Y.Z-rc.N tags + value: ${{ jobs.check.outputs.prerelease }} + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.tag.outputs.version }} + prerelease: ${{ steps.tag.outputs.prerelease }} + steps: + - if: github.ref_type != 'tag' + run: | + echo "::error::releases run on tag pushes only" + exit 1 + - 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: tag + env: + ROOT: ${{ inputs.working-directory }} + TAG: ${{ github.ref_name }} + run: uv run --no-project --with packaging python .shared/scripts/manifest.py --root "$ROOT" tag "$TAG" + - if: inputs.check-script != '' + env: + SCRIPT: ${{ inputs.check-script }} + TAG: ${{ github.ref_name }} + run: '"./$SCRIPT" "$TAG"' diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 0000000..f788bd3 --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,81 @@ +name: Rust tests +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + cargo-args: + description: Arguments for clippy and test, such as --workspace or --all-features --locked + type: string + default: --locked + setup-script: + description: Repository script run before the test. It may append to $GITHUB_ENV. + type: string + default: "" + teardown-script: + type: string + default: "" + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Installs the channel pinned in rust-toolchain.toml. + - run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ${{ inputs.working-directory }} + - run: cargo fmt --all -- --check + - env: + CARGO_ARGS: ${{ inputs.cargo-args }} + run: | + read -ra args <<<"$CARGO_ARGS" + cargo clippy --all-targets "${args[@]}" -- -D warnings + + test: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ${{ inputs.working-directory }} + - if: inputs.setup-script != '' + env: + SCRIPT: ${{ inputs.setup-script }} + run: '"./$SCRIPT"' + - env: + CARGO_ARGS: ${{ inputs.cargo-args }} + run: | + read -ra args <<<"$CARGO_ARGS" + cargo test "${args[@]}" + - if: always() && inputs.teardown-script != '' + env: + SCRIPT: ${{ inputs.teardown-script }} + run: '"./$SCRIPT"' + + passed: + if: always() + needs: [lint, test] + runs-on: ubuntu-24.04 + steps: + - env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + ! grep -qE 'failure|cancelled' <<<"$RESULTS" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f913324 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + python: + uses: ./.github/workflows/python-tests.yml + with: + uv-sync-args: --group dev + + actionlint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - run: uv run --group dev actionlint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae4022a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e10080 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "shared-workflows" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = ["packaging"] + +[dependency-groups] +dev = ["pytest==9.0.2", "ruff==0.15.10", "actionlint-py==1.7.12.24"] + +[tool.ruff] +line-length = 160 + +[tool.uv] +package = false diff --git a/scripts/manifest.py b/scripts/manifest.py new file mode 100644 index 0000000..e59fdfc --- /dev/null +++ b/scripts/manifest.py @@ -0,0 +1,162 @@ +"""Read release and test facts from a repository's own manifests. Run under uv with packaging available.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tomllib +from enum import Enum +from pathlib import Path + +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +TAG = re.compile(r"^v(?P\d+\.\d+\.\d+(?:-rc\.(?P\d+))?)$") + + +class Backend(Enum): + MATURIN = "maturin" + HATCHLING = "hatchling.build" + OTHER = None + + @classmethod + def of(cls, pyproject): + backend = pyproject.get("build-system", {}).get("build-backend") + return next((b for b in cls if b.value == backend), cls.OTHER) + + +def load(path): + return tomllib.loads(path.read_text()) if path.is_file() else {} + + +def cargo_version(path): + data = load(path) + version = data.get("package", {}).get("version") + if isinstance(version, dict) or version is None: + version = data.get("workspace", {}).get("package", {}).get("version") + return version + + +def poetry_spec(spec): + caret = re.fullmatch(r"\^(\d+)\.(\d+)", spec.strip()) + return f">={caret[1]}.{caret[2]},<{int(caret[1]) + 1}" if caret else spec + + +def python_spec(root): + pyproject = load(root / "pyproject.toml") + spec = pyproject.get("project", {}).get("requires-python") + if spec is None: + spec = poetry_spec(pyproject.get("tool", {}).get("poetry", {}).get("dependencies", {}).get("python", "")) + if not spec: + sys.exit(f"no requires-python in {root / 'pyproject.toml'}") + return SpecifierSet(spec) + + +def stable_minors(): + listing = subprocess.run( + ["uv", "python", "list", "--all-versions", "--only-downloads", "--output-format", "json"], + check=True, + capture_output=True, + text=True, + ).stdout + minors = set() + for build in json.loads(listing): + if build["implementation"] != "cpython" or build["variant"] != "default": + continue + if Version(build["version"]).is_prerelease: + continue + parts = build["version_parts"] + minors.add((parts["major"], parts["minor"])) + return sorted(minors) + + +def python_versions(root): + spec = python_spec(root) + versions = [f"{major}.{minor}" for major, minor in stable_minors() if f"{major}.{minor}" in spec] + if not versions: + sys.exit(f"no released CPython satisfies {spec}") + return versions + + +def version(root): + pyproject = load(root / "pyproject.toml") + project = pyproject.get("project", {}) + if "version" in project: + return project["version"] + tool = pyproject.get("tool", {}) + backend = Backend.of(pyproject) + if backend is Backend.HATCHLING and "path" in tool.get("hatch", {}).get("version", {}): + source = (root / tool["hatch"]["version"]["path"]).read_text() + return re.search(r"""__version__\s*=\s*["']([^"']+)["']""", source)[1] + if "version" in tool.get("poetry", {}): + return tool["poetry"]["version"] + cargo = root / tool.get("maturin", {}).get("manifest-path", "Cargo.toml") + found = cargo_version(cargo) + if found is None: + sys.exit(f"no static version in {root}") + return found + + +def emit(**outputs): + lines = [f"{key}={value}" for key, value in outputs.items()] + target = os.environ.get("GITHUB_OUTPUT") + if target: + with open(target, "a") as fh: + fh.write("\n".join(lines) + "\n") + print("\n".join(lines)) + + +def cmd_matrix(args): + pyproject = load(args.root / "pyproject.toml") + emit( + versions=json.dumps(python_versions(args.root)), + maturin=str(Backend.of(pyproject) is Backend.MATURIN).lower(), + ) + + +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") + declared = version(args.root) + if Version(declared) != Version(match["version"]): + sys.exit(f"tag {args.tag} does not match manifest version {declared}") + emit(version=declared, prerelease=str(match["rc"] is not None).lower()) + + +def cmd_notes(args): + heading = re.compile(rf"^## \[?v?{re.escape(args.version)}\]?(\s|$)") + section, inside = [], False + for line in args.changelog.read_text().splitlines(): + if line.startswith("## "): + if inside: + break + inside = bool(heading.match(line)) + continue + if inside: + section.append(line) + if not inside: + sys.exit(f"no '## [{args.version}]' section in {args.changelog}") + print("\n".join(section).strip()) + + +def main(): + parser = argparse.ArgumentParser() + 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) + notes = sub.add_parser("notes") + notes.add_argument("changelog", type=Path) + notes.add_argument("version") + notes.set_defaults(func=cmd_notes) + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..44d5e52 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,34 @@ +#!/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/"} +branch=$(git symbolic-ref --quiet --short HEAD || true) +[ "$branch" = "$trunk" ] || die "on '${branch:-detached HEAD}', not trunk '$trunk'" + +git fetch --quiet --tags "$remote" "$trunk" +[ "$(git rev-parse HEAD)" = "$(git rev-parse "$remote/$trunk")" ] || die "local $trunk differs from $remote/$trunk" +git rev-parse -q --verify "refs/tags/$tag" >/dev/null && die "tag $tag already exists" +uv run --quiet --no-project --with packaging python "$here/manifest.py" tag "$tag" >/dev/null || die "merge the version bump first" + +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 new file mode 100644 index 0000000..5c183dc --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,79 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "scripts" / "manifest.py" + + +def run(root, *args): + return subprocess.run([sys.executable, SCRIPT, "--root", root, *args], capture_output=True, text=True) + + +def outputs(result): + assert result.returncode == 0, result.stderr + return dict(line.split("=", 1) for line in result.stdout.splitlines()) + + +@pytest.fixture +def repo(tmp_path): + def make(pyproject, files=None): + (tmp_path / "pyproject.toml").write_text(pyproject) + for name, body in (files or {}).items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + return tmp_path + + return make + + +def test_matrix_follows_requires_python(repo): + root = repo('[project]\nname = "x"\nversion = "1.0.0"\nrequires-python = ">=3.10,<3.13"\n') + out = outputs(run(root, "matrix")) + assert json.loads(out["versions"]) == ["3.10", "3.11", "3.12"] + assert out["maturin"] == "false" + + +def test_matrix_reads_poetry_python(repo): + root = repo('[tool.poetry]\nversion = "0.1.0"\n[tool.poetry.dependencies]\npython = "^3.11"\n') + assert json.loads(outputs(run(root, "matrix"))["versions"])[0] == "3.11" + + +def test_maturin_version_comes_from_cargo(repo): + root = repo( + '[build-system]\nbuild-backend = "maturin"\n[project]\nname = "x"\ndynamic = ["version"]\nrequires-python = ">=3.12"\n', + {"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", "prerelease": "false"} + + +def test_hatch_version_path(repo): + root = 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 + + +def test_notes_extracts_one_section(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("# Changelog\n\n## [1.1.0]\n- new\n\n## [1.0.0]\n- old\n") + result = run(tmp_path, "notes", str(changelog), "1.1.0") + assert result.stdout.strip() == "- new" + assert run(tmp_path, "notes", str(changelog), "9.9.9").returncode != 0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b9c5a92 --- /dev/null +++ b/uv.lock @@ -0,0 +1,120 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "actionlint-py" +version = "1.7.12.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +] + +[[package]] +name = "shared-workflows" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "packaging" }, +] + +[package.dev-dependencies] +dev = [ + { name = "actionlint-py" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "packaging" }] + +[package.metadata.requires-dev] +dev = [ + { name = "actionlint-py", specifier = "==1.7.12.24" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "ruff", specifier = "==0.15.10" }, +] From 8cee31e7d7bfd56815a260baabc486ba503284bc Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:18:42 -0700 Subject: [PATCH 02/12] feat(ci): gate releases on tracked Helm charts and expose semver parts #156 forbids packaging a chart under a version that differs from the committed Chart.yaml. The tag check now walks every Chart.yaml tracked by git and requires its version and appVersion to equal the tag, so callers do not list chart paths. release-check also outputs semver, major, and minor for image tags. Asset globs are split without shell word splitting of the files array. --- .github/workflows/publish.yml | 2 +- .github/workflows/release-check.yml | 12 +++++++++++- scripts/manifest.py | 27 ++++++++++++++++++++++++++- tests/test_manifest.py | 12 +++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7890ede..4af98c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,7 +79,7 @@ jobs: 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) - [ -n "$ASSETS" ] && files+=($ASSETS) + for asset in $ASSETS; do files+=("$asset"); done gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "${files[@]}" image-sbom: diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 36c7fdb..35b555c 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -11,11 +11,18 @@ on: default: "" outputs: version: - description: Manifest version, equal to the tag + description: Manifest version, equal to the tag. Every tracked Helm Chart.yaml version and appVersion must equal it too. 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 }} + major: + value: ${{ jobs.check.outputs.major }} + minor: + value: ${{ jobs.check.outputs.minor }} permissions: contents: read @@ -26,6 +33,9 @@ jobs: 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 }} steps: - if: github.ref_type != 'tag' run: | diff --git a/scripts/manifest.py b/scripts/manifest.py index e59fdfc..fb40817 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -108,6 +108,20 @@ def emit(**outputs): print("\n".join(lines)) +def charts(root): + listed = subprocess.run(["git", "ls-files", "-z", "--", "*Chart.yaml"], cwd=root, capture_output=True, text=True) + return [root / name for name in listed.stdout.split("\0") if name] + + +def chart_fields(path): + found = {} + for line in path.read_text().splitlines(): + field = re.match(r"""^(version|appVersion):\s*["']?([^"'\s#]+)""", line) + if field: + found[field[1]] = field[2] + return found + + def cmd_matrix(args): pyproject = load(args.root / "pyproject.toml") emit( @@ -123,7 +137,18 @@ def cmd_tag(args): declared = version(args.root) if Version(declared) != Version(match["version"]): sys.exit(f"tag {args.tag} does not match manifest version {declared}") - emit(version=declared, prerelease=str(match["rc"] is not None).lower()) + 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(), + ) def cmd_notes(args): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 5c183dc..b625816 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", "prerelease": "false"} + assert outputs(run(root, "tag", "v2.0.0"))["version"] == "2.0.0" def test_hatch_version_path(repo): @@ -77,3 +77,13 @@ def test_notes_extracts_one_section(tmp_path): result = run(tmp_path, "notes", str(changelog), "1.1.0") assert result.stdout.strip() == "- new" 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"} From 17b2a644e502308aac627794021af282546f3acc Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:21:24 -0700 Subject: [PATCH 03/12] fix(ci): skip Helm charts that belong to a nested package bbot-enterprise vendors bbot-server under backend/server with its own pyproject version and chart. Walking every tracked Chart.yaml forced that subchart onto the enterprise version. A chart under a directory holding its own pyproject.toml, Cargo.toml, or package.json now versions with that package and is not checked against the root tag. --- scripts/manifest.py | 11 ++++++++++- tests/test_manifest.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/manifest.py b/scripts/manifest.py index fb40817..00cb7d3 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -14,6 +14,7 @@ from packaging.version import Version TAG = re.compile(r"^v(?P\d+\.\d+\.\d+(?:-rc\.(?P\d+))?)$") +MANIFESTS = ("pyproject.toml", "Cargo.toml", "package.json") class Backend(Enum): @@ -110,7 +111,15 @@ def emit(**outputs): def charts(root): listed = subprocess.run(["git", "ls-files", "-z", "--", "*Chart.yaml"], cwd=root, capture_output=True, text=True) - return [root / name for name in listed.stdout.split("\0") if name] + found = [root / name for name in listed.stdout.split("\0") if name] + return [chart for chart in found if owned(root, chart)] + + +def owned(root, chart): + """A chart beneath a nested package manifest versions with that package, not with root.""" + nested = [root / part for part in chart.parent.relative_to(root).parents][:-1] + nested.insert(0, chart.parent) + return not any((folder / name).is_file() for folder in nested if folder != root for name in MANIFESTS) def chart_fields(path): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index b625816..abbad4d 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -87,3 +87,13 @@ def test_tracked_charts_must_match_tag(repo): 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', + {"server/pyproject.toml": '[project]\nname = "s"\nversion = "0.3.1"\n', "server/helm/Chart.yaml": "version: 0.3.1\n"}, + ) + 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" From 0654fbb8b305e452219b14480d31ff56efc74b73 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:25:01 -0700 Subject: [PATCH 04/12] ci: add reusable dependabot auto-merge workflow Ten repositories each carried an identical dependabot-auto-merge.yml. Hosting it here, next to cla-reusable.yml, gives one place to bump the fetch-metadata pin and change the merge policy. This repository is public, so both public and private callers can reach it. Majors stay manual. Minor and patch get squash auto-merge, which still waits on the target branch's required checks. --- .github/workflows/dependabot-auto-merge.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..0bbab39 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,21 @@ +name: Dependabot auto-merge +on: + workflow_call: + +permissions: {} + +jobs: + auto-merge: + if: github.event.pull_request.user.login == 'dependabot[bot]' && !github.event.repository.fork + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + - if: steps.metadata.outputs.update-type != 'version-update:semver-major' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ github.token }} From ffc3d66dceaf12717934083012de10aae82c5d37 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:30:52 -0700 Subject: [PATCH 05/12] refactor(ci): take release version from the caller's release-check Callers already run release-check.yml before any registry upload, so publish.yml re-running it checked the tag twice and skipped the caller's check-script the second time. publish now takes the version and prerelease outputs as required inputs. --- .github/workflows/publish.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4af98c9..ac53a67 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,6 +2,14 @@ name: Publish release on: workflow_call: inputs: + 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 + type: string + required: true images: description: JSON list of pushed image references to attach SBOMs for type: string @@ -18,21 +26,12 @@ on: description: Glob of files attached to the release, relative to the repository root type: string default: "" - working-directory: - type: string - default: . permissions: contents: read jobs: - check: - uses: $/.github/workflows/release-check.yml - with: - working-directory: ${{ inputs.working-directory }} - release: - needs: check runs-on: ubuntu-24.04 permissions: contents: write @@ -63,8 +62,8 @@ jobs: - env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} - VERSION: ${{ needs.check.outputs.version }} - PRERELEASE: ${{ needs.check.outputs.prerelease }} + VERSION: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} CHANGELOG: ${{ inputs.changelog }} ASSETS: ${{ inputs.assets }} run: | From 324b40db0fd81ed861e8ced54e95d68064071b39 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:31:16 -0700 Subject: [PATCH 06/12] feat(ci): let release-check check-script carry arguments bbot-enterprise validates its version files with scripts/update_version.py --check TAG. A bare script path forced a wrapper file per repository just to add the flag. --- .github/workflows/release-check.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 35b555c..15f7464 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -6,7 +6,7 @@ on: type: string default: . check-script: - description: Extra repository script run with the tag as its argument, for versions stated outside the manifest + description: Repository script and arguments, run with the tag appended, for versions stated outside the manifest type: string default: "" outputs: @@ -60,4 +60,6 @@ jobs: env: SCRIPT: ${{ inputs.check-script }} TAG: ${{ github.ref_name }} - run: '"./$SCRIPT" "$TAG"' + run: | + read -ra cmd <<<"$SCRIPT" + "./${cmd[0]}" "${cmd[@]:1}" "$TAG" From 116fad42150d96aeb872b9516ac8aec98ca1e9a8 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:34:19 -0700 Subject: [PATCH 07/12] fix(ci): drop the reusable PyPI workflow PyPI trusted publishing matches the top-level caller workflow and is unsupported from inside a reusable workflow, per the pypa/gh-action-pypi-publish README. Callers keep a top-level pypi job in their own publish.yml. --- .github/workflows/pypi.yml | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 .github/workflows/pypi.yml diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index 3eb106c..0000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: PyPI -on: - workflow_call: - inputs: - working-directory: - type: string - default: . - environment: - type: string - default: pypi - -permissions: - contents: read - -jobs: - pypi: - runs-on: ubuntu-24.04 - environment: ${{ inputs.environment }} - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: ${{ inputs.working-directory }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - run: uv build --out-dir "$GITHUB_WORKSPACE/dist" - - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 From 883bfbc89464cadbeb10307b2b82891ba7853f56 Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:35:37 -0700 Subject: [PATCH 08/12] fix(ci): pin CLA actions, read release inputs from the caller, test-args - cla-reusable.yml pins create-github-app-token and contributor-assistant to commit SHAs and declares least-privilege job permissions. - release.sh found no manifest in repositories whose package lives in a subdirectory and skipped the repository's extra check. It now reads working-directory and check-script from the caller's own release-check job, so both are stated once, in the workflow. - rust-tests.yml takes test-args for cargo test only, since flags after -- such as --include-ignored break clippy. --- .github/workflows/cla-reusable.yml | 10 ++++- .github/workflows/publish.yml | 2 +- .github/workflows/python-versions.yml | 2 +- .github/workflows/release-check.yml | 2 +- .github/workflows/rust-tests.yml | 8 +++- pyproject.toml | 2 +- scripts/manifest.py | 14 +++++- scripts/release.sh | 10 ++++- tests/test_manifest.py | 9 ++++ uv.lock | 61 ++++++++++++++++++++++++++- 10 files changed, 110 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index 24e2128..4cd68af 100644 --- a/.github/workflows/cla-reusable.yml +++ b/.github/workflows/cla-reusable.yml @@ -2,13 +2,19 @@ name: "CLA Assistant (Reusable)" on: workflow_call: +permissions: {} + jobs: CLAAssistant: + permissions: + pull-requests: write + statuses: write + contents: read runs-on: ubuntu-24.04 steps: - name: Generate token from GitHub App id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -80,7 +86,7 @@ jobs: if: | (steps.cla-check.outputs.all_exempt != 'true') && ((github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target') - uses: contributor-assistant/github-action@v2.6.1 + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ac53a67..ca14af0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,7 +69,7 @@ jobs: run: | flags=(--verify-tag) if [ -n "$CHANGELOG" ]; then - uv run --no-project --with packaging python .shared/scripts/manifest.py notes "$CHANGELOG" "$VERSION" > "$RUNNER_TEMP/notes.md" + 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) diff --git a/.github/workflows/python-versions.yml b/.github/workflows/python-versions.yml index bdb2e90..9de4a18 100644 --- a/.github/workflows/python-versions.yml +++ b/.github/workflows/python-versions.yml @@ -36,4 +36,4 @@ jobs: - id: read env: ROOT: ${{ inputs.working-directory }} - run: uv run --no-project --with packaging python .shared/scripts/manifest.py --root "$ROOT" matrix + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" matrix diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 15f7464..04497ff 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -55,7 +55,7 @@ jobs: env: ROOT: ${{ inputs.working-directory }} TAG: ${{ github.ref_name }} - run: uv run --no-project --with packaging 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" tag "$TAG" - if: inputs.check-script != '' env: SCRIPT: ${{ inputs.check-script }} diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index f788bd3..09e9eb1 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -9,6 +9,10 @@ on: description: Arguments for clippy and test, such as --workspace or --all-features --locked type: string default: --locked + test-args: + description: Arguments after -- for cargo test only, such as --include-ignored + type: string + default: "" setup-script: description: Repository script run before the test. It may append to $GITHUB_ENV. type: string @@ -61,9 +65,11 @@ jobs: run: '"./$SCRIPT"' - env: CARGO_ARGS: ${{ inputs.cargo-args }} + TEST_ARGS: ${{ inputs.test-args }} run: | read -ra args <<<"$CARGO_ARGS" - cargo test "${args[@]}" + read -ra extra <<<"$TEST_ARGS" + cargo test "${args[@]}" -- "${extra[@]}" - if: always() && inputs.teardown-script != '' env: SCRIPT: ${{ inputs.teardown-script }} diff --git a/pyproject.toml b/pyproject.toml index 6e10080..778e6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "shared-workflows" version = "0.0.0" requires-python = ">=3.11" -dependencies = ["packaging"] +dependencies = ["packaging", "pyyaml"] [dependency-groups] dev = ["pytest==9.0.2", "ruff==0.15.10", "actionlint-py==1.7.12.24"] diff --git a/scripts/manifest.py b/scripts/manifest.py index 00cb7d3..4ec02c4 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -1,4 +1,4 @@ -"""Read release and test facts from a repository's own manifests. Run under uv with packaging available.""" +"""Read release and test facts from a repository's own manifests. Run under uv with packaging and pyyaml.""" import argparse import json @@ -10,6 +10,7 @@ from enum import Enum from pathlib import Path +import yaml from packaging.specifiers import SpecifierSet from packaging.version import Version @@ -176,6 +177,16 @@ 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 main(): parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, default=Path(".")) @@ -188,6 +199,7 @@ def main(): 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) args = parser.parse_args() args.func(args) diff --git a/scripts/release.sh b/scripts/release.sh index 44d5e52..6a581ec 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -20,7 +20,15 @@ branch=$(git symbolic-ref --quiet --short HEAD || true) git fetch --quiet --tags "$remote" "$trunk" [ "$(git rev-parse HEAD)" = "$(git rev-parse "$remote/$trunk")" ] || die "local $trunk differs from $remote/$trunk" git rev-parse -q --verify "refs/tags/$tag" >/dev/null && die "tag $tag already exists" -uv run --quiet --no-project --with packaging python "$here/manifest.py" tag "$tag" >/dev/null || die "merge the version bump first" +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")" diff --git a/tests/test_manifest.py b/tests/test_manifest.py index abbad4d..27dcb83 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -97,3 +97,12 @@ 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"} diff --git a/uv.lock b/uv.lock index b9c5a92..8d4d221 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.10" @@ -100,6 +155,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "packaging" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -110,7 +166,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "packaging" }] +requires-dist = [ + { name = "packaging" }, + { name = "pyyaml" }, +] [package.metadata.requires-dev] dev = [ From 25ed1ebc0b8b42c484890183fb8fb0cd609b872b Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:37:26 -0700 Subject: [PATCH 09/12] feat(ci): add shared Docker build and push workflow bbot and bbot-server carried the same inline Docker job. docker.yml builds every git-tracked root Dockerfile, suffixing tags with the text after "Dockerfile.", and applies the release tag policy from the release-check outputs: version always, dev on prereleases, and latest, stable, MAJOR.MINOR, MAJOR on releases. The images output feeds publish.yml for SBOMs. --- .github/workflows/docker.yml | 110 +++++++++++++++++++++++++++++++++++ scripts/manifest.py | 16 +++++ tests/test_manifest.py | 11 ++++ 3 files changed, 137 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..0fc865d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,110 @@ +name: Docker +on: + workflow_call: + inputs: + image: + description: Registry repository, such as blacklanternsecurity/bbot + type: string + required: true + version: + description: version output of release-check.yml + type: string + required: true + prerelease: + type: string + required: true + major: + type: string + required: true + minor: + type: string + required: true + description: + description: Push README.md to the Docker Hub repository description + type: boolean + default: false + outputs: + images: + description: JSON list of pushed version references, for publish.yml images + value: ${{ jobs.plan.outputs.images }} + +permissions: + contents: read + +jobs: + plan: + runs-on: ubuntu-24.04 + outputs: + builds: ${{ steps.read.outputs.builds }} + images: ${{ steps.read.outputs.images }} + 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: ${{ inputs.image }} + VERSION: ${{ inputs.version }} + run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py images "$IMAGE" "$VERSION" + + build: + needs: plan + runs-on: ubuntu-24.04 + strategy: + matrix: + build: ${{ fromJSON(needs.plan.outputs.builds) }} + env: + RELEASE: ${{ inputs.prerelease == 'false' }} + 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: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ inputs.image }} + flavor: | + latest=false + 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 }} + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: ${{ matrix.build.file }} + push: true + tags: ${{ steps.meta.outputs.tags }} + 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 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + repository: ${{ inputs.image }} diff --git a/scripts/manifest.py b/scripts/manifest.py index 4ec02c4..054b66d 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -187,6 +187,18 @@ def cmd_caller(args): 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 = [] + for name in sorted(n for n in listed.stdout.split("\0") if n and "/" not in n): + variant = name.removeprefix("Dockerfile").removeprefix(".") + suffix = f"-{variant}" if variant else "" + builds.append({"file": name, "suffix": suffix, "ref": f"{args.image}:{args.version}{suffix}"}) + if not builds: + sys.exit(f"no Dockerfile tracked at {args.root}") + emit(builds=json.dumps(builds), images=json.dumps([b["ref"] for b in builds])) + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, default=Path(".")) @@ -200,6 +212,10 @@ def main(): 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") + images.set_defaults(func=cmd_images) args = parser.parse_args() args.func(args) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 27dcb83..1d5cdc3 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -106,3 +106,14 @@ def test_caller_reads_release_check_inputs(repo): ) 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"} + + +def test_images_from_tracked_root_dockerfiles(repo): + root = repo( + '[project]\nname = "x"\nversion = "1.0.0"\n', {"Dockerfile": "FROM scratch\n", "Dockerfile.full": "FROM scratch\n", "sub/Dockerfile": "FROM scratch\n"} + ) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + 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"] From 36d17197352fdd849f0d1e209ff9aa688acd00bd Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:41:04 -0700 Subject: [PATCH 10/12] feat(ci): add shared maturin wheel build and crates.io publish blastdns, blasthttp, cloudcheck and radixtarget each carried the same wheel matrix and cargo publish job. maturin-wheels.yml builds every family from one registry of runners and manylinux policies, takes the interpreter list from python-versions.yml so it follows requires-python, and uploads wheels-* artifacts for the caller's top-level PyPI job. crates.yml publishes with the environment and setup script the caller names. --- .github/workflows/crates.yml | 40 ++++++++++ .github/workflows/maturin-wheels.yml | 115 +++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .github/workflows/crates.yml create mode 100644 .github/workflows/maturin-wheels.yml diff --git a/.github/workflows/crates.yml b/.github/workflows/crates.yml new file mode 100644 index 0000000..eca1857 --- /dev/null +++ b/.github/workflows/crates.yml @@ -0,0 +1,40 @@ +name: crates.io +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + setup-script: + description: Repository script run before cargo publish. It may append to $GITHUB_ENV. + type: string + default: "" + environment: + type: string + default: release + secrets: + CARGO_REGISTRY_TOKEN: + required: true + +permissions: + contents: read + +jobs: + crates: + runs-on: ubuntu-24.04 + environment: ${{ inputs.environment }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup show active-toolchain || rustup toolchain install + - if: inputs.setup-script != '' + env: + SCRIPT: ${{ inputs.setup-script }} + run: '"./$SCRIPT"' + - env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked diff --git a/.github/workflows/maturin-wheels.yml b/.github/workflows/maturin-wheels.yml new file mode 100644 index 0000000..201e99e --- /dev/null +++ b/.github/workflows/maturin-wheels.yml @@ -0,0 +1,115 @@ +name: Maturin wheels +on: + workflow_call: + inputs: + targets: + description: JSON object of maturin targets per platform family in FAMILIES. Override to drop targets a crate cannot build. + type: string + default: '{"linux": ["x86_64", "x86", "aarch64", "armv7", "s390x", "ppc64le"], "musllinux": ["x86_64", "x86", "aarch64", "armv7"], "windows": ["x64", "x86"], "macos": ["x86_64", "aarch64"]}' + manylinux: + description: manylinux policy for the linux family + type: string + default: "2_28" + before-script-linux: + description: Repository script sourced inside the linux and musllinux build containers. Its exports reach maturin. + type: string + default: "" + env: + description: JSON object of plain environment variables for every build + type: string + default: "{}" + outputs: + artifacts: + description: Artifact name pattern covering every wheel and the sdist + value: wheels-* + +permissions: + contents: read + +jobs: + python: + uses: $/.github/workflows/python-versions.yml + + plan: + runs-on: ubuntu-24.04 + outputs: + builds: ${{ steps.plan.outputs.builds }} + steps: + - id: plan + env: + TARGETS: ${{ inputs.targets }} + MANYLINUX: ${{ inputs.manylinux }} + FAMILIES: | + { + "linux": {"runner": {"*": "ubuntu-24.04"}, "container": true}, + "musllinux": {"runner": {"*": "ubuntu-24.04"}, "manylinux": "musllinux_1_2", "container": true}, + "windows": {"runner": {"*": "windows-2025"}, "manylinux": "", "container": false, "architecture": true}, + "macos": {"runner": {"x86_64": "macos-15-intel", "aarch64": "macos-15"}, "manylinux": "", "container": false} + } + run: | + builds=$(jq -c --argjson families "$FAMILIES" --arg manylinux "$MANYLINUX" ' + [to_entries[] | .key as $family | ($families[$family] // error("unknown family \($family)")) as $f | .value[] | { + family: $family, + target: ., + runner: ($f.runner[.] // $f.runner["*"] // error("no runner for \($family) \(.)")), + manylinux: ($f.manylinux // $manylinux), + container: $f.container, + architecture: (if $f.architecture then . else "" end) + }]' <<<"$TARGETS") + echo "builds=$builds" >> "$GITHUB_OUTPUT" + + wheel: + needs: [python, plan] + runs-on: ${{ matrix.build.runner }} + strategy: + matrix: + build: ${{ fromJSON(needs.plan.outputs.builds) }} + env: ${{ fromJSON(inputs.env) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: args + shell: bash + env: + VERSIONS: ${{ needs.python.outputs.versions }} + BEFORE: ${{ inputs.before-script-linux }} + run: | + { + echo "interpreters=$(jq -r 'map("-i python" + .) | join(" ")' <<<"$VERSIONS")" + echo "setup<> "$GITHUB_OUTPUT" + - if: ${{ !matrix.build.container }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ steps.args.outputs.setup }} + architecture: ${{ matrix.build.architecture }} + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + target: ${{ matrix.build.target }} + args: --release --locked --out dist ${{ steps.args.outputs.interpreters }} + sccache: false + manylinux: ${{ matrix.build.manylinux }} + before-script-linux: ${{ steps.args.outputs.before }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-${{ matrix.build.family }}-${{ matrix.build.target }} + path: dist + + sdist: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + command: sdist + args: --out dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-sdist + path: dist From bd5493d26aab101458d606886d23e62b1354cdba Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:41:27 -0700 Subject: [PATCH 11/12] fix(ci): merge dependabot updates only after their checks pass dev has no required checks (#139), so gh pr merge --auto merged dependabot pull requests immediately instead of on green. The workflow now waits for every other check on the pull request to finish, refuses to merge if any failed or was cancelled, and merges the exact head commit it waited on. --- .github/workflows/dependabot-auto-merge.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 0bbab39..73256f1 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -8,14 +8,33 @@ jobs: auto-merge: if: github.event.pull_request.user.login == 'dependabot[bot]' && !github.event.repository.fork runs-on: ubuntu-24.04 + timeout-minutes: 180 permissions: contents: write pull-requests: write + checks: read steps: - id: metadata uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + # dev carries no required checks (#139), so the green gate is here rather than in --auto. - if: steps.metadata.outputs.update-type != 'version-update:semver-major' - run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} + HEAD: ${{ github.event.pull_request.head.sha }} + SELF: ${{ github.workflow }} GH_TOKEN: ${{ github.token }} + run: | + others='[.[] | select(.workflow != env.SELF)]' + while :; do + sleep 30 + checks=$(gh pr checks "$PR_URL" --json name,bucket,workflow --jq "$others") || true + [ "$(jq length <<<"$checks")" -gt 0 ] || continue + jq -e 'any(.bucket == "pending")' <<<"$checks" >/dev/null && continue + break + done + jq -r '.[] | "\(.bucket)\t\(.name)"' <<<"$checks" + if jq -e 'any(.bucket == "fail" or .bucket == "cancel")' <<<"$checks" >/dev/null; then + echo "::error::checks failed, not merging" + exit 1 + fi + gh pr merge "$PR_URL" --squash --match-head-commit "$HEAD" From e532142ee9e7322888f6edc80d9a89e2f8c0d96d Mon Sep 17 00:00:00 2001 From: Shane Engelman Date: Sat, 3 Oct 2026 07:42:27 -0700 Subject: [PATCH 12/12] fix(ci): tolerate absent secrets and run setup before clippy Fork pull requests receive no secrets, so secret-env now warns and skips a missing name instead of failing every leg, matching the inline jobs it replaces. rust-tests runs setup-script and teardown-script around clippy too, since build scripts such as blasthttp's vendored OpenSSL run during clippy. --- .github/workflows/python-tests.yml | 8 ++++---- .github/workflows/rust-tests.yml | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 20e17b6..3fc056e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -95,11 +95,11 @@ jobs: python3 - <<'PY' import json, os, secrets values, names = json.loads(os.environ["SECRETS"]), json.loads(os.environ["NAMES"]) - missing = [n for n in names if n not in values] - if missing: - raise SystemExit(f"secrets not passed by caller: {missing}") + for n in names: + if not values.get(n): + print(f"::warning::secret {n} is unavailable, as on fork pull requests") with open(os.environ["GITHUB_ENV"], "a") as env: - for n in names: + for n in filter(values.get, names): delim = secrets.token_hex(16) env.write(f"{n}<<{delim}\n{values[n]}\n{delim}\n") PY diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 09e9eb1..e7ac343 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -14,7 +14,7 @@ on: type: string default: "" setup-script: - description: Repository script run before the test. It may append to $GITHUB_ENV. + description: Repository script run before clippy and before the test. It may append to $GITHUB_ENV. type: string default: "" teardown-script: @@ -40,11 +40,19 @@ jobs: with: workspaces: ${{ inputs.working-directory }} - run: cargo fmt --all -- --check + - if: inputs.setup-script != '' + env: + SCRIPT: ${{ inputs.setup-script }} + run: '"./$SCRIPT"' - env: CARGO_ARGS: ${{ inputs.cargo-args }} run: | read -ra args <<<"$CARGO_ARGS" cargo clippy --all-targets "${args[@]}" -- -D warnings + - if: always() && inputs.teardown-script != '' + env: + SCRIPT: ${{ inputs.teardown-script }} + run: '"./$SCRIPT"' test: runs-on: ubuntu-24.04