Skip to content

Commit b0748fe

Browse files
Pigbibicodex
andcommitted
Gate RSS live publication on complete fetches
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf0dcaf commit b0748fe

5 files changed

Lines changed: 210 additions & 38 deletions

File tree

.github/workflows/rss_source_pipeline.yml

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ on:
44
workflow_dispatch:
55
inputs:
66
feeds_path:
7-
description: "RSS feed config CSV path."
7+
description: "Must remain the canonical feed configuration path."
88
required: false
99
default: "config/free_rss_feeds.csv"
1010
type: string
1111
aliases_path:
12-
description: "Symbol alias CSV path."
12+
description: "Must remain the canonical alias configuration path."
1313
required: false
1414
default: "config/core_us_equity_aliases.csv"
1515
type: string
1616
watchlist_path:
17-
description: "Watchlist CSV path."
17+
description: "Must remain the canonical watchlist path."
1818
required: false
1919
default: "data/live/political_watchlist.csv"
2020
type: string
@@ -28,9 +28,7 @@ on:
2828
required: false
2929
default: "false"
3030
type: choice
31-
options:
32-
- "false"
33-
- "true"
31+
options: ["false", "true"]
3432
schedule:
3533
- cron: "15 12 * * 6"
3634

@@ -42,74 +40,112 @@ concurrency:
4240
cancel-in-progress: false
4341

