Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 13 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
108 changes: 86 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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; }
Expand All @@ -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'
14 changes: 7 additions & 7 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -82,15 +82,15 @@ 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.
- name: The doc renderer refuses what it should
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
Expand Down Expand Up @@ -129,15 +129,15 @@ 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
needs: deploy
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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading