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..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 }} @@ -31,7 +37,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 +46,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 +60,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,13 +80,13 @@ 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: | (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/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/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..73256f1 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,40 @@ +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 + 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' + 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" 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/.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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ca14af0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,109 @@ +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 + 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: "" + +permissions: + contents: read + +jobs: + release: + 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: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} + CHANGELOG: ${{ inputs.changelog }} + ASSETS: ${{ inputs.assets }} + run: | + flags=(--verify-tag) + if [ -n "$CHANGELOG" ]; then + uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py notes "$CHANGELOG" "$VERSION" > "$RUNNER_TEMP/notes.md" + flags+=(--notes-file "$RUNNER_TEMP/notes.md") + else + flags+=(--generate-notes) + fi + [ "$PRERELEASE" = true ] && flags+=(--prerelease) + gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1 || gh release create "$TAG" -R "$GITHUB_REPOSITORY" "${flags[@]}" + shopt -s globstar nullglob + files=(sbom/*.spdx.json) + for asset in $ASSETS; do files+=("$asset"); done + gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "${files[@]}" + + image-sbom: + if: inputs.images != '[]' + needs: release + runs-on: ubuntu-24.04 + permissions: + contents: write + strategy: + matrix: + image: ${{ fromJSON(inputs.images) }} + steps: + - id: name + env: + IMAGE: ${{ matrix.image }} + run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_OUTPUT" + - uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + with: + image: ${{ matrix.image }} + format: spdx-json + output-file: ${{ steps.name.outputs.file }} + upload-artifact: false + upload-release-assets: false + - env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + FILE: ${{ steps.name.outputs.file }} + run: gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "$FILE" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..3fc056e --- /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"]) + 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 filter(values.get, 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..9de4a18 --- /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 --with pyyaml 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..04497ff --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,65 @@ +name: Release check +on: + workflow_call: + inputs: + working-directory: + type: string + default: . + check-script: + description: Repository script and arguments, run with the tag appended, for versions stated outside the manifest + type: string + default: "" + outputs: + version: + 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 + +jobs: + check: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.tag.outputs.version }} + prerelease: ${{ steps.tag.outputs.prerelease }} + semver: ${{ steps.tag.outputs.semver }} + major: ${{ steps.tag.outputs.major }} + minor: ${{ steps.tag.outputs.minor }} + 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 --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" tag "$TAG" + - if: inputs.check-script != '' + env: + SCRIPT: ${{ inputs.check-script }} + TAG: ${{ github.ref_name }} + run: | + read -ra cmd <<<"$SCRIPT" + "./${cmd[0]}" "${cmd[@]:1}" "$TAG" diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 0000000..e7ac343 --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,95 @@ +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 + test-args: + description: Arguments after -- for cargo test only, such as --include-ignored + type: string + default: "" + setup-script: + description: Repository script run before clippy and 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 + - 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 + 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 }} + TEST_ARGS: ${{ inputs.test-args }} + run: | + read -ra args <<<"$CARGO_ARGS" + read -ra extra <<<"$TEST_ARGS" + cargo test "${args[@]}" -- "${extra[@]}" + - 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..778e6dc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "shared-workflows" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = ["packaging", "pyyaml"] + +[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..054b66d --- /dev/null +++ b/scripts/manifest.py @@ -0,0 +1,224 @@ +"""Read release and test facts from a repository's own manifests. Run under uv with packaging and pyyaml.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tomllib +from enum import Enum +from pathlib import Path + +import yaml +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +TAG = re.compile(r"^v(?P\d+\.\d+\.\d+(?:-rc\.(?P\d+))?)$") +MANIFESTS = ("pyproject.toml", "Cargo.toml", "package.json") + + +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 charts(root): + listed = subprocess.run(["git", "ls-files", "-z", "--", "*Chart.yaml"], cwd=root, capture_output=True, text=True) + 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): + 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( + 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}") + 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): + 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 cmd_caller(args): + for path in sorted((args.root / ".github" / "workflows").glob("*.y*ml")): + for job in (yaml.safe_load(path.read_text()) or {}).get("jobs", {}).values(): + if "/.github/workflows/release-check.yml@" in str(job.get("uses", "")): + given = job.get("with", {}) + print(json.dumps({"working-directory": given.get("working-directory", "."), "check-script": given.get("check-script", "")})) + return + sys.exit(f"no job in {args.root}/.github/workflows calls release-check.yml") + + +def cmd_images(args): + listed = subprocess.run(["git", "ls-files", "-z", "--", "Dockerfile*"], cwd=args.root, capture_output=True, text=True, check=True) + builds = [] + 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(".")) + 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) + 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) + + +if __name__ == "__main__": + main() diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..6a581ec --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,42 @@ +#!/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" +manifest() { uv run --quiet --no-project --with packaging --with pyyaml python "$here/manifest.py" "$@"; } +caller=$(manifest caller) || die "no workflow here calls release-check.yml" +root=$(jq -r '.["working-directory"]' <<<"$caller") +check=$(jq -r '.["check-script"]' <<<"$caller") +manifest --root "$root" tag "$tag" >/dev/null || die "merge the version bump first" +if [ -n "$check" ]; then + read -ra cmd <<<"$check" + "./${cmd[0]}" "${cmd[@]:1}" "$tag" || die "$check refused $tag" +fi + +prev=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true) +echo "Repository: $(git remote get-url "$remote")" +echo "Commit: $(git log -1 --format='%h %s')" +echo "Tag: $tag${prev:+ (previous $prev)}" +[ -n "$prev" ] && git log --oneline "$prev..HEAD" +read -r -p "Create and push $tag? [y/N] " answer +[ "$answer" = y ] || [ "$answer" = Y ] || die "aborted" + +git tag -a "$tag" -m "$tag" +git push "$remote" "refs/tags/$tag" diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..1d5cdc3 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,119 @@ +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" + + +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 + + +def test_tracked_charts_must_match_tag(repo): + root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"helm/Chart.yaml": 'name: x\nversion: 1.2.3\nappVersion: "1.2.2"\n'}) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + result = run(root, "tag", "v1.2.3") + assert result.returncode != 0 and "appVersion" in result.stderr + (root / "helm/Chart.yaml").write_text('name: x\nversion: 1.2.3\nappVersion: "1.2.3"\n') + assert outputs(run(root, "tag", "v1.2.3")) == {"version": "1.2.3", "semver": "1.2.3", "major": "1", "minor": "2", "prerelease": "false"} + + +def test_chart_under_nested_package_is_not_ours(repo): + root = repo( + '[project]\nname = "x"\nversion = "1.2.3"\n', + {"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" + + +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"} + + +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"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8d4d221 --- /dev/null +++ b/uv.lock @@ -0,0 +1,179 @@ +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 = "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" +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" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "actionlint-py" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "packaging" }, + { name = "pyyaml" }, +] + +[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" }, +]