diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7d3d03c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# Every action in .github/workflows is pinned by commit, so a moved tag cannot +# change what runs. The cost is that pins go stale; this keeps them current, +# one reviewable pull request a week per ecosystem. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # A release this new has had no time to be found out if it is bad. + cooldown: + default-days: 7 + groups: + actions: + patterns: ["*"] + commit-message: + prefix: ci + + # uv.lock, which CI installs with --locked. The floors in pyproject are + # tested by the lowest-direct job and are raised by hand, not here. + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + versioning-strategy: lockfile-only + groups: + dependencies: + patterns: ["*"] + commit-message: + prefix: deps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 048ddaa..f7b6736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,16 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python: ["3.12", "3.13"] + # Every platform and every Python that requires-python admits and + # that has a release, so ">=3.12" states what is tested. + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: ${{ matrix.python }} enable-cache: true @@ -74,10 +76,10 @@ jobs: name: the oldest dependencies pyproject allows runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.12" @@ -102,10 +104,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.13" enable-cache: true @@ -165,7 +167,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Tracked prose, source, site, and templates contain no em dash run: | @@ -197,10 +199,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.13" enable-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9ab2ae..f0918b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,29 +14,61 @@ on: required: true type: string -permissions: - contents: write # attaching assets to the release +# No job gets a token it does not need, so each names its own. The build runs +# the project's code and every dependency's, so it can only read; the job that +# can write runs none of it. +permissions: {} +# Keyed on the tag itself, not the ref, so a tag push and a manual re-run of +# the same tag queue behind each other instead of racing to replace the same +# assets. concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false jobs: - release: - name: build and attach the distributions + resolve: + name: the tag is a release tag runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + # The tag arrives through the environment and is checked before any + # other job sees it. A dispatch input is free text, and one expanded + # straight into a script is a script of the caller's choosing. + - id: tag + env: + TAG: ${{ inputs.tag || github.ref_name }} + run: | + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::the tag is not a release tag of the form v1.2.3" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + build: + name: build and check the distributions + needs: resolve + runs-on: ubuntu-latest + permissions: + contents: read + env: + TAG: ${{ needs.resolve.outputs.tag }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - ref: ${{ inputs.tag || github.ref }} + ref: refs/tags/${{ needs.resolve.outputs.tag }} fetch-depth: 0 + persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.13" - enable-cache: true + # A release builds from a clean download, never from a cache that + # another workflow could have written. + enable-cache: false - name: Sync dependencies run: uv sync --locked @@ -46,12 +78,11 @@ jobs: # because the test asserting the version used a substring match. - name: The tag and the package version must agree run: | - tag="${{ inputs.tag || github.ref_name }}" - expected="${tag#v}" + expected="${TAG#v}" actual="$(uv run python -c 'import plumbline; print(plumbline.__version__)')" - echo "tag $tag implies version $expected; package says $actual" + echo "tag $TAG implies version $expected; package says $actual" if [ "$expected" != "$actual" ]; then - echo "::error::tag $tag does not match __version__ $actual. Bump the version or move the tag; do not release a mismatch." + echo "::error::tag $TAG does not match __version__ $actual. Bump the version or move the tag; do not release a mismatch." exit 1 fi @@ -67,8 +98,7 @@ jobs: - name: The artifact names carry the released version run: | - tag="${{ inputs.tag || github.ref_name }}" - version="${tag#v}" + version="${TAG#v}" ls -l dist/ test -f "dist/plumbline-${version}-py3-none-any.whl" \ || { echo "::error::expected dist/plumbline-${version}-py3-none-any.whl"; exit 1; } @@ -88,18 +118,52 @@ jobs: print("installed", plumbline.__version__, "clean") PY + - name: Hand the distributions to the publishing job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: attest and attach the distributions + needs: [resolve, build] + runs-on: ubuntu-latest + # This job holds the only write token, so it checks out nothing and runs + # no project code: it signs what the build made and attaches it. + permissions: + contents: write # attaching assets to the release + id-token: write # signing the provenance + attestations: write # storing the provenance + env: + TAG: ${{ needs.resolve.outputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + + steps: + - name: Collect the distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist + + # Anyone can then check that a wheel came from this workflow at this + # tag: gh attestation verify plumbline-*.whl -R TMHSDigital/plumbline + - name: Record where the distributions came from + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/* + - name: Attach the distributions to the release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - tag="${{ inputs.tag || github.ref_name }}" # The release notes are written by hand, so this only ever adds # assets to a release that already exists. It does not create one and # it does not generate notes from commit messages. - if ! gh release view "$tag" > /dev/null 2>&1; then - echo "::error::no release exists for $tag. Create it with written notes first, then re-run this workflow." + if ! gh release view "$TAG" > /dev/null 2>&1; then + echo "::error::no release exists for $TAG. Create it with written notes first, then re-run this workflow." exit 1 fi - gh release upload "$tag" dist/* --clobber + gh release upload "$TAG" dist/* --clobber echo "attached:" - gh release view "$tag" --json assets --jq '.assets[].name' + gh release view "$TAG" --json assets --jq '.assets[].name' diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index ef40a24..39ff25a 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -31,10 +31,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.13" enable-cache: true @@ -72,7 +72,7 @@ jobs: # The exact bytes the deploy job publishes. On a pull request this is # also a downloadable preview of the site. - name: Keep the assembled site - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: _site @@ -82,7 +82,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 # The renderer's refusals (raw HTML off the allowlist, broken links and # anchors, remote images, a modified vendored file) each have a case. @@ -90,7 +90,7 @@ jobs: run: node scripts/render_docs.mjs --self-test - name: Fetch the assembled site - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: github-pages path: artifact @@ -129,7 +129,7 @@ jobs: steps: - name: Publish the checked site id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 live: name: the live site resolves as the checked one did @@ -137,7 +137,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 # What Pages actually serves, including a real 404 for a missing path, # which only a request to the live site can show. The CDN can take a diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cfd3d..8e527cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,19 @@ different event from one that moved because it was wrong. ### Fixed +- The release workflow gave its whole run a write token while it executed the + project's code and every dependency's, expanded the tag, which a manual run + takes as free text, straight into shell, and let a tag push and a manual run + of the same tag race to replace the same assets (#51). The tag is now checked + against `vX.Y.Z` in a job of its own and passed through the environment; the + build job can only read; a separate job with no checkout and no project code + signs build provenance for the wheel and sdist + (`gh attestation verify`) and attaches them; and runs queue per tag. +- Every action was referenced by a movable major tag (#52). Each is now pinned + to a commit, with its version beside it, and Dependabot keeps the pins and + uv.lock current, waiting a week after any release. CI now also runs on macOS + and Python 3.14, so `requires-python = ">=3.12"` says what is tested. + - The dependency floors in pyproject were never tested, and three were wrong: scipy 1.14.0 has no wheel for Python 3.13, anthropic before 0.77 lacks the structured output types the generative adapter uses, and typer before 0.16 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4eace9..650bd8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,8 @@ uv run ruff format --check . uv run mypy --strict ``` -CI runs these on Python 3.12 and 3.13, on Ubuntu and Windows. +CI runs these on Python 3.12, 3.13 and 3.14, on Ubuntu, Windows and macOS, and +runs the tests once more on the oldest dependencies pyproject allows. **Tests never need a network connection or an API key.** Every test must pass without either. If a change needs a live call to be tested, the live call is @@ -80,9 +81,10 @@ The flow: 4. **Open a pull request.** No approval is required, because there is currently one maintainer and a rule demanding one would only demand it of them. CI is the gate that actually matters. -5. **CI must be green** before merge. The required checks are the four test - jobs (ruff, ruff format, mypy --strict and pytest, on Python 3.12 and 3.13, - on Ubuntu and Windows), the quickstart as the README documents it, the prose +5. **CI must be green** before merge. The required checks are the nine test + jobs (ruff, ruff format, mypy --strict and pytest, on Python 3.12, 3.13 and + 3.14, on Ubuntu, Windows and macOS), the tests on the oldest dependencies + pyproject allows, the quickstart as the README documents it, the prose check (no em dashes, no `--` used as a dash), the built wheel, and the site's two checks (the floor agrees with the Python; every link, anchor, meta tag and policy resolves, and the pages work in a real browser). diff --git a/README.md b/README.md index ef5d182..611dd95 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,8 @@ Specific, and none of them are going to surprise you later. - **Probabilities from a hosted API may arrive quantized.** That bounds the resolution of any threshold or bin computed from them. METHODOLOGY says what the bound is and where it bites. -- **Verified on Windows and Ubuntu, Python 3.12 and 3.13.** macOS is untested. +- **Verified on Ubuntu, Windows, and macOS, Python 3.12 through 3.14**, and on + the oldest release of each dependency that pyproject allows. - **Two of the three transports have never run outside the test suite.** See the adapters table above and [issue #3](https://github.com/TMHSDigital/plumbline/issues/3). diff --git a/pyproject.toml b/pyproject.toml index bb52ca6..b881f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] # Each floor is the oldest release the whole test suite passes on, checked by # the lowest-direct job in CI. The SDKs are capped below their next breaking