diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc7e2e2c..6525c11e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,9 @@ permissions: contents: read on: + # Backstop for a manually-created release: releases created by the Release + # workflow itself never emit this event (see the workflow_call comment + # below), so in the normal flow the push happens via workflow_call instead. release: types: - published @@ -13,6 +16,16 @@ on: # Allow maintainers to build/validate the multi-arch image on demand # (e.g. from a feature branch) without pushing anything to the registry. workflow_dispatch: + # Called directly by the Release workflow, since a GitHub Release created + # with that workflow's own GITHUB_TOKEN does not emit a `release: + # published` event (GitHub recursion prevention), so the trigger above + # never fires for it. + workflow_call: + inputs: + push_image: + description: "Push the built image to ghcr.io (used by the Release workflow)" + type: boolean + default: false env: REGISTRY: ghcr.io @@ -49,10 +62,12 @@ jobs: type=semver,pattern={{major}}.{{minor}} - name: Log in to the Container registry - # Only authenticate when we will actually push (release). The master - # push and workflow_dispatch runs build for validation only and must - # never touch the registry, so they skip the login entirely. - if: github.event_name == 'release' + # Only authenticate when we will actually push: a published release + # event, or the Release workflow calling this with push_image: true + # (see the workflow_call comment above). The master push and + # workflow_dispatch runs build for validation only and must never + # touch the registry, so they skip the login entirely. + if: github.event_name == 'release' || inputs.push_image == true uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -64,9 +79,10 @@ jobs: with: context: . platforms: linux/amd64,linux/arm64 - # Push only on a published release. Every other trigger (push to + # Push on a published release event, or when the Release workflow + # calls this with push_image: true. Every other trigger (push to # master, workflow_dispatch) builds both architectures for # validation but never pushes. - push: ${{ github.event_name == 'release' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + push: ${{ github.event_name == 'release' || inputs.push_image == true }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..4e457d4b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: Docs + +# Builds the Sphinx docs and deploys them to GitHub Pages. +# Runs on demand (Actions → Docs → Run workflow) for documentation-only +# updates between releases, and is called by release.yml on every release. +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + docs: + name: Build and deploy docs + runs-on: ubuntu-latest + concurrency: + group: github-pages-deploy + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + # Job-level permissions replace (not merge with) the workflow-level + # grant, so contents: read must be repeated here for checkout. + permissions: + contents: read + pages: write + id-token: write + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[build] + + - name: Build docs + run: make -C docs html + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/build/html + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index cda25e28..120212fa 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -8,6 +8,7 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_call: jobs: lint-docs-build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..804826c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +# Fires when a version tag (e.g. 10.5.0) is pushed. Publishing is gated on +# the full CI suite passing, and PyPI upload uses Trusted Publishing (OIDC), +# so no API token secret is needed. +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+*" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + name: CI + uses: ./.github/workflows/python-tests.yml + secrets: inherit + + build: + name: Build distributions + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Verify tag matches package version + run: | + python -m pip install --upgrade pip hatch + version="$(hatch version)" + if [ "$version" != "$GITHUB_REF_NAME" ]; then + echo "Tag $GITHUB_REF_NAME does not match package version ($version)" >&2 + exit 1 + fi + + - name: Build sdist and wheel + run: | + hatch build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/parsedmarc/ + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub release + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Extract changelog notes + run: | + awk -v ver="$GITHUB_REF_NAME" ' + $0 == "## " ver {found=1; next} + /^## / && found {exit} + found {print} + ' CHANGELOG.md > release-notes.md + if ! [ -s release-notes.md ]; then + echo "No CHANGELOG.md section found for $GITHUB_REF_NAME" >&2 + exit 1 + fi + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md + + docker: + name: Build and push Docker image + needs: publish-pypi + # A GitHub Release created with this workflow's own GITHUB_TOKEN does not + # emit a `release: published` event to other workflows (GitHub's + # recursion-prevention rule), so docker.yml's `release: published` + # trigger never fires for the release created above. It is called + # directly here instead, right after the PyPI publish. + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker.yml + with: + push_image: true + + docs: + name: Publish documentation + # Gated on the publish so docs for a version that never shipped (tag + # mismatch, failed upload) don't deploy. + needs: publish-pypi + # Must cover everything docs.yml requests (contents: read at its + # workflow level) — a called workflow can't exceed the caller's grant, + # and the mismatch fails the whole run at startup. + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/docs.yml diff --git a/AGENTS.md b/AGENTS.md index 8e83abc0..bd932972 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,16 +224,12 @@ The author's "cold re-read" is never cold — it confirms the model the author a ## Releases -A release isn't done until built artifacts are attached to the GitHub release page. Full sequence: - -1. Bump version in `parsedmarc/constants.py`; rename `CHANGELOG.md`'s `## Unreleased` heading to the new version number (these two edits always land together, in the release PR only). Feature/fix PRs accumulate their entries under `## Unreleased` and never touch `constants.py` or pick a version number — choosing the number is a release-time decision. -2. Commit on a feature branch, open a PR, merge to master. -3. `git fetch && git checkout master && git pull`. -4. `git tag -a -m "" ` and `git push origin `. -5. `rm -rf dist && hatch build`. Verify `git describe --tags --exact-match` matches the tag. -6. `gh release create --title "" --notes-file `. -7. `gh release upload dist/parsedmarc-.tar.gz dist/parsedmarc--py3-none-any.whl`. -8. Confirm `gh release view --json assets` shows both the sdist and the wheel before considering the release complete. +- **CRITICAL: Never make a release without the explicit permission of the maintainer.** That includes every action that starts or advances a release: pushing a version tag, creating a GitHub Release, publishing to PyPI, or merging a release branch. Preparing release changes on a branch is fine; triggering the release itself requires the maintainer to say so, each time. +- Feature/fix PRs accumulate their entries under `CHANGELOG.md`'s `## Unreleased` heading and never touch `parsedmarc/constants.py` or pick a version number — choosing the number is a release-time decision. The release PR bumps the version in `parsedmarc/constants.py` and renames `## Unreleased` to the version number; these two edits always land together, and only in the release PR. +- Releases are automated by `.github/workflows/release.yml`. Once the release PR merges, push an annotated tag matching the version (e.g. `10.5.0`, no `v` prefix): `git tag -a -m "" && git push origin `. The tag push runs the full CI suite (reused from `python-tests.yml` via `workflow_call`), and only if it passes: builds the package (failing if the tag doesn't match the version in `parsedmarc/constants.py`), publishes it to PyPI via Trusted Publishing, creates a GitHub Release (notes taken from the tag's `CHANGELOG.md` section, failing if none exists, with the built distributions attached), builds and pushes the multi-arch Docker image to ghcr.io, and deploys the Sphinx docs to GitHub Pages. +- A release isn't done until the Release workflow run is fully green: PyPI shows the new version, the GitHub Release has both the sdist and wheel attached, and the ghcr.io image tags exist. +- Docs deployment lives in `.github/workflows/docs.yml`, which release.yml calls. For documentation-only updates between releases, the maintainer can run it on demand (Actions → Docs → Run workflow). Like releases, on-demand docs deployment is a maintainer-permission action — see the CRITICAL rule above. +- The pipeline rests on one-time repo/PyPI configuration; if a release fails in an unexpected place, check these before debugging the workflows: a PyPI Trusted Publisher for the `parsedmarc` project (owner `domainaware`, repo `parsedmarc`, workflow `release.yml`, environment `pypi`), the repo's Pages source set to "GitHub Actions" (not the legacy `gh-pages` branch), and the `github-pages` environment's deployment policy allowing version *tags* — release.yml calls docs.yml from a `refs/tags/*` ref, so a branch-only policy fails that deployment. ## Maintaining the reverse DNS maps diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ce15a0..fea48514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Changes + +- **Bumped the `mailsuite` floor to `>=2.3.0`**, which raises the transitive `mail-parser` floor to `>=4.6.2` and the transitive `cryptography` floor to `>=50.0.0`. +- **Releases and docs deployment are now automated by a tag-triggered GitHub Actions workflow**: CI-gated build, PyPI publishing via Trusted Publishing, a GitHub Release with attached distributions, a Docker image push to ghcr.io, and a GitHub Pages docs deploy. The manual `build.sh` and `publish-docs.sh` scripts are removed. + ## 10.4.1 ### Bug fixes diff --git a/build.sh b/build.sh deleted file mode 100755 index 61b16ce7..00000000 --- a/build.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ ! -d ".venv" ]; then - python3 -m venv .venv || exit -fi - -. .venv/bin/activate -pip install .[build] -ruff format . -cd docs -make clean -make html -touch build/html/.nojekyll -if [ -d "../../parsedmarc-docs" ]; then - cp -rf build/html/* ../../parsedmarc-docs/ -fi -cd .. -cd parsedmarc/resources/maps -python3 sortlists.py -echo "Checking for invalid UTF-8 bytes in base_reverse_dns_map.csv" -python3 find_bad_utf8.py base_reverse_dns_map.csv -cd ../../.. -python3 -m pytest --cov --cov-report=xml --junitxml=junit.xml -o junit_family=legacy tests/ -rm -rf dist/ build/ -hatch build diff --git a/publish-docs.sh b/publish-docs.sh deleted file mode 100755 index 416fc7f0..00000000 --- a/publish-docs.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -git pull -cd ../parsedmarc-docs || exit -git pull -cd ../parsedmarc || exit -./build.sh -cd ../parsedmarc-docs || exit -git add . -git commit -m "Update docs" -git push diff --git a/pyproject.toml b/pyproject.toml index ff125440..23f1b8d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "httpx>=0.25", "kafka-python>=2.3.2", "lxml>=4.4.0", - "mailsuite[gmail,msgraph]>=2.2.2", + "mailsuite[gmail,msgraph]>=2.3.0", "maxminddb>=2.0.0", # Imported directly in cli.py for Graph error handling; otherwise # only a transitive dep of mailsuite[msgraph] -> msgraph-sdk.