mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-09-21 13:18:33 +00:00
A tag push ran python-tests.yml — lint, docs build, the test matrix — and nothing else. CodeQL runs as GitHub default setup rather than from that workflow, so release.yml never saw it, and a pull request's own CodeQL check reports only what that diff introduces, which means an alert already open on master passed every check a release went through. release.yml now has a code-scanning job that build waits on, so a release stops before it publishes anything rather than after. Everything else hangs off build, so gating build gates the PyPI upload, the GitHub release, the Docker image, and the docs deploy with it. Reading the alert list is only meaningful if CodeQL has looked at the code being released, and the tag is usually pushed moments after the commit lands on master, while its analyses are still running. So the job first waits, up to 15 minutes, for an analysis of the tagged commit in every language CodeQL covers. That set is read back from the analyses already published for the default branch instead of hard-coded, so it cannot drift out of step with the repository configuration, and a commit CodeQL never analyzed fails rather than passing against a scan of different code. A false positive is dismissed in the Security tab: a dismissed alert has state "dismissed", not "open", so it stops blocking without needing an exception here. Co-authored-by: Sean Whalen <seanthegeek@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
241 lines
8.2 KiB
YAML
241 lines
8.2 KiB
YAML
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
|
|
|
|
# CodeQL runs as GitHub default setup (on pull requests and weekly), not
|
|
# from ci.yml, so nothing in the release chain would otherwise look at it.
|
|
# A pull request's own CodeQL check only reports what that diff introduces,
|
|
# which means alerts already open on the default branch pass every check a
|
|
# release sees.
|
|
# This job is what stops a release shipping with one of those open.
|
|
code-scanning:
|
|
name: No open code scanning alerts
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
security-events: read
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
# CodeQL analyzes the default branch, not tags. Passed through the
|
|
# environment rather than interpolated into the scripts below, so the
|
|
# shell never sees a ${{ }} expansion.
|
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
steps:
|
|
# An alert list is only worth reading if CodeQL has looked at the code
|
|
# being released. The tag is usually pushed right after the commit
|
|
# lands on main, so its analyses are often still running.
|
|
- name: Wait for CodeQL to analyze the tagged commit
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
sha="$(gh api "repos/$REPO/commits/$GITHUB_REF_NAME" --jq .sha)"
|
|
echo "Tag $GITHUB_REF_NAME is commit $sha"
|
|
|
|
# Which languages CodeQL covers is repository configuration, so read
|
|
# it back from the analyses already published for the default branch
|
|
# instead of hard-coding a list here that would silently drift out of
|
|
# date. A short window means a language removed from the
|
|
# configuration ages out of it within a few commits.
|
|
expected="$(
|
|
gh api "repos/$REPO/code-scanning/analyses?ref=refs/heads/$DEFAULT_BRANCH&per_page=20" \
|
|
--jq '[.[].category] | unique | .[]'
|
|
)"
|
|
if [ -z "$expected" ]; then
|
|
echo "::error::No CodeQL analyses found for $DEFAULT_BRANCH, so there" \
|
|
"is no way to tell whether this commit was scanned"
|
|
exit 1
|
|
fi
|
|
echo "CodeQL publishes these categories for $DEFAULT_BRANCH:"
|
|
echo "$expected" | sed 's/^/ /'
|
|
|
|
deadline=$((SECONDS + 900))
|
|
while :; do
|
|
analyzed="$(
|
|
SHA="$sha" gh api \
|
|
"repos/$REPO/code-scanning/analyses?ref=refs/heads/$DEFAULT_BRANCH&per_page=100" \
|
|
--jq '[.[] | select(.commit_sha == env.SHA) | .category] | unique | .[]'
|
|
)"
|
|
missing="$(comm -23 <(echo "$expected" | sort) <(echo "$analyzed" | sort) || true)"
|
|
if [ -z "$missing" ]; then
|
|
echo "CodeQL has analyzed $sha for every category"
|
|
break
|
|
fi
|
|
# Collapse to one line for the log, dropping the trailing newline
|
|
missing_list="$(echo $missing)"
|
|
if [ "$SECONDS" -ge "$deadline" ]; then
|
|
echo "::error::CodeQL has not analyzed $sha for $missing_list, so" \
|
|
"this release would be judged against a scan of different code"
|
|
exit 1
|
|
fi
|
|
echo "Still waiting for: $missing_list"
|
|
sleep 30
|
|
done
|
|
|
|
# A dismissed alert has state "dismissed", not "open", so dismissing one
|
|
# in the Security tab is the escape hatch for a false positive.
|
|
- name: Fail if any code scanning alert is open
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
alerts="$(
|
|
gh api "repos/$REPO/code-scanning/alerts?ref=refs/heads/$DEFAULT_BRANCH&state=open&per_page=100"
|
|
)"
|
|
count="$(echo "$alerts" | jq length)"
|
|
|
|
if [ "$count" -eq 0 ]; then
|
|
echo "No open code scanning alerts."
|
|
echo "No open code scanning alerts." >> "$GITHUB_STEP_SUMMARY"
|
|
exit 0
|
|
fi
|
|
|
|
{
|
|
echo "### Release blocked: $count open code scanning alert(s)"
|
|
echo
|
|
echo "| Alert | Severity | Rule | Location |"
|
|
echo "| --- | --- | --- | --- |"
|
|
echo "$alerts" | jq -r '.[] | "| [#\(.number)](\(.html_url)) "
|
|
+ "| \(.rule.security_severity_level // .rule.severity) "
|
|
+ "| `\(.rule.id)` "
|
|
+ "| `\(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)` |"'
|
|
echo
|
|
echo "Fix them, or dismiss the ones that are not real in the"
|
|
echo "Security tab, then push the tag again."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
echo "::error::$count open code scanning alert(s); see the job summary"
|
|
exit 1
|
|
|
|
build:
|
|
name: Build distributions
|
|
needs: [ci, code-scanning]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v7
|
|
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@v7
|
|
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@v8
|
|
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@v7
|
|
|
|
- name: Download distributions
|
|
uses: actions/download-artifact@v8
|
|
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
|