bigfix-relevance-debugger 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. bigfix_relevance_debugger-0.1.0/.github/actions/install-uv/action.yml +61 -0
  2. bigfix_relevance_debugger-0.1.0/.github/dependabot.yml +16 -0
  3. bigfix_relevance_debugger-0.1.0/.github/workflows/pre-commit.yaml +112 -0
  4. bigfix_relevance_debugger-0.1.0/.github/workflows/tag_and_release.yaml +351 -0
  5. bigfix_relevance_debugger-0.1.0/.github/workflows/test.yaml +175 -0
  6. bigfix_relevance_debugger-0.1.0/.gitignore +220 -0
  7. bigfix_relevance_debugger-0.1.0/.pre-commit-config.yaml +78 -0
  8. bigfix_relevance_debugger-0.1.0/.python-version +1 -0
  9. bigfix_relevance_debugger-0.1.0/AGENTS.md +144 -0
  10. bigfix_relevance_debugger-0.1.0/CLAUDE.md +1 -0
  11. bigfix_relevance_debugger-0.1.0/LICENSE +21 -0
  12. bigfix_relevance_debugger-0.1.0/PKG-INFO +337 -0
  13. bigfix_relevance_debugger-0.1.0/README.md +308 -0
  14. bigfix_relevance_debugger-0.1.0/pyproject.toml +90 -0
  15. bigfix_relevance_debugger-0.1.0/pyrightconfig.json +15 -0
  16. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/__init__.py +15 -0
  17. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/__main__.py +6 -0
  18. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/analysis.py +284 -0
  19. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/app.py +1276 -0
  20. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/evaluation.py +1223 -0
  21. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/log_bridge.py +88 -0
  22. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/upstream/__init__.py +17 -0
  23. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/upstream/besapi.py +114 -0
  24. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/upstream/relevance_analyzer.py +146 -0
  25. bigfix_relevance_debugger-0.1.0/src/bigfix_relevance_debugger/upstream/remote_client_relevance.py +121 -0
  26. bigfix_relevance_debugger-0.1.0/tests/conftest.py +79 -0
  27. bigfix_relevance_debugger-0.1.0/tests/unit/test_analysis.py +343 -0
  28. bigfix_relevance_debugger-0.1.0/tests/unit/test_app.py +2516 -0
  29. bigfix_relevance_debugger-0.1.0/tests/unit/test_evaluation.py +1976 -0
  30. bigfix_relevance_debugger-0.1.0/tests/unit/test_log_bridge.py +169 -0
  31. bigfix_relevance_debugger-0.1.0/tests/unit/test_repo_config.py +89 -0
  32. bigfix_relevance_debugger-0.1.0/tests/unit/test_upstream_rules.py +57 -0
  33. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/fixtures/besapi/empty.xml +10 -0
  34. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/fixtures/besapi/error.xml +7 -0
  35. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/fixtures/besapi/plural.xml +14 -0
  36. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/fixtures/besapi/singular.xml +12 -0
  37. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/fixtures/besapi/tuples.xml +19 -0
  38. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/test_besapi.py +144 -0
  39. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/test_relevance_analyzer.py +114 -0
  40. bigfix_relevance_debugger-0.1.0/tests/unit/upstream/test_remote_client_relevance.py +170 -0
  41. bigfix_relevance_debugger-0.1.0/uv.lock +2911 -0
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: install uv
3
+ description: >-
4
+ Set up Python, then pip-install the uv version pinned in
5
+ .pre-commit-config.yaml. Needs the repo checked out first.
6
+
7
+ inputs:
8
+ python-version:
9
+ description: the Python to set up and install uv into
10
+ required: true
11
+
12
+ outputs:
13
+ uv-version:
14
+ description: the uv version that was installed
15
+ value: ${{ steps.read.outputs.uv-version }}
16
+
17
+ runs:
18
+ using: composite
19
+ steps:
20
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
21
+ with:
22
+ python-version: ${{ inputs.python-version }}
23
+
24
+ # Single source of truth for the uv version: the `rev` of the
25
+ # astral-sh/uv-pre-commit repo in .pre-commit-config.yaml, which
26
+ # `pre-commit autoupdate` already keeps current. Read with awk rather than
27
+ # a yaml parser so it needs nothing installed first. `rev` is SHA-pinned
28
+ # with the version in a trailing `# X.Y.Z` comment, so the version comes
29
+ # from that comment; fall back to the bare `rev` value for a tag-pinned rev
30
+ # with no comment.
31
+ - name: read uv version from .pre-commit-config.yaml
32
+ id: read
33
+ shell: bash
34
+ run: |
35
+ UV_VERSION="$(awk '
36
+ /^[[:space:]]*-[[:space:]]*repo:.*astral-sh\/uv-pre-commit[[:space:]]*$/ { found = 1; next }
37
+ found && $1 == "rev:" {
38
+ if (match($0, /#[ \t]*(frozen:[ \t]*)?[vV]?[0-9]+(\.[0-9]+)*/)) {
39
+ ver = substr($0, RSTART, RLENGTH)
40
+ sub(/^#[ \t]*(frozen:[ \t]*)?[vV]?/, "", ver)
41
+ } else {
42
+ ver = $2
43
+ gsub(/["'\'']/, "", ver)
44
+ sub(/^[vV]/, "", ver)
45
+ }
46
+ print ver
47
+ exit
48
+ }
49
+ ' .pre-commit-config.yaml)"
50
+ if [ -z "$UV_VERSION" ]; then
51
+ echo "::error::could not read the astral-sh/uv-pre-commit version from .pre-commit-config.yaml"
52
+ exit 1
53
+ fi
54
+ echo "uv-version=$UV_VERSION" >> "$GITHUB_OUTPUT"
55
+ echo "uv version: $UV_VERSION"
56
+
57
+ - name: install uv
58
+ shell: bash
59
+ env:
60
+ UV_VERSION: ${{ steps.read.outputs.uv-version }}
61
+ run: python -m pip install "uv==${UV_VERSION}"
@@ -0,0 +1,16 @@
1
+ # Keeps the SHA-pinned actions in .github/workflows/ current; Dependabot
2
+ # updates the trailing `# vX.Y.Z` comment along with the SHA.
3
+ #
4
+ # The 7-day cooldown is the point, not a courtesy: a release published minutes
5
+ # ago is the likeliest to be a compromised one.
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
12
+ cooldown:
13
+ default-days: 7
14
+ open-pull-requests-limit: 5
15
+ labels:
16
+ - "dependencies"
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: pre-commit
3
+
4
+ on:
5
+ pull_request:
6
+ push:
7
+ branches:
8
+ - main
9
+ workflow_dispatch:
10
+ # so .github/workflows/tag_and_release.yaml can gate a release on this exact
11
+ # job rather than polling for other checks to go green
12
+ workflow_call:
13
+ inputs:
14
+ all-files:
15
+ description: run every hook on the whole tree instead of the pushed diff
16
+ type: boolean
17
+ default: false
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ concurrency:
23
+ # github.workflow keeps a workflow_call from tag_and_release.yaml out of the
24
+ # group a push to main starts for this workflow on its own
25
+ group: pre-commit-${{ github.workflow }}-${{ github.ref }}
26
+ cancel-in-progress: true
27
+
28
+ jobs:
29
+ pre-commit:
30
+ runs-on: ubuntu-latest
31
+ # no-commit-to-branch guards a developer's local `git commit`; it has
32
+ # nothing to check on a runner, and on a push to main it would fail the
33
+ # run of every merged PR. This repo doesn't use that hook today; the SKIP
34
+ # keeps CI working if it's added. zizmor is skipped here and runs in its
35
+ # own step at the end, the only one that gets GH_TOKEN. SKIP is read by
36
+ # pre-commit itself.
37
+ env:
38
+ SKIP: no-commit-to-branch,zizmor
39
+ steps:
40
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
41
+ with:
42
+ # required to grab the history of the PR for pre-commit
43
+ fetch-depth: 0
44
+ persist-credentials: false
45
+
46
+ # setup-python, then the uv version pinned in .pre-commit-config.yaml.
47
+ # `./` rather than zizmor's preferred `$/`, which actionlint v1.7.12
48
+ # rejects (rhysd/actionlint#711, #732), as in the analyzer.
49
+ # The local pyright and pytest hooks are `language: system` and run
50
+ # `uv run`, so uv has to be on PATH or both fail.
51
+ - uses: ./.github/actions/install-uv # zizmor: ignore[self-repository]
52
+ with:
53
+ python-version: "3.12"
54
+
55
+ # A PR or a push: the hooks on the changed files, including the
56
+ # manual-stage ones (pytest and the workflow linters).
57
+ # In a called workflow github.event_name is the caller's event, so a
58
+ # release started by a push would land here too; the all-files input is
59
+ # what sends it to the next step instead.
60
+ - name: pre-commit on the diff
61
+ if: >-
62
+ !inputs.all-files
63
+ && (github.event_name == 'pull_request' || github.event_name == 'push')
64
+ uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
65
+ with:
66
+ extra_args: >-
67
+ --color=always
68
+ --from-ref ${{ github.event.pull_request.base.sha || github.event.before }}
69
+ --to-ref ${{ github.event.pull_request.head.sha || github.sha }}
70
+ --hook-stage manual
71
+
72
+ # manual dispatch, and tag_and_release.yaml: every hook against the full
73
+ # tree, not just a diff range
74
+ - name: pre-commit on all files
75
+ if: inputs.all-files || github.event_name == 'workflow_dispatch'
76
+ uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
77
+ with:
78
+ extra_args: --color=always --all-files --hook-stage manual
79
+
80
+ # The two steps above cover pull_request, push and workflow_dispatch, and
81
+ # a workflow_call with all-files. Any other caller event would run no
82
+ # hooks at all, and pre-commit (installed by pre-commit/action) wouldn't
83
+ # be on PATH for the steps below; fail here and say why instead.
84
+ - name: no pre-commit run for this event
85
+ if: >-
86
+ !inputs.all-files
87
+ && !contains(fromJSON('["pull_request", "push", "workflow_dispatch"]'), github.event_name)
88
+ env:
89
+ EVENT_NAME: ${{ github.event_name }}
90
+ run: |
91
+ echo "::error::pre-commit.yaml has no diff range for a '${EVENT_NAME}' event; call it with all-files: true"
92
+ exit 1
93
+
94
+ # zizmor on its own, so GH_TOKEN reaches only it and not every hook
95
+ # (pytest and pyright run PR code). The token puts zizmor in online mode
96
+ # (offline is the default without one), so it can fetch the referenced
97
+ # actions for the audits that need them. The runner's GITHUB_TOKEN is
98
+ # enough for a public repo and is read-only per `permissions:` above.
99
+ # The workflow files are few, so this checks all of them every time.
100
+ - name: zizmor (online)
101
+ env:
102
+ SKIP: no-commit-to-branch
103
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
104
+ run: pre-commit run zizmor --color=always --all-files --hook-stage manual
105
+
106
+ # pre-commit/action restores the previous cache on a config change and
107
+ # saves it back under the new key, so every hook rev bump would leave its
108
+ # old environment in the cache forever. gc deletes the environments no
109
+ # live config references. Last on purpose: the cache is saved in the
110
+ # action's post step, after this.
111
+ - name: prune unused hook environments before the cache is saved
112
+ run: pre-commit gc
@@ -0,0 +1,351 @@
1
+ ---
2
+ # Tags, releases and publishes to PyPI on a version bump. Nothing here bumps
3
+ # the version: change `[project] version` in pyproject.toml in a PR, merge it
4
+ # to main, and this workflow does the rest. A cut-down version of
5
+ # bigfix-relevance-analyzer's, which also builds a playground and a VS Code
6
+ # extension; here the wheel and sdist are the only assets.
7
+ name: Tag and Release
8
+
9
+ on:
10
+ push:
11
+ branches:
12
+ - main
13
+ paths:
14
+ - "pyproject.toml"
15
+ - "uv.lock"
16
+ - "src/**"
17
+ - ".github/workflows/tag_and_release.yaml"
18
+ - ".github/workflows/test.yaml"
19
+ - ".github/workflows/pre-commit.yaml"
20
+ - ".github/actions/**"
21
+ # for the first release, for re-running one that failed after the version
22
+ # was merged, and, with PYPI_PUBLISH unset, for testing everything up to
23
+ # the release job; the tag check below still makes it a no-op if the tag
24
+ # exists
25
+ workflow_dispatch:
26
+
27
+ permissions:
28
+ contents: read
29
+
30
+ concurrency:
31
+ # One release at a time, repo-wide. Releases only happen from main (see the
32
+ # `version` job), so keying on github.ref would only add a way to overlap.
33
+ # Serializing also keeps the tag check sound: a draft has no tag until it's
34
+ # published, so a second run that started mid-release would see no tag and
35
+ # try to release the same version again.
36
+ # Deliberately no cancel-in-progress: never kill a release mid-flight.
37
+ # GitHub keeps at most ONE pending run per group: a newer queued run
38
+ # replaces an older queued one (never the running one). The newer run
39
+ # releases whatever version its own commit has, so an intermediate bump
40
+ # whose run got replaced is skipped, not released late.
41
+ group: release
42
+
43
+ jobs:
44
+ # Decides whether there is anything to do, so the jobs below can be skipped
45
+ # outright. A push that touches src/** without bumping the version gets a
46
+ # green, near-instant run and no release.
47
+ version:
48
+ name: Check for an untagged version
49
+ runs-on: ubuntu-latest
50
+ # Every later job needs this one, so skipping it skips the whole release.
51
+ # fork guard: a fork that pushes to its own main should not try to release.
52
+ # main guard: `push` is already limited to main, but workflow_dispatch can
53
+ # be started from any branch, and would release that branch's version.
54
+ # PYPI_PUBLISH gates only the `release` job (see there), so this job and
55
+ # the test and pre-commit gates run either way.
56
+ if: >-
57
+ github.repository == 'jgstew/bigfix-relevance-debugger'
58
+ && github.ref == 'refs/heads/main'
59
+ outputs:
60
+ version: ${{ steps.getversion.outputs.version }}
61
+ prerelease: ${{ steps.getversion.outputs.prerelease }}
62
+ release_needed: ${{ steps.tagged.outputs.release_needed }}
63
+ steps:
64
+ # shallow: this job only reads pyproject.toml; the tag check asks the
65
+ # remote instead of needing every tag locally
66
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
67
+ with:
68
+ persist-credentials: false
69
+
70
+ # `[project] version` in pyproject.toml is the single source of truth:
71
+ # __version__ reads the installed package metadata, so there is no
72
+ # second place to bump. tomllib is stdlib on 3.11+, and the runner's
73
+ # preinstalled python is new enough, so no setup-python step is needed.
74
+ #
75
+ # Only the normalized PEP 440 spelling is accepted (X.Y.Z, then
76
+ # optionally aN/bN/rcN, .postN, .devN), so the tag v<version> matches
77
+ # the version PyPI and the wheel filename use: `1.0.0-beta` would be
78
+ # tagged as written but published as 1.0.0b0. A pre-release (a, b, rc or
79
+ # dev) becomes a GitHub pre-release, so it is never marked Latest.
80
+ - name: Read version from pyproject.toml
81
+ id: getversion
82
+ run: |
83
+ version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
84
+ if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$'; then
85
+ echo "::error::'$version' in pyproject.toml isn't a normalized X.Y.Z[aN|bN|rcN][.postN][.devN] version"
86
+ exit 1
87
+ fi
88
+ if printf '%s' "$version" | grep -Eq '(a|b|rc|\.dev)[0-9]+'; then
89
+ prerelease=true
90
+ else
91
+ prerelease=false
92
+ fi
93
+ echo "version=$version" >> "$GITHUB_OUTPUT"
94
+ echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
95
+ echo "version: $version (prerelease: $prerelease)"
96
+
97
+ # Release only if this version has no tag yet. This is what makes the
98
+ # workflow idempotent and safe to re-run. `ls-remote --exit-code` exits
99
+ # 2 for "no such ref"; anything else non-zero is a real failure and
100
+ # must not be read as "untagged".
101
+ - name: Check whether v<version> is already tagged
102
+ id: tagged
103
+ env:
104
+ VERSION: ${{ steps.getversion.outputs.version }}
105
+ PYPI_PUBLISH: ${{ vars.PYPI_PUBLISH }}
106
+ run: |
107
+ rc=0
108
+ git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" > /dev/null || rc=$?
109
+ case "$rc" in
110
+ 0)
111
+ echo "release_needed=false" >> "$GITHUB_OUTPUT"
112
+ echo "v${VERSION} is already tagged - nothing to release"
113
+ ;;
114
+ 2)
115
+ echo "release_needed=true" >> "$GITHUB_OUTPUT"
116
+ if [ "$PYPI_PUBLISH" = "true" ]; then
117
+ echo "v${VERSION} has no tag yet - will release"
118
+ else
119
+ msg="v${VERSION} has no tag yet, but PYPI_PUBLISH isn't \`true\`: the gates run, the release job is skipped"
120
+ echo "::notice::${msg}"
121
+ echo "$msg" >> "$GITHUB_STEP_SUMMARY"
122
+ fi
123
+ ;;
124
+ *)
125
+ echo "::error::git ls-remote failed (exit $rc) checking for tag v${VERSION}"
126
+ exit 1
127
+ ;;
128
+ esac
129
+
130
+ # The gates. Reusing the test and pre-commit workflows means the release is
131
+ # blocked by the exact same jobs that guard PRs (including the wheel smoke
132
+ # test on Linux, macOS and Windows), with no polling. Both run in parallel
133
+ # off `version`.
134
+ #
135
+ # The calls keep `./` and silence zizmor's self-repository audit, which
136
+ # wants GitHub's newer `$/` syntax that actionlint v1.7.12 rejects
137
+ # (rhysd/actionlint#711, #732), as in the analyzer.
138
+ test:
139
+ name: Test
140
+ needs: version
141
+ if: needs.version.outputs.release_needed == 'true'
142
+ uses: ./.github/workflows/test.yaml # zizmor: ignore[self-repository]
143
+
144
+ pre-commit:
145
+ name: pre-commit
146
+ needs: version
147
+ if: needs.version.outputs.release_needed == 'true'
148
+ uses: ./.github/workflows/pre-commit.yaml # zizmor: ignore[self-repository]
149
+ with:
150
+ # a push only carries its own diff; a release checks the whole tree
151
+ all-files: true
152
+
153
+ release:
154
+ name: Tag, release and publish
155
+ needs: [version, test, pre-commit]
156
+ # The only job PYPI_PUBLISH gates. It does all the outward-facing work
157
+ # (tag, GitHub release, PyPI upload), so with the repository variable
158
+ # unset (Settings > Secrets and variables > Actions > Variables) a run
159
+ # still exercises the version check and both gates on the real trigger,
160
+ # and creates nothing. This gates the whole job, not just the PyPI step
161
+ # as the analyzer does: for this repo PyPI is the point of a release, and
162
+ # a GitHub-only release would tag the version, so the tag check would
163
+ # never publish it to PyPI later. (`needs` still requires the gates to
164
+ # have succeeded; an `if:` without a status function implies success().)
165
+ if: vars.PYPI_PUBLISH == 'true'
166
+ runs-on: ubuntu-latest
167
+ # Gates the whole job - tag, GitHub release and PyPI upload - so an
168
+ # environment protection rule (required reviewer, branch restriction)
169
+ # stops a release before anything is created. The name must match the
170
+ # `Environment name` of the PyPI trusted publisher exactly: PyPI checks the
171
+ # `environment` claim in the OIDC token.
172
+ environment: pypi
173
+ permissions:
174
+ # contents: write - create the release (and so the tag) and upload assets
175
+ # id-token: write - OIDC for PyPI trusted publishing
176
+ contents: write
177
+ id-token: write
178
+ steps:
179
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
180
+ with:
181
+ persist-credentials: false
182
+
183
+ # setup-python, then the uv version pinned in .pre-commit-config.yaml;
184
+ # the same action test.yaml and pre-commit.yaml use
185
+ - uses: ./.github/actions/install-uv # zizmor: ignore[self-repository]
186
+ with:
187
+ python-version: "3.13"
188
+
189
+ # The exact bytes the gates tested: test.yaml's `build` job uploaded
190
+ # them and its smoke matrix ran them on Linux, macOS and Windows. No
191
+ # second `uv build` here: the wheel records the hatchling that built it
192
+ # (WHEEL's Generator), so a rebuild after a hatchling release would ship
193
+ # untested bytes, and a re-run's rebuild would no longer match what an
194
+ # earlier attempt already put on PyPI. Artifacts belong to the run, so
195
+ # "Re-run failed jobs" downloads the first attempt's.
196
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
197
+ with:
198
+ name: test-dist
199
+ path: dist
200
+
201
+ # `sha256sum -c`-verifiable, with no directory prefix, so it checks out
202
+ # wherever the assets are downloaded to
203
+ - name: Compute SHA-256 checksums
204
+ working-directory: dist
205
+ run: |
206
+ sha256sum ./*.whl ./*.tar.gz > SHA256SUMS.txt
207
+ cat SHA256SUMS.txt
208
+
209
+ # PyPI never replaces a file. If an earlier attempt already uploaded this
210
+ # version from other bytes (a newer commit on main, or a rebuild), stop
211
+ # here, before the draft's assets are touched; `uv publish --check-url`
212
+ # would only catch it after they had been replaced. Files PyPI doesn't
213
+ # have yet, or has with the same hash, are fine: that's a re-run
214
+ # finishing a partial release. Also checks dist/ is for this version.
215
+ - name: Check PyPI doesn't already have different bytes for this version
216
+ env:
217
+ PACKAGE: bigfix-relevance-debugger
218
+ VERSION: ${{ needs.version.outputs.version }}
219
+ run: |
220
+ python - <<'EOF'
221
+ import hashlib
222
+ import json
223
+ import os
224
+ import pathlib
225
+ import sys
226
+ import urllib.error
227
+ import urllib.request
228
+
229
+ package, version = os.environ["PACKAGE"], os.environ["VERSION"]
230
+ stem = package.replace("-", "_") + "-" + version
231
+ local = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in pathlib.Path("dist").glob(f"{stem}[-.]*")}
232
+ if not any(n.endswith(".whl") for n in local) or not any(n.endswith(".tar.gz") for n in local):
233
+ sys.exit(f"::error::dist/ has no wheel and sdist for {version}: {sorted(local)}")
234
+ try:
235
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=30) as r:
236
+ released = {f["filename"]: f["digests"]["sha256"] for f in json.load(r)["urls"]}
237
+ except urllib.error.HTTPError as e:
238
+ if e.code != 404:
239
+ raise
240
+ released = {}
241
+ differ = sorted(n for n, h in local.items() if n in released and released[n] != h)
242
+ if differ:
243
+ sys.exit(
244
+ f"::error::PyPI already has {', '.join(differ)} for {version} with different bytes. "
245
+ "PyPI never replaces a file: bump the version, or re-run the original run's failed jobs."
246
+ )
247
+ for name in sorted(local):
248
+ print(f"{name}: {'already on PyPI (identical)' if name in released else 'not on PyPI yet'}")
249
+ EOF
250
+
251
+ # Created as a DRAFT and published only at the end, once PyPI has the
252
+ # package. That keeps this compatible with GitHub's immutable releases,
253
+ # which lock a release's assets at publication. A draft does not create
254
+ # its tag either: the target is recorded now, the tag appears on publish.
255
+ #
256
+ # A re-run that finds the draft from a failed attempt reuses it: it
257
+ # moves the target to this run's commit (a run from a newer main would
258
+ # otherwise tag the old commit next to the new commit's assets) and
259
+ # replaces the assets (--clobber). The lookup lists releases with
260
+ # `gh api`, so a failed request fails the step rather than reading as
261
+ # "no draft" and creating a second one.
262
+ - name: Create or reuse the draft GitHub release
263
+ env:
264
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
265
+ VERSION: ${{ needs.version.outputs.version }}
266
+ PRERELEASE: ${{ needs.version.outputs.prerelease }}
267
+ run: |
268
+ drafts="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" \
269
+ --jq ".[] | select(.draft and .tag_name == \"v${VERSION}\") | .id")"
270
+ count="$(printf '%s' "$drafts" | grep -c . || true)"
271
+ case "$count" in
272
+ 0)
273
+ gh release create "v${VERSION}" --repo "$GITHUB_REPOSITORY" \
274
+ --draft \
275
+ --prerelease="${PRERELEASE}" \
276
+ --target "${GITHUB_SHA}" \
277
+ --title "v${VERSION}" \
278
+ --notes "Draft: publishing to PyPI." \
279
+ dist/*.whl dist/*.tar.gz dist/SHA256SUMS.txt
280
+ ;;
281
+ 1)
282
+ echo "draft v${VERSION} (id ${drafts}) exists from an earlier attempt; retargeting it to ${GITHUB_SHA} and replacing its assets"
283
+ gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${drafts}" \
284
+ -f "target_commitish=${GITHUB_SHA}" \
285
+ -F "prerelease=${PRERELEASE}" > /dev/null
286
+ gh release upload "v${VERSION}" --repo "$GITHUB_REPOSITORY" --clobber \
287
+ dist/*.whl dist/*.tar.gz dist/SHA256SUMS.txt
288
+ ;;
289
+ *)
290
+ echo "::error::${count} draft releases are tagged v${VERSION} (ids: ${drafts//$'\n'/ }); delete the extras and re-run"
291
+ exit 1
292
+ ;;
293
+ esac
294
+
295
+ # Trusted publishing: `uv publish` speaks OIDC natively given id-token:
296
+ # write, so there is no long-lived API token to store and no
297
+ # pypa/gh-action-pypi-publish pin to maintain. Needs a PyPI trusted
298
+ # publisher (or pending publisher) for this repo with workflow
299
+ # `tag_and_release.yaml` and environment `pypi`.
300
+ # The explicit globs keep SHA256SUMS.txt out of the upload. --check-url
301
+ # skips files PyPI already has, so a re-run after a partial upload
302
+ # finishes the job instead of failing on "File already exists".
303
+ - name: Publish to PyPI (trusted publishing)
304
+ run: >-
305
+ uv publish --trusted-publishing always
306
+ --check-url https://pypi.org/simple/
307
+ dist/*.whl dist/*.tar.gz
308
+
309
+ # GitHub's own generated notes with the checksums appended, so the
310
+ # hashes are readable in the release body as well as downloadable.
311
+ - name: Build release notes
312
+ env:
313
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
314
+ VERSION: ${{ needs.version.outputs.version }}
315
+ run: |
316
+ notes_file="${RUNNER_TEMP}/release-notes.md"
317
+ if ! gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
318
+ --method POST \
319
+ -f "tag_name=v${VERSION}" \
320
+ -f "target_commitish=${GITHUB_SHA}" \
321
+ --jq '.body' > "$notes_file"; then
322
+ echo "::warning::could not generate release notes; falling back to a plain body"
323
+ printf 'Release v%s\n' "${VERSION}" > "$notes_file"
324
+ fi
325
+ # The backticks below are Markdown in release-note text, not command
326
+ # substitutions; single quotes keep them literal.
327
+ # shellcheck disable=SC2016
328
+ {
329
+ printf '\n## Install\n\n'
330
+ printf '```bash\nuvx bigfix-relevance-debugger@%s\n```\n' "${VERSION}"
331
+ printf '\n## SHA-256\n\n'
332
+ printf 'Verify with `sha256sum -c SHA256SUMS.txt`:\n\n'
333
+ printf '```\n'
334
+ cat dist/SHA256SUMS.txt
335
+ printf '```\n'
336
+ } >> "$notes_file"
337
+ echo "NOTES_FILE=$notes_file" >> "$GITHUB_ENV"
338
+ cat "$notes_file"
339
+
340
+ # Publishing is the last step and the point where the tag is created
341
+ # and, with immutable releases on, everything is locked.
342
+ - name: Publish the GitHub release
343
+ env:
344
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
345
+ VERSION: ${{ needs.version.outputs.version }}
346
+ PRERELEASE: ${{ needs.version.outputs.prerelease }}
347
+ run: |
348
+ gh release edit "v${VERSION}" --repo "$GITHUB_REPOSITORY" \
349
+ --notes-file "${NOTES_FILE}" \
350
+ --prerelease="${PRERELEASE}" \
351
+ --draft=false