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.
Files changed (32) hide show
  1. feed_sentiment-0.1.0/.github/workflows/publish.yml +691 -0
  2. feed_sentiment-0.1.0/.github/workflows/quality.yml +557 -0
  3. feed_sentiment-0.1.0/.gitignore +149 -0
  4. feed_sentiment-0.1.0/LICENSE +21 -0
  5. feed_sentiment-0.1.0/PKG-INFO +117 -0
  6. feed_sentiment-0.1.0/README.md +80 -0
  7. feed_sentiment-0.1.0/docs/releasing.md +34 -0
  8. feed_sentiment-0.1.0/pyproject.toml +81 -0
  9. feed_sentiment-0.1.0/src/feed_sentiment/__init__.py +58 -0
  10. feed_sentiment-0.1.0/src/feed_sentiment/_metadata.py +18 -0
  11. feed_sentiment-0.1.0/src/feed_sentiment/analyzers/__init__.py +4 -0
  12. feed_sentiment-0.1.0/src/feed_sentiment/analyzers/base.py +15 -0
  13. feed_sentiment-0.1.0/src/feed_sentiment/analyzers/vader.py +31 -0
  14. feed_sentiment-0.1.0/src/feed_sentiment/cli/__init__.py +3 -0
  15. feed_sentiment-0.1.0/src/feed_sentiment/cli/app.py +93 -0
  16. feed_sentiment-0.1.0/src/feed_sentiment/exceptions/__init__.py +34 -0
  17. feed_sentiment-0.1.0/src/feed_sentiment/feeds/__init__.py +19 -0
  18. feed_sentiment-0.1.0/src/feed_sentiment/feeds/parser.py +125 -0
  19. feed_sentiment-0.1.0/src/feed_sentiment/feeds/retriever.py +135 -0
  20. feed_sentiment-0.1.0/src/feed_sentiment/models.py +131 -0
  21. feed_sentiment-0.1.0/src/feed_sentiment/services/__init__.py +8 -0
  22. feed_sentiment-0.1.0/src/feed_sentiment/services/analysis.py +106 -0
  23. feed_sentiment-0.1.0/tests/fixtures/atom.xml +4 -0
  24. feed_sentiment-0.1.0/tests/fixtures/empty.xml +1 -0
  25. feed_sentiment-0.1.0/tests/fixtures/malformed.xml +1 -0
  26. feed_sentiment-0.1.0/tests/fixtures/rss.xml +7 -0
  27. feed_sentiment-0.1.0/tests/integration/test_cli.py +60 -0
  28. feed_sentiment-0.1.0/tests/integration/test_public_api.py +48 -0
  29. feed_sentiment-0.1.0/tests/packaging/smoke.py +86 -0
  30. feed_sentiment-0.1.0/tests/unit/test_analysis.py +78 -0
  31. feed_sentiment-0.1.0/tests/unit/test_feeds.py +148 -0
  32. 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