4442
jobs:
43+
validate-workflow-boundary:
44+
if: >-
45+
github.repository == 'QuantStrategyLab/PoliticalEventTrackingResearch' &&
46+
github.ref == 'refs/heads/main' &&
47+
github.workflow_ref == 'QuantStrategyLab/PoliticalEventTrackingResearch/.github/workflows/rss_source_pipeline.yml@refs/heads/main'
48+
runs-on: ubuntu-latest
49+
steps:
50+
- name: Validate canonical workflow inputs
51+
env:
52+
EVENT_NAME: ${{ github.event_name }}
53+
FEEDS_PATH: ${{ github.event.inputs.feeds_path || 'config/free_rss_feeds.csv' }}
54+
ALIASES_PATH: ${{ github.event.inputs.aliases_path || 'config/core_us_equity_aliases.csv' }}
55+
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
56+
run: |
57+
set -euo pipefail
58+
python3 - <<'PY'
59+
import os
60+
expected = (
61+
"config/free_rss_feeds.csv",
62+
"config/core_us_equity_aliases.csv",
63+
"data/live/political_watchlist.csv",
64+
)
65+
actual = tuple(os.environ[key] for key in ("FEEDS_PATH", "ALIASES_PATH", "WATCHLIST_PATH"))
66+
if actual != expected:
67+
raise SystemExit("workflow_input_path_invalid")
68+
PY
69+
4570
build-rss-source-events:
71+
needs: validate-workflow-boundary
72+
if: needs.validate-workflow-boundary.result == 'success'
4673
runs-on: ubuntu-latest
4774
timeout-minutes: 30
4875
steps:
4976
- uses: actions/checkout@v6
77+
with:
78+
repository: QuantStrategyLab/PoliticalEventTrackingResearch
79+
ref: ${{ github.sha }}
80+
persist-credentials: false
81+
- name: Verify reviewed main checkout
82+
env:
83+
EXPECTED_SHA: ${{ github.sha }}
84+
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
5085
- uses: actions/setup-python@v6
5186
with:
5287
python-version: "3.11"
5388
- name: Install package
5489
run: python -m pip install -e .
5590
- name: Fetch RSS sources and extract mentions
5691
env:
57-
FEEDS_PATH: ${{ github.event.inputs.feeds_path || 'config/free_rss_feeds.csv' }}
58-
ALIASES_PATH: ${{ github.event.inputs.aliases_path || 'config/core_us_equity_aliases.csv' }}
59-
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
6092
MAX_ITEMS_PER_FEED: ${{ github.event.inputs.max_items_per_feed || '50' }}
6193
run: |
6294
set -euo pipefail
6395
mkdir -p data/output/rss_source_pipeline
96+
fetch_exit=0
6497
python scripts/fetch_rss_sources.py \
65-
--feeds "${FEEDS_PATH}" \
98+
--feeds config/free_rss_feeds.csv \
6699
--output data/output/rss_source_pipeline/source_items.csv \
67100
--max-items-per-feed "${MAX_ITEMS_PER_FEED}" \
68101
--continue-on-feed-error \
69-
--status-output data/output/rss_source_pipeline/source_fetch_status.json
70-
python scripts/extract_source_mentions.py \
71-
--raw-items data/output/rss_source_pipeline/source_items.csv \
72-
--aliases "${ALIASES_PATH}" \
73-
--output data/output/rss_source_pipeline/source_events.csv
74-
python scripts/build_tracker.py \
75-
--watchlist "${WATCHLIST_PATH}" \
76-
--events data/output/rss_source_pipeline/source_events.csv \
77-
--output data/output/rss_source_pipeline/source_tracker.csv
102+
--status-output data/output/rss_source_pipeline/source_fetch_status.json || fetch_exit=$?
103+
if [ -f data/output/rss_source_pipeline/source_items.csv ]; then
104+
python scripts/extract_source_mentions.py \
105+
--raw-items data/output/rss_source_pipeline/source_items.csv \
106+
--aliases config/core_us_equity_aliases.csv \
107+
--output data/output/rss_source_pipeline/source_events.csv
108+
python scripts/build_tracker.py \
109+
--watchlist data/live/political_watchlist.csv \
110+
--events data/output/rss_source_pipeline/source_events.csv \
111+
--output data/output/rss_source_pipeline/source_tracker.csv
112+
fi
113+
printf 'fetch_exit=%s\n' "${fetch_exit}"
114+
- name: Upload RSS source artifact
115+
uses: actions/upload-artifact@v7
116+
with:
117+
name: rss-source-pipeline
118+
path: data/output/rss_source_pipeline/
119+
if-no-files-found: error
120+
- name: Validate feed completeness
121+
run: python scripts/validate_fetch_status.py --status data/output/rss_source_pipeline/source_fetch_status.json
78122
- name: Publish live CSV outputs to repository
79123
env:
80124
COMMIT_OUTPUTS: ${{ github.event_name == 'schedule' && 'true' || github.event.inputs.commit_outputs || 'false' }}
125+
GITHUB_TOKEN: ${{ github.token }}
126+
EXPECTED_SHA: ${{ github.sha }}
81127
run: |
82128
set -euo pipefail
83-
if [ "${COMMIT_OUTPUTS}" != "true" ]; then
84-
echo "Live output commit disabled."
85-
exit 0
86-
fi
129+
if [ "${COMMIT_OUTPUTS}" != "true" ]; then echo "Live output commit disabled."; exit 0; fi
130+
test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
87131
mkdir -p data/live
88132
cp data/output/rss_source_pipeline/source_items.csv data/live/source_items.csv
89133
cp data/output/rss_source_pipeline/source_events.csv data/live/source_events.csv
90134
cp data/output/rss_source_pipeline/source_events.csv data/live/political_events.csv
91135
cp data/output/rss_source_pipeline/source_tracker.csv data/live/source_tracker.csv
92136
cp data/output/rss_source_pipeline/source_fetch_status.json data/live/source_fetch_status.json
93-
python scripts/write_live_manifest.py \
94-
--base-dir . \
95-
--output data/live/source_manifest.json \
96-
data/live/source_fetch_status.json \
97-
data/live/source_items.csv \
98-
data/live/source_events.csv \
99-
data/live/political_events.csv \
100-
data/live/source_tracker.csv
137+
python scripts/write_live_manifest.py --base-dir . --output data/live/source_manifest.json data/live/source_fetch_status.json data/live/source_items.csv data/live/source_events.csv data/live/political_events.csv data/live/source_tracker.csv
101138
git config user.name "github-actions[bot]"
102139
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
103140
git add data/live/source_items.csv data/live/source_events.csv data/live/political_events.csv data/live/source_tracker.csv data/live/source_fetch_status.json data/live/source_manifest.json
104141
if git diff --cached --quiet; then
105142
echo "No live RSS output changes to commit."
106143
else
107144
git commit -m "Update live RSS source events [skip ci]"
108-
git push
145+
git config --local http.https://github.com/.extraheader "AUTHORIZATION: bearer ${GITHUB_TOKEN}"
146+
cleanup_git_auth() { git config --local --unset-all http.https://github.com/.extraheader >/dev/null 2>&1 || true; }
147+
trap cleanup_git_auth EXIT
148+
git push origin HEAD:refs/heads/main
149+
cleanup_git_auth
150+
trap - EXIT
109151
fi
110-
- name: Upload RSS source artifact
111-
uses: actions/upload-artifact@v7
112-
with:
113-
name: rss-source-pipeline
114-
path: data/output/rss_source_pipeline/
115-
if-no-files-found: error

scripts/validate_fetch_status.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env python3
2+
"""Fail closed unless the existing RSS fetch status is complete."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import sys
8+
from pathlib import Path
9+
10+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11+
12+
from political_event_tracking_research.rss_source_fetch import FetchStatusError, validate_fetch_status # noqa: E402
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser()
17+
parser.add_argument("--status", required=True, type=Path)
18+
args = parser.parse_args()
19+
try:
20+
complete = validate_fetch_status(json.loads(args.status.read_text(encoding="utf-8")))
21+
except (FetchStatusError, OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
22+
raise SystemExit("fetch_status_invalid") from None
23+
if not complete:
24+
raise SystemExit("fetch_incomplete")
25+
26+
27+
if __name__ == "__main__":
28+
main()

src/political_event_tracking_research/rss_source_fetch.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,35 @@ def to_json(self) -> dict[str, object]:
4848
}
4949

5050

51+
class FetchStatusError(ValueError):
52+
def __init__(self, code: str) -> None:
53+
self.code = code
54+
super().__init__(code)
55+
56+
57+
def validate_fetch_status(payload: object) -> bool:
58+
required = {"generated_at", "feed_count", "successful_feed_count", "failed_feed_count", "item_count", "feeds"}
59+
entry_keys = {"feed_id", "feed_url", "ok", "item_count", "error"}
60+
if not isinstance(payload, dict) or set(payload) != required or not isinstance(payload["feeds"], list):
61+
raise FetchStatusError("fetch_status_shape_invalid")
62+
counters = ("feed_count", "successful_feed_count", "failed_feed_count", "item_count")
63+
if type(payload["generated_at"]) is not str or any(type(payload[key]) is not int or payload[key] < 0 for key in counters):
64+
raise FetchStatusError("fetch_status_counter_invalid")
65+
feeds = payload["feeds"]
66+
if len(feeds) != payload["feed_count"]:
67+
raise FetchStatusError("fetch_status_counter_invalid")
68+
successful = 0
69+
total_items = 0
70+
for feed in feeds:
71+
if not isinstance(feed, dict) or set(feed) != entry_keys or type(feed["feed_id"]) is not str or type(feed["feed_url"]) is not str or type(feed["ok"]) is not bool or type(feed["item_count"]) is not int or feed["item_count"] < 0 or type(feed["error"]) is not str:
72+
raise FetchStatusError("fetch_status_entry_invalid")
73+
successful += int(feed["ok"])
74+
total_items += feed["item_count"]
75+
if successful != payload["successful_feed_count"] or payload["feed_count"] - successful != payload["failed_feed_count"] or total_items != payload["item_count"]:
76+
raise FetchStatusError("fetch_status_counter_invalid")
77+
return payload["failed_feed_count"] == 0
78+
79+
5180
def load_feed_config(path: str | Path) -> list[FeedConfig]:
5281
feeds: list[FeedConfig] = []
5382
for row in read_csv_rows(path):
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Pure guards for the existing RSS workflow boundary."""
2+
from __future__ import annotations
3+
4+
from pathlib import PurePosixPath
5+
6+
CANONICAL_FEEDS = "config/free_rss_feeds.csv"
7+
CANONICAL_ALIASES = "config/core_us_equity_aliases.csv"
8+
CANONICAL_WATCHLIST = "data/live/political_watchlist.csv"
9+
10+
11+
class PathBoundaryError(ValueError):
12+
def __init__(self, code: str) -> None:
13+
self.code = code
14+
super().__init__(code)
15+
16+
17+
def validate_source_paths(feeds_path: object, aliases_path: object, watchlist_path: object) -> None:
18+
expected = (CANONICAL_FEEDS, CANONICAL_ALIASES, CANONICAL_WATCHLIST)
19+
actual = (feeds_path, aliases_path, watchlist_path)
20+
if any(type(value) is not str or value != expected_value for value, expected_value in zip(actual, expected, strict=True)):
21+
raise PathBoundaryError("workflow_input_path_invalid")
22+
for value in actual:
23+
path = PurePosixPath(value)
24+
if path.is_absolute() or value != str(path) or any(part in {"", ".", ".."} for part in path.parts) or "\\" in value:
25+
raise PathBoundaryError("workflow_input_path_invalid")

tests/test_weekly_workflow_w0.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from political_event_tracking_research.rss_source_fetch import FetchStatusError, validate_fetch_status
8+
from political_event_tracking_research.workflow_boundary import PathBoundaryError, validate_source_paths
9+
10+
11+
def status(**overrides: object) -> dict[str, object]:
12+
payload: dict[str, object] = {
13+
"generated_at": "2026-07-16T00:00:00Z",
14+
"feed_count": 2,
15+
"successful_feed_count": 2,
16+
"failed_feed_count": 0,
17+
"item_count": 1,
18+
"feeds": [
19+
{"feed_id": "a", "feed_url": "https://a.example/feed", "ok": True, "item_count": 1, "error": ""},
20+
{"feed_id": "b", "feed_url": "https://b.example/feed", "ok": True, "item_count": 0, "error": ""},
21+
],
22+
}
23+
payload.update(overrides)
24+
return payload
25+
26+
27+
def test_fetch_status_validation_returns_complete_state() -> None:
28+
assert validate_fetch_status(status()) is True
29+
assert validate_fetch_status(status(failed_feed_count=1, successful_feed_count=1, feeds=[
30+
{"feed_id": "a", "feed_url": "https://a.example/feed", "ok": True, "item_count": 1, "error": ""},
31+
{"feed_id": "b", "feed_url": "https://b.example/feed", "ok": False, "item_count": 0, "error": "blocked"},
32+
])) is False
33+
34+
35+
@pytest.mark.parametrize("change", [{"complete": False}, {"failed_feed_count": 1}, {"unknown": 1}, {"feeds": []}])
36+
def test_fetch_status_shape_or_incompleteness_fails_closed(change: dict[str, object]) -> None:
37+
with pytest.raises(FetchStatusError):
38+
validate_fetch_status({**status(), **change})
39+
40+
41+
@pytest.mark.parametrize("value", ["config/other.csv", "../config/free_rss_feeds.csv", "/tmp/feeds.csv", "config\\feeds.csv", ""])
42+
def test_manual_source_paths_are_canonical_only(value: str) -> None:
43+
with pytest.raises(PathBoundaryError):
44+
validate_source_paths(value, "config/core_us_equity_aliases.csv", "data/live/political_watchlist.csv")
45+
46+
47+
def test_workflow_orders_debug_upload_gate_and_live_push() -> None:
48+
workflow = Path(__file__).parents[1].joinpath(".github/workflows/rss_source_pipeline.yml").read_text(encoding="utf-8")
49+
assert workflow.index("Validate canonical workflow inputs") < workflow.index("actions/checkout@")
50+
assert workflow.index("Upload RSS source artifact") < workflow.index("Validate feed completeness")
51+
assert workflow.index("Validate feed completeness") < workflow.index("Publish live CSV outputs")
52+
assert "git push origin HEAD:refs/heads/main" in workflow
53+
assert "git rev-parse HEAD" in workflow
54+
assert "weekly" not in workflow.lower()

0 commit comments

Comments
 (0)