feed-sentiment 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.
- feed_sentiment-0.1.0/.github/workflows/publish.yml +691 -0
- feed_sentiment-0.1.0/.github/workflows/quality.yml +557 -0
- feed_sentiment-0.1.0/.gitignore +149 -0
- feed_sentiment-0.1.0/LICENSE +21 -0
- feed_sentiment-0.1.0/PKG-INFO +117 -0
- feed_sentiment-0.1.0/README.md +80 -0
- feed_sentiment-0.1.0/docs/releasing.md +34 -0
- feed_sentiment-0.1.0/pyproject.toml +81 -0
- feed_sentiment-0.1.0/src/feed_sentiment/__init__.py +58 -0
- feed_sentiment-0.1.0/src/feed_sentiment/_metadata.py +18 -0
- feed_sentiment-0.1.0/src/feed_sentiment/analyzers/__init__.py +4 -0
- feed_sentiment-0.1.0/src/feed_sentiment/analyzers/base.py +15 -0
- feed_sentiment-0.1.0/src/feed_sentiment/analyzers/vader.py +31 -0
- feed_sentiment-0.1.0/src/feed_sentiment/cli/__init__.py +3 -0
- feed_sentiment-0.1.0/src/feed_sentiment/cli/app.py +93 -0
- feed_sentiment-0.1.0/src/feed_sentiment/exceptions/__init__.py +34 -0
- feed_sentiment-0.1.0/src/feed_sentiment/feeds/__init__.py +19 -0
- feed_sentiment-0.1.0/src/feed_sentiment/feeds/parser.py +125 -0
- feed_sentiment-0.1.0/src/feed_sentiment/feeds/retriever.py +135 -0
- feed_sentiment-0.1.0/src/feed_sentiment/models.py +131 -0
- feed_sentiment-0.1.0/src/feed_sentiment/services/__init__.py +8 -0
- feed_sentiment-0.1.0/src/feed_sentiment/services/analysis.py +106 -0
- feed_sentiment-0.1.0/tests/fixtures/atom.xml +4 -0
- feed_sentiment-0.1.0/tests/fixtures/empty.xml +1 -0
- feed_sentiment-0.1.0/tests/fixtures/malformed.xml +1 -0
- feed_sentiment-0.1.0/tests/fixtures/rss.xml +7 -0
- feed_sentiment-0.1.0/tests/integration/test_cli.py +60 -0
- feed_sentiment-0.1.0/tests/integration/test_public_api.py +48 -0
- feed_sentiment-0.1.0/tests/packaging/smoke.py +86 -0
- feed_sentiment-0.1.0/tests/unit/test_analysis.py +78 -0
- feed_sentiment-0.1.0/tests/unit/test_feeds.py +148 -0
- feed_sentiment-0.1.0/tests/unit/test_metadata.py +42 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: publish-pypi-${{ github.event.release.tag_name }}
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
quality:
|
|
16
|
+
name: Quality gates
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
uses: ./.github/workflows/quality.yml
|
|
20
|
+
|
|
21
|
+
release_validation:
|
|
22
|
+
name: Release artifact validation
|
|
23
|
+
needs: quality
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
outputs:
|
|
28
|
+
package-version: ${{ steps.metadata.outputs.package-version }}
|
|
29
|
+
steps:
|
|
30
|
+
- name: Set up Python 3.12
|
|
31
|
+
uses: actions/setup-python@v6
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.12"
|
|
34
|
+
|
|
35
|
+
- name: Install metadata validation tools
|
|
36
|
+
run: python -m pip install "packaging>=24,<27"
|
|
37
|
+
|
|
38
|
+
- name: Initialize validation diagnostics
|
|
39
|
+
run: mkdir -p release-results
|
|
40
|
+
|
|
41
|
+
- name: Validate published release
|
|
42
|
+
id: release
|
|
43
|
+
env:
|
|
44
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
45
|
+
EVENT_ACTION: ${{ github.event.action }}
|
|
46
|
+
RELEASE_DRAFT: ${{ github.event.release.draft }}
|
|
47
|
+
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
|
48
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
49
|
+
RELEASE_NAME: ${{ github.event.release.name }}
|
|
50
|
+
RELEASE_SHA: ${{ github.sha }}
|
|
51
|
+
run: |
|
|
52
|
+
python - <<'PY'
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from packaging.version import InvalidVersion, Version
|
|
59
|
+
|
|
60
|
+
errors = []
|
|
61
|
+
tag = os.environ["RELEASE_TAG"]
|
|
62
|
+
if os.environ["EVENT_NAME"] != "release":
|
|
63
|
+
errors.append("event must be 'release'")
|
|
64
|
+
if os.environ["EVENT_ACTION"] != "published":
|
|
65
|
+
errors.append("release action must be 'published'")
|
|
66
|
+
if os.environ["RELEASE_DRAFT"].lower() != "false":
|
|
67
|
+
errors.append("draft releases cannot be published to PyPI")
|
|
68
|
+
if os.environ["RELEASE_PRERELEASE"].lower() != "false":
|
|
69
|
+
errors.append("GitHub prereleases cannot be published to production PyPI")
|
|
70
|
+
if not tag:
|
|
71
|
+
errors.append("release tag is missing")
|
|
72
|
+
elif not tag.startswith("v"):
|
|
73
|
+
errors.append("release tag must start with 'v'")
|
|
74
|
+
|
|
75
|
+
expected = tag[1:] if tag.startswith("v") else ""
|
|
76
|
+
if expected:
|
|
77
|
+
try:
|
|
78
|
+
parsed = Version(expected)
|
|
79
|
+
if str(parsed) != expected:
|
|
80
|
+
errors.append(
|
|
81
|
+
f"release version must be canonical: {expected!r} != {str(parsed)!r}"
|
|
82
|
+
)
|
|
83
|
+
except InvalidVersion:
|
|
84
|
+
errors.append(f"release tag contains an invalid package version: {expected!r}")
|
|
85
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}", os.environ["RELEASE_SHA"]):
|
|
86
|
+
errors.append("release commit SHA is missing or malformed")
|
|
87
|
+
|
|
88
|
+
context = {
|
|
89
|
+
"event": os.environ["EVENT_NAME"],
|
|
90
|
+
"action": os.environ["EVENT_ACTION"],
|
|
91
|
+
"draft": os.environ["RELEASE_DRAFT"].lower(),
|
|
92
|
+
"prerelease": os.environ["RELEASE_PRERELEASE"].lower(),
|
|
93
|
+
"tag": tag,
|
|
94
|
+
"release_name": os.environ["RELEASE_NAME"],
|
|
95
|
+
"release_sha": os.environ["RELEASE_SHA"],
|
|
96
|
+
"expected_version": expected,
|
|
97
|
+
"tag_validation": "passed" if not errors else "failed",
|
|
98
|
+
"errors": errors,
|
|
99
|
+
}
|
|
100
|
+
Path("release-results/release-context.json").write_text(
|
|
101
|
+
json.dumps(context, indent=2) + "\n", encoding="utf-8"
|
|
102
|
+
)
|
|
103
|
+
if errors:
|
|
104
|
+
raise SystemExit("Release validation failed: " + "; ".join(errors))
|
|
105
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
106
|
+
output.write(f"expected-version={expected}\n")
|
|
107
|
+
print(f"Validated full release {tag} at {os.environ['RELEASE_SHA']}")
|
|
108
|
+
PY
|
|
109
|
+
|
|
110
|
+
- name: Download quality-validated distributions
|
|
111
|
+
id: download
|
|
112
|
+
uses: actions/download-artifact@v8
|
|
113
|
+
with:
|
|
114
|
+
name: python-package-distributions
|
|
115
|
+
path: dist/
|
|
116
|
+
|
|
117
|
+
- name: Validate distribution artifacts and metadata
|
|
118
|
+
id: metadata
|
|
119
|
+
env:
|
|
120
|
+
EXPECTED_VERSION: ${{ steps.release.outputs.expected-version }}
|
|
121
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
122
|
+
RELEASE_SHA: ${{ github.sha }}
|
|
123
|
+
run: |
|
|
124
|
+
python - <<'PY'
|
|
125
|
+
from __future__ import annotations
|
|
126
|
+
|
|
127
|
+
import configparser
|
|
128
|
+
import hashlib
|
|
129
|
+
import json
|
|
130
|
+
import os
|
|
131
|
+
import tarfile
|
|
132
|
+
import zipfile
|
|
133
|
+
from datetime import UTC, datetime
|
|
134
|
+
from email.parser import BytesParser
|
|
135
|
+
from pathlib import Path
|
|
136
|
+
|
|
137
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
138
|
+
from packaging.utils import (
|
|
139
|
+
InvalidSdistFilename,
|
|
140
|
+
InvalidWheelFilename,
|
|
141
|
+
canonicalize_name,
|
|
142
|
+
parse_sdist_filename,
|
|
143
|
+
parse_wheel_filename,
|
|
144
|
+
)
|
|
145
|
+
from packaging.version import InvalidVersion, Version
|
|
146
|
+
|
|
147
|
+
expected_name = "feed-sentiment"
|
|
148
|
+
expected_version = Version(os.environ["EXPECTED_VERSION"])
|
|
149
|
+
expected_python = ">=3.12"
|
|
150
|
+
dist = Path("dist")
|
|
151
|
+
results = Path("release-results")
|
|
152
|
+
files = sorted(path for path in dist.iterdir() if path.is_file()) if dist.is_dir() else []
|
|
153
|
+
wheels = [path for path in files if path.suffix == ".whl"]
|
|
154
|
+
sdists = [path for path in files if path.name.endswith(".tar.gz")]
|
|
155
|
+
unknown = [path for path in files if path not in wheels and path not in sdists]
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
checks: dict[str, str] = {}
|
|
158
|
+
|
|
159
|
+
def require(condition: bool, label: str, message: str) -> None:
|
|
160
|
+
checks[label] = "passed" if condition else "failed"
|
|
161
|
+
if not condition:
|
|
162
|
+
errors.append(message)
|
|
163
|
+
|
|
164
|
+
def metadata_version_matches(raw: str, label: str) -> bool:
|
|
165
|
+
try:
|
|
166
|
+
matches = Version(raw) == expected_version
|
|
167
|
+
except InvalidVersion:
|
|
168
|
+
matches = False
|
|
169
|
+
require(matches, label, f"{label.replace('_', ' ')} {raw!r} does not match tag version {expected_version}")
|
|
170
|
+
return matches
|
|
171
|
+
|
|
172
|
+
require(len(files) == 2, "artifact_count", f"expected exactly 2 files, found {len(files)}")
|
|
173
|
+
require(len(wheels) == 1, "wheel_count", f"expected exactly 1 wheel, found {len(wheels)}")
|
|
174
|
+
require(len(sdists) == 1, "sdist_count", f"expected exactly 1 sdist, found {len(sdists)}")
|
|
175
|
+
require(not unknown, "unknown_files", "unexpected artifact files: " + ", ".join(path.name for path in unknown))
|
|
176
|
+
|
|
177
|
+
wheel_metadata = None
|
|
178
|
+
sdist_metadata = None
|
|
179
|
+
entry_points = ""
|
|
180
|
+
if len(wheels) == 1:
|
|
181
|
+
try:
|
|
182
|
+
with zipfile.ZipFile(wheels[0]) as archive:
|
|
183
|
+
metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")]
|
|
184
|
+
entry_names = [name for name in archive.namelist() if name.endswith(".dist-info/entry_points.txt")]
|
|
185
|
+
require(len(metadata_names) == 1, "wheel_metadata_file", "wheel must contain exactly one METADATA file")
|
|
186
|
+
require(len(entry_names) == 1, "wheel_entry_points_file", "wheel must contain exactly one entry_points.txt")
|
|
187
|
+
if len(metadata_names) == 1:
|
|
188
|
+
wheel_metadata = BytesParser().parsebytes(archive.read(metadata_names[0]))
|
|
189
|
+
if len(entry_names) == 1:
|
|
190
|
+
entry_points = archive.read(entry_names[0]).decode("utf-8")
|
|
191
|
+
except (OSError, zipfile.BadZipFile, UnicodeDecodeError) as exc:
|
|
192
|
+
errors.append(f"wheel could not be inspected: {exc}")
|
|
193
|
+
checks["wheel_archive"] = "failed"
|
|
194
|
+
if len(sdists) == 1:
|
|
195
|
+
try:
|
|
196
|
+
with tarfile.open(sdists[0], "r:gz") as archive:
|
|
197
|
+
metadata_members = [member for member in archive.getmembers() if member.name.endswith("/PKG-INFO") and member.isfile()]
|
|
198
|
+
require(len(metadata_members) == 1, "sdist_metadata_file", "sdist must contain exactly one PKG-INFO file")
|
|
199
|
+
if len(metadata_members) == 1:
|
|
200
|
+
extracted = archive.extractfile(metadata_members[0])
|
|
201
|
+
if extracted is not None:
|
|
202
|
+
sdist_metadata = BytesParser().parsebytes(extracted.read())
|
|
203
|
+
except (OSError, tarfile.TarError) as exc:
|
|
204
|
+
errors.append(f"sdist could not be inspected: {exc}")
|
|
205
|
+
checks["sdist_archive"] = "failed"
|
|
206
|
+
|
|
207
|
+
wheel_name = wheel_version = wheel_python = ""
|
|
208
|
+
sdist_name = sdist_version = sdist_python = ""
|
|
209
|
+
if wheel_metadata is not None:
|
|
210
|
+
wheel_name = wheel_metadata.get("Name", "")
|
|
211
|
+
wheel_version = wheel_metadata.get("Version", "")
|
|
212
|
+
wheel_python = wheel_metadata.get("Requires-Python", "")
|
|
213
|
+
wheel_license = bool(
|
|
214
|
+
wheel_metadata.get("License-Expression")
|
|
215
|
+
or wheel_metadata.get("License")
|
|
216
|
+
or wheel_metadata.get_all("License-File")
|
|
217
|
+
)
|
|
218
|
+
require(canonicalize_name(wheel_name) == expected_name, "wheel_name", f"wheel name is {wheel_name!r}, expected {expected_name!r}")
|
|
219
|
+
metadata_version_matches(wheel_version, "wheel_version")
|
|
220
|
+
require(wheel_python == expected_python, "wheel_requires_python", f"wheel Requires-Python is {wheel_python!r}, expected {expected_python!r}")
|
|
221
|
+
require(wheel_license, "wheel_license", "wheel license metadata is missing")
|
|
222
|
+
if sdist_metadata is not None:
|
|
223
|
+
sdist_name = sdist_metadata.get("Name", "")
|
|
224
|
+
sdist_version = sdist_metadata.get("Version", "")
|
|
225
|
+
sdist_python = sdist_metadata.get("Requires-Python", "")
|
|
226
|
+
sdist_license = bool(
|
|
227
|
+
sdist_metadata.get("License-Expression")
|
|
228
|
+
or sdist_metadata.get("License")
|
|
229
|
+
or sdist_metadata.get_all("License-File")
|
|
230
|
+
)
|
|
231
|
+
require(canonicalize_name(sdist_name) == expected_name, "sdist_name", f"sdist name is {sdist_name!r}, expected {expected_name!r}")
|
|
232
|
+
metadata_version_matches(sdist_version, "sdist_version")
|
|
233
|
+
require(sdist_python == expected_python, "sdist_requires_python", f"sdist Requires-Python is {sdist_python!r}, expected {expected_python!r}")
|
|
234
|
+
require(sdist_license, "sdist_license", "sdist license metadata is missing")
|
|
235
|
+
|
|
236
|
+
if wheel_metadata is not None and sdist_metadata is not None:
|
|
237
|
+
try:
|
|
238
|
+
versions_match = Version(wheel_version) == Version(sdist_version)
|
|
239
|
+
except InvalidVersion:
|
|
240
|
+
versions_match = False
|
|
241
|
+
require(versions_match, "distribution_versions_match", "wheel and sdist versions differ or are invalid")
|
|
242
|
+
|
|
243
|
+
parser = configparser.ConfigParser()
|
|
244
|
+
entry_value = ""
|
|
245
|
+
try:
|
|
246
|
+
if entry_points:
|
|
247
|
+
parser.read_string(entry_points)
|
|
248
|
+
entry_value = parser.get("console_scripts", "feed-sentiment", fallback="")
|
|
249
|
+
except configparser.Error as exc:
|
|
250
|
+
errors.append(f"entry-point metadata could not be parsed: {exc}")
|
|
251
|
+
require(entry_value == "feed_sentiment.cli.app:app", "console_entry_point", f"unexpected or missing feed-sentiment entry point: {entry_value!r}")
|
|
252
|
+
|
|
253
|
+
if len(wheels) == 1:
|
|
254
|
+
try:
|
|
255
|
+
filename_name, filename_version, _build, tags = parse_wheel_filename(wheels[0].name)
|
|
256
|
+
require(canonicalize_name(filename_name) == expected_name, "wheel_filename_name", "wheel filename has the wrong project name")
|
|
257
|
+
require(filename_version == expected_version, "wheel_filename_version", "wheel filename version does not match metadata")
|
|
258
|
+
require({str(tag) for tag in tags} == {"py3-none-any"}, "wheel_tag", "expected one universal py3-none-any wheel")
|
|
259
|
+
require(wheels[0].name == f"feed_sentiment-{expected_version}-py3-none-any.whl", "wheel_filename", "wheel filename is not the expected universal filename")
|
|
260
|
+
except InvalidWheelFilename as exc:
|
|
261
|
+
errors.append(f"invalid wheel filename: {exc}")
|
|
262
|
+
checks["wheel_filename"] = "failed"
|
|
263
|
+
if len(sdists) == 1:
|
|
264
|
+
try:
|
|
265
|
+
filename_name, filename_version = parse_sdist_filename(sdists[0].name)
|
|
266
|
+
require(canonicalize_name(filename_name) == expected_name, "sdist_filename_name", "sdist filename has the wrong project name")
|
|
267
|
+
require(filename_version == expected_version, "sdist_filename_version", "sdist filename version does not match metadata")
|
|
268
|
+
require(sdists[0].name == f"feed_sentiment-{expected_version}.tar.gz", "sdist_filename", "sdist filename is not the expected filename")
|
|
269
|
+
except InvalidSdistFilename as exc:
|
|
270
|
+
errors.append(f"invalid sdist filename: {exc}")
|
|
271
|
+
checks["sdist_filename"] = "failed"
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
Requirement(f"{expected_name}=={expected_version}")
|
|
275
|
+
checks["valid_package_version"] = "passed"
|
|
276
|
+
except InvalidRequirement as exc:
|
|
277
|
+
errors.append(f"invalid package name/version requirement: {exc}")
|
|
278
|
+
checks["valid_package_version"] = "failed"
|
|
279
|
+
|
|
280
|
+
manifest_lines = [
|
|
281
|
+
f"GitHub Release tag: {os.environ['RELEASE_TAG']}",
|
|
282
|
+
f"Release commit SHA: {os.environ['RELEASE_SHA']}",
|
|
283
|
+
f"Package name: {expected_name}",
|
|
284
|
+
f"Package version: {expected_version}",
|
|
285
|
+
f"Validation timestamp UTC: {datetime.now(UTC).isoformat()}",
|
|
286
|
+
"",
|
|
287
|
+
"filename\ttype\tsize_bytes\tsha256",
|
|
288
|
+
]
|
|
289
|
+
checksum_lines = []
|
|
290
|
+
distribution_rows = []
|
|
291
|
+
for path in files:
|
|
292
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
293
|
+
kind = "wheel" if path in wheels else "sdist" if path in sdists else "unknown"
|
|
294
|
+
manifest_lines.append(f"{path.name}\t{kind}\t{path.stat().st_size}\t{digest}")
|
|
295
|
+
checksum_lines.append(f"{digest} {path.name}")
|
|
296
|
+
distribution_rows.append({"filename": path.name, "type": kind, "size": path.stat().st_size, "sha256": digest})
|
|
297
|
+
results.mkdir(exist_ok=True)
|
|
298
|
+
(results / "manifest.txt").write_text("\n".join(manifest_lines) + "\n", encoding="utf-8")
|
|
299
|
+
(results / "SHA256SUMS").write_text("\n".join(checksum_lines) + ("\n" if checksum_lines else ""), encoding="utf-8")
|
|
300
|
+
report = {
|
|
301
|
+
"release_tag": os.environ["RELEASE_TAG"],
|
|
302
|
+
"release_sha": os.environ["RELEASE_SHA"],
|
|
303
|
+
"package_name": expected_name,
|
|
304
|
+
"package_version": str(expected_version),
|
|
305
|
+
"wheel_count": len(wheels),
|
|
306
|
+
"sdist_count": len(sdists),
|
|
307
|
+
"unknown_count": len(unknown),
|
|
308
|
+
"wheel_metadata_version": wheel_version,
|
|
309
|
+
"sdist_metadata_version": sdist_version,
|
|
310
|
+
"requires_python": wheel_python,
|
|
311
|
+
"checks": checks,
|
|
312
|
+
"distributions": distribution_rows,
|
|
313
|
+
"outcome": "passed" if not errors else "failed",
|
|
314
|
+
"errors": errors,
|
|
315
|
+
}
|
|
316
|
+
(results / "release-validation.txt").write_text(
|
|
317
|
+
json.dumps(report, indent=2) + "\n", encoding="utf-8"
|
|
318
|
+
)
|
|
319
|
+
if errors:
|
|
320
|
+
raise SystemExit("Artifact validation failed: " + "; ".join(errors))
|
|
321
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
322
|
+
output.write(f"package-version={expected_version}\n")
|
|
323
|
+
print(f"Validated {len(files)} release distributions for {expected_name} {expected_version}")
|
|
324
|
+
PY
|
|
325
|
+
|
|
326
|
+
- name: Upload release-validation diagnostics
|
|
327
|
+
id: validation_artifact
|
|
328
|
+
if: always()
|
|
329
|
+
uses: actions/upload-artifact@v6
|
|
330
|
+
with:
|
|
331
|
+
name: pypi-release-validation
|
|
332
|
+
path: release-results/
|
|
333
|
+
if-no-files-found: warn
|
|
334
|
+
retention-days: 7
|
|
335
|
+
|
|
336
|
+
- name: Write release-validation summary
|
|
337
|
+
if: always()
|
|
338
|
+
env:
|
|
339
|
+
QUALITY_RESULT: ${{ needs.quality.result }}
|
|
340
|
+
RELEASE_OUTCOME: ${{ steps.release.outcome }}
|
|
341
|
+
DOWNLOAD_OUTCOME: ${{ steps.download.outcome }}
|
|
342
|
+
METADATA_OUTCOME: ${{ steps.metadata.outcome }}
|
|
343
|
+
UPLOAD_OUTCOME: ${{ steps.validation_artifact.outcome }}
|
|
344
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
345
|
+
RELEASE_NAME: ${{ github.event.release.name }}
|
|
346
|
+
RELEASE_SHA: ${{ github.sha }}
|
|
347
|
+
RELEASE_DRAFT: ${{ github.event.release.draft }}
|
|
348
|
+
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
|
349
|
+
run: |
|
|
350
|
+
python - <<'PY'
|
|
351
|
+
import json
|
|
352
|
+
import os
|
|
353
|
+
from pathlib import Path
|
|
354
|
+
|
|
355
|
+
def safe(value: object) -> str:
|
|
356
|
+
return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
357
|
+
|
|
358
|
+
outcomes = [
|
|
359
|
+
("Quality workflow", os.environ["QUALITY_RESULT"]),
|
|
360
|
+
("Release event validation", os.environ["RELEASE_OUTCOME"]),
|
|
361
|
+
("Distribution artifact download", os.environ["DOWNLOAD_OUTCOME"]),
|
|
362
|
+
("Artifact metadata validation", os.environ["METADATA_OUTCOME"]),
|
|
363
|
+
("Validation artifact upload", os.environ["UPLOAD_OUTCOME"]),
|
|
364
|
+
]
|
|
365
|
+
values = [value for _, value in outcomes]
|
|
366
|
+
if all(value == "success" for value in values):
|
|
367
|
+
overall = "Passed"
|
|
368
|
+
elif any(value == "cancelled" for value in values):
|
|
369
|
+
overall = "Cancelled"
|
|
370
|
+
elif all(value == "skipped" for value in values):
|
|
371
|
+
overall = "Skipped"
|
|
372
|
+
else:
|
|
373
|
+
overall = "Failed"
|
|
374
|
+
|
|
375
|
+
context = {}
|
|
376
|
+
report = {}
|
|
377
|
+
for path, target in [
|
|
378
|
+
(Path("release-results/release-context.json"), context),
|
|
379
|
+
(Path("release-results/release-validation.txt"), report),
|
|
380
|
+
]:
|
|
381
|
+
if path.is_file():
|
|
382
|
+
try:
|
|
383
|
+
target.update(json.loads(path.read_text(encoding="utf-8")))
|
|
384
|
+
except (OSError, json.JSONDecodeError):
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
lines = [
|
|
388
|
+
"## Release artifact validation",
|
|
389
|
+
"",
|
|
390
|
+
f"**Overall result: {overall}**",
|
|
391
|
+
"",
|
|
392
|
+
"| Release information | Value |",
|
|
393
|
+
"|---|---|",
|
|
394
|
+
f"| Release tag | `{safe(os.environ['RELEASE_TAG'])}` |",
|
|
395
|
+
f"| Release name | {safe(os.environ['RELEASE_NAME'])} |",
|
|
396
|
+
f"| Release commit | `{safe(os.environ['RELEASE_SHA'])}` |",
|
|
397
|
+
f"| Draft | `{safe(os.environ['RELEASE_DRAFT'])}` |",
|
|
398
|
+
f"| Prerelease | `{safe(os.environ['RELEASE_PRERELEASE'])}` |",
|
|
399
|
+
f"| Expected version from tag | `{safe(context.get('expected_version', 'unavailable'))}` |",
|
|
400
|
+
f"| Package metadata version | `{safe(report.get('package_version', 'unavailable'))}` |",
|
|
401
|
+
f"| Version match | `{safe(report.get('checks', {}).get('wheel_version', 'unavailable'))}` |",
|
|
402
|
+
f"| Wheel count | `{safe(report.get('wheel_count', 'unavailable'))}` |",
|
|
403
|
+
f"| Source-distribution count | `{safe(report.get('sdist_count', 'unavailable'))}` |",
|
|
404
|
+
"",
|
|
405
|
+
"### Gate outcomes",
|
|
406
|
+
"",
|
|
407
|
+
"| Gate | Outcome |",
|
|
408
|
+
"|---|---|",
|
|
409
|
+
]
|
|
410
|
+
lines.extend(f"| {name} | `{safe(value)}` |" for name, value in outcomes)
|
|
411
|
+
lines += ["", "### Distributions", ""]
|
|
412
|
+
distributions = report.get("distributions", [])
|
|
413
|
+
if distributions:
|
|
414
|
+
lines += ["| Filename | Type | Size (bytes) | SHA-256 |", "|---|---|---:|---|"]
|
|
415
|
+
for item in distributions:
|
|
416
|
+
lines.append(
|
|
417
|
+
f"| `{safe(item.get('filename', ''))}` | {safe(item.get('type', ''))} | "
|
|
418
|
+
f"{safe(item.get('size', ''))} | `{safe(item.get('sha256', ''))}` |"
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
lines.append("No distribution details were available.")
|
|
422
|
+
lines += [
|
|
423
|
+
"",
|
|
424
|
+
"Validation diagnostics: `pypi-release-validation` (7-day retention).",
|
|
425
|
+
f"Upload outcome: `{safe(os.environ['UPLOAD_OUTCOME'])}`.",
|
|
426
|
+
"",
|
|
427
|
+
f"## Overall result: {overall}",
|
|
428
|
+
]
|
|
429
|
+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
|
|
430
|
+
summary.write("\n".join(lines) + "\n")
|
|
431
|
+
PY
|
|
432
|
+
|
|
433
|
+
publish:
|
|
434
|
+
name: Publish validated distributions to PyPI
|
|
435
|
+
needs: [quality, release_validation]
|
|
436
|
+
runs-on: ubuntu-latest
|
|
437
|
+
environment:
|
|
438
|
+
name: pypi
|
|
439
|
+
url: https://pypi.org/project/feed-sentiment/
|
|
440
|
+
permissions:
|
|
441
|
+
id-token: write
|
|
442
|
+
steps:
|
|
443
|
+
- name: Download validated distributions
|
|
444
|
+
uses: actions/download-artifact@v8
|
|
445
|
+
with:
|
|
446
|
+
name: python-package-distributions
|
|
447
|
+
path: dist/
|
|
448
|
+
|
|
449
|
+
- name: Publish distributions with Trusted Publishing
|
|
450
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
451
|
+
with:
|
|
452
|
+
packages-dir: dist/
|
|
453
|
+
print-hash: true
|
|
454
|
+
|
|
455
|
+
verify:
|
|
456
|
+
name: Verify production PyPI release
|
|
457
|
+
needs: publish
|
|
458
|
+
runs-on: ubuntu-latest
|
|
459
|
+
permissions:
|
|
460
|
+
contents: read
|
|
461
|
+
steps:
|
|
462
|
+
- name: Set up Python 3.12
|
|
463
|
+
uses: actions/setup-python@v6
|
|
464
|
+
with:
|
|
465
|
+
python-version: "3.12"
|
|
466
|
+
|
|
467
|
+
- name: Initialize verification diagnostics
|
|
468
|
+
run: mkdir -p release-results
|
|
469
|
+
|
|
470
|
+
- name: Install exact release from production PyPI
|
|
471
|
+
id: install
|
|
472
|
+
env:
|
|
473
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
474
|
+
shell: bash
|
|
475
|
+
run: |
|
|
476
|
+
set -o pipefail
|
|
477
|
+
[[ "$RELEASE_TAG" == v* ]] || { echo "Release tag must start with v" >&2; exit 1; }
|
|
478
|
+
EXPECTED_VERSION="${RELEASE_TAG#v}"
|
|
479
|
+
ENV_DIR="$RUNNER_TEMP/feed-sentiment-pypi"
|
|
480
|
+
python -m venv "$ENV_DIR"
|
|
481
|
+
: > release-results/pypi-install.txt
|
|
482
|
+
installed=false
|
|
483
|
+
attempts=0
|
|
484
|
+
for attempt in 1 2 3 4 5; do
|
|
485
|
+
attempts="$attempt"
|
|
486
|
+
echo "PyPI installation attempt $attempt of 5 for feed-sentiment==$EXPECTED_VERSION" | tee -a release-results/pypi-install.txt
|
|
487
|
+
if "$ENV_DIR/bin/python" -m pip install --no-cache-dir --index-url https://pypi.org/simple "feed-sentiment==$EXPECTED_VERSION" 2>&1 | tee -a release-results/pypi-install.txt; then
|
|
488
|
+
installed=true
|
|
489
|
+
break
|
|
490
|
+
fi
|
|
491
|
+
if [[ "$attempt" -lt 5 ]]; then
|
|
492
|
+
echo "Version not available yet; retrying in 15 seconds." | tee -a release-results/pypi-install.txt
|
|
493
|
+
sleep 15
|
|
494
|
+
fi
|
|
495
|
+
done
|
|
496
|
+
printf '%s\n' "$attempts" > release-results/install-attempts.txt
|
|
497
|
+
[[ "$installed" == true ]] || { echo "Production PyPI installation failed after $attempts attempts" >&2; exit 1; }
|
|
498
|
+
echo "expected-version=$EXPECTED_VERSION" >> "$GITHUB_OUTPUT"
|
|
499
|
+
echo "environment-dir=$ENV_DIR" >> "$GITHUB_OUTPUT"
|
|
500
|
+
echo "attempts=$attempts" >> "$GITHUB_OUTPUT"
|
|
501
|
+
|
|
502
|
+
- name: Smoke-test installed PyPI package
|
|
503
|
+
id: smoke
|
|
504
|
+
env:
|
|
505
|
+
EXPECTED_VERSION: ${{ steps.install.outputs.expected-version }}
|
|
506
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
507
|
+
ENV_DIR: ${{ steps.install.outputs.environment-dir }}
|
|
508
|
+
shell: bash
|
|
509
|
+
run: |
|
|
510
|
+
set -o pipefail
|
|
511
|
+
cd "$RUNNER_TEMP"
|
|
512
|
+
{
|
|
513
|
+
"$ENV_DIR/bin/python" - <<'PY'
|
|
514
|
+
import json
|
|
515
|
+
import os
|
|
516
|
+
from importlib.metadata import version
|
|
517
|
+
|
|
518
|
+
import feed_sentiment
|
|
519
|
+
from feed_sentiment import RetrievalPolicy, analyze_text
|
|
520
|
+
|
|
521
|
+
expected = os.environ["EXPECTED_VERSION"]
|
|
522
|
+
installed = version("feed-sentiment")
|
|
523
|
+
score = analyze_text("This release is excellent!")
|
|
524
|
+
assert installed == expected
|
|
525
|
+
assert feed_sentiment.__version__ == installed
|
|
526
|
+
assert score.label == "positive"
|
|
527
|
+
assert RetrievalPolicy().user_agent == (
|
|
528
|
+
f"feed-sentiment/{installed} "
|
|
529
|
+
"(+https://github.com/kurtpatrickyu/feed-sentiment)"
|
|
530
|
+
)
|
|
531
|
+
print(json.dumps({
|
|
532
|
+
"installed_version": installed,
|
|
533
|
+
"import": "passed",
|
|
534
|
+
"public_version": "passed",
|
|
535
|
+
"analyze_text": score.label,
|
|
536
|
+
"user_agent": "passed",
|
|
537
|
+
}))
|
|
538
|
+
PY
|
|
539
|
+
"$ENV_DIR/bin/feed-sentiment" --help >/dev/null
|
|
540
|
+
CLI_VERSION="$($ENV_DIR/bin/feed-sentiment --version)"
|
|
541
|
+
[[ "$CLI_VERSION" == "$EXPECTED_VERSION" ]]
|
|
542
|
+
printf 'CLI help: passed\nCLI version: passed (%s)\n' "$CLI_VERSION"
|
|
543
|
+
} 2>&1 | tee "$GITHUB_WORKSPACE/release-results/pypi-smoke.txt"
|
|
544
|
+
|
|
545
|
+
- name: Upload PyPI verification diagnostics
|
|
546
|
+
id: verification_artifact
|
|
547
|
+
if: always()
|
|
548
|
+
uses: actions/upload-artifact@v6
|
|
549
|
+
with:
|
|
550
|
+
name: pypi-verification-results
|
|
551
|
+
path: release-results/
|
|
552
|
+
if-no-files-found: warn
|
|
553
|
+
retention-days: 7
|
|
554
|
+
|
|
555
|
+
- name: Write PyPI verification summary
|
|
556
|
+
if: always()
|
|
557
|
+
env:
|
|
558
|
+
EXPECTED_VERSION: ${{ steps.install.outputs.expected-version }}
|
|
559
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
560
|
+
INSTALL_ATTEMPTS: ${{ steps.install.outputs.attempts }}
|
|
561
|
+
INSTALL_OUTCOME: ${{ steps.install.outcome }}
|
|
562
|
+
SMOKE_OUTCOME: ${{ steps.smoke.outcome }}
|
|
563
|
+
UPLOAD_OUTCOME: ${{ steps.verification_artifact.outcome }}
|
|
564
|
+
run: |
|
|
565
|
+
python - <<'PY'
|
|
566
|
+
import json
|
|
567
|
+
import os
|
|
568
|
+
from pathlib import Path
|
|
569
|
+
|
|
570
|
+
values = [os.environ["INSTALL_OUTCOME"], os.environ["SMOKE_OUTCOME"], os.environ["UPLOAD_OUTCOME"]]
|
|
571
|
+
if all(value == "success" for value in values):
|
|
572
|
+
overall = "Passed"
|
|
573
|
+
elif any(value == "cancelled" for value in values):
|
|
574
|
+
overall = "Cancelled"
|
|
575
|
+
elif all(value == "skipped" for value in values):
|
|
576
|
+
overall = "Skipped"
|
|
577
|
+
else:
|
|
578
|
+
overall = "Failed"
|
|
579
|
+
smoke_text = Path("release-results/pypi-smoke.txt").read_text(encoding="utf-8", errors="replace") if Path("release-results/pypi-smoke.txt").is_file() else ""
|
|
580
|
+
details = {}
|
|
581
|
+
for line in smoke_text.splitlines():
|
|
582
|
+
try:
|
|
583
|
+
candidate = json.loads(line)
|
|
584
|
+
except json.JSONDecodeError:
|
|
585
|
+
continue
|
|
586
|
+
if isinstance(candidate, dict) and "installed_version" in candidate:
|
|
587
|
+
details = candidate
|
|
588
|
+
|
|
589
|
+
attempts = os.environ["INSTALL_ATTEMPTS"]
|
|
590
|
+
if not attempts and Path("release-results/install-attempts.txt").is_file():
|
|
591
|
+
attempts = Path("release-results/install-attempts.txt").read_text(encoding="utf-8").strip()
|
|
592
|
+
expected_version = os.environ["EXPECTED_VERSION"]
|
|
593
|
+
if not expected_version and os.environ["RELEASE_TAG"].startswith("v"):
|
|
594
|
+
expected_version = os.environ["RELEASE_TAG"][1:]
|
|
595
|
+
lines = [
|
|
596
|
+
"## Production PyPI verification",
|
|
597
|
+
"",
|
|
598
|
+
f"**Overall result: {overall}**",
|
|
599
|
+
"",
|
|
600
|
+
"| Check | Result |",
|
|
601
|
+
"|---|---|",
|
|
602
|
+
f"| Expected package version | `{expected_version or 'unavailable'}` |",
|
|
603
|
+
f"| Installation attempts | `{attempts or 'unavailable'}` |",
|
|
604
|
+
f"| PyPI installation | `{os.environ['INSTALL_OUTCOME']}` |",
|
|
605
|
+
f"| Installed metadata version | `{details.get('installed_version', 'unavailable')}` |",
|
|
606
|
+
f"| Package import | `{details.get('import', 'unavailable')}` |",
|
|
607
|
+
f"| Public `__version__` | `{details.get('public_version', 'unavailable')}` |",
|
|
608
|
+
f"| `analyze_text()` | `{details.get('analyze_text', 'unavailable')}` |",
|
|
609
|
+
f"| Dynamic User-Agent | `{details.get('user_agent', 'unavailable')}` |",
|
|
610
|
+
f"| CLI help | `{'passed' if 'CLI help: passed' in smoke_text else 'unavailable'}` |",
|
|
611
|
+
f"| CLI version | `{'passed' if 'CLI version: passed' in smoke_text else 'unavailable'}` |",
|
|
612
|
+
f"| Diagnostic artifact | `pypi-verification-results` (`{os.environ['UPLOAD_OUTCOME']}`) |",
|
|
613
|
+
"",
|
|
614
|
+
"A verification failure does not remove or roll back a package that was already published to PyPI.",
|
|
615
|
+
"",
|
|
616
|
+
f"## Overall result: {overall}",
|
|
617
|
+
]
|
|
618
|
+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
|
|
619
|
+
summary.write("\n".join(lines) + "\n")
|
|
620
|
+
PY
|
|
621
|
+
|
|
622
|
+
summary:
|
|
623
|
+
name: Final publication summary
|
|
624
|
+
needs: [quality, release_validation, publish, verify]
|
|
625
|
+
if: always()
|
|
626
|
+
runs-on: ubuntu-latest
|
|
627
|
+
permissions:
|
|
628
|
+
contents: read
|
|
629
|
+
steps:
|
|
630
|
+
- name: Write final publication summary
|
|
631
|
+
env:
|
|
632
|
+
QUALITY_RESULT: ${{ needs.quality.result }}
|
|
633
|
+
VALIDATION_RESULT: ${{ needs.release_validation.result }}
|
|
634
|
+
PUBLISH_RESULT: ${{ needs.publish.result }}
|
|
635
|
+
VERIFY_RESULT: ${{ needs.verify.result }}
|
|
636
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
637
|
+
RELEASE_SHA: ${{ github.sha }}
|
|
638
|
+
RELEASE_URL: ${{ github.event.release.html_url }}
|
|
639
|
+
run: |
|
|
640
|
+
python - <<'PY'
|
|
641
|
+
import os
|
|
642
|
+
|
|
643
|
+
def safe(value: str) -> str:
|
|
644
|
+
return value.replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
645
|
+
|
|
646
|
+
tag = os.environ["RELEASE_TAG"]
|
|
647
|
+
version = tag[1:] if tag.startswith("v") else "unavailable"
|
|
648
|
+
quality = os.environ["QUALITY_RESULT"]
|
|
649
|
+
validation = os.environ["VALIDATION_RESULT"]
|
|
650
|
+
publication = os.environ["PUBLISH_RESULT"]
|
|
651
|
+
verification = os.environ["VERIFY_RESULT"]
|
|
652
|
+
if all(value == "success" for value in (quality, validation, publication, verification)):
|
|
653
|
+
overall = "Successful production release"
|
|
654
|
+
elif publication == "success":
|
|
655
|
+
overall = "Package published but post-publication verification failed"
|
|
656
|
+
else:
|
|
657
|
+
overall = "Package was not published by this run"
|
|
658
|
+
|
|
659
|
+
pypi_url = "https://pypi.org/project/feed-sentiment/"
|
|
660
|
+
lines = [
|
|
661
|
+
"## Feed Sentiment publication",
|
|
662
|
+
"",
|
|
663
|
+
f"**Overall release status: {overall}**",
|
|
664
|
+
"",
|
|
665
|
+
"| Stage | Job result | Artifact | URL |",
|
|
666
|
+
"|---|---|---|---|",
|
|
667
|
+
f"| Quality gates | `{safe(quality)}` | `python-package-distributions` | n/a |",
|
|
668
|
+
f"| Release validation | `{safe(validation)}` | `pypi-release-validation` | n/a |",
|
|
669
|
+
f"| PyPI publication | `{safe(publication)}` | Uses validated distributions | [PyPI]({pypi_url}) |",
|
|
670
|
+
f"| Post-publication verification | `{safe(verification)}` | `pypi-verification-results` | [PyPI]({pypi_url}) |",
|
|
671
|
+
"",
|
|
672
|
+
"| Release information | Value |",
|
|
673
|
+
"|---|---|",
|
|
674
|
+
"| PyPI project | `feed-sentiment` |",
|
|
675
|
+
f"| GitHub Release tag | `{safe(tag)}` |",
|
|
676
|
+
f"| Package version | `{safe(version)}` |",
|
|
677
|
+
f"| Release commit SHA | `{safe(os.environ['RELEASE_SHA'])}` |",
|
|
678
|
+
"| GitHub environment | `pypi` |",
|
|
679
|
+
"| Authentication | Trusted Publishing OIDC |",
|
|
680
|
+
"| Distribution artifact | `python-package-distributions` |",
|
|
681
|
+
"| Validation artifact | `pypi-release-validation` |",
|
|
682
|
+
"| Verification artifact | `pypi-verification-results` |",
|
|
683
|
+
f"| PyPI project URL | [feed-sentiment]({pypi_url}) |",
|
|
684
|
+
f"| GitHub Release URL | [release]({safe(os.environ['RELEASE_URL'])}) |",
|
|
685
|
+
"| Attestations | Expected: enabled by the publisher action default |",
|
|
686
|
+
"",
|
|
687
|
+
"PyPI publication is not rolled back automatically if post-publication verification fails.",
|
|
688
|
+
]
|
|
689
|
+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
|
|
690
|
+
summary.write("\n".join(lines) + "\n")
|
|
691
|
+
PY
|