Skip to content

Commit 7c551e6

Browse files
Pigbibicodex
andcommitted
Enforce immediate prior week provenance
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf0dcaf commit 7c551e6

6 files changed

Lines changed: 696 additions & 37 deletions

File tree

.github/workflows/rss_source_pipeline.yml

Lines changed: 104 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,108 @@ on:
2323
required: false
2424
default: "50"
2525
type: string
26+
period_start:
27+
description: "Must equal the immediately prior complete ISO-week Monday derived from this run."
28+
required: true
29+
type: string
30+
as_of:
31+
description: "Must equal the immediately prior complete ISO-week Sunday derived from this run."
32+
required: true
33+
type: string
2634
commit_outputs:
2735
description: "Commit generated live CSV outputs back to data/live."
2836
required: false
2937
default: "false"
3038
type: choice
31-
options:
32-
- "false"
33-
- "true"
39+
options: ["false", "true"]
3440
schedule:
35-
- cron: "15 12 * * 6"
41+
- cron: "15 12 * * 1"
3642

3743
permissions:
44+
actions: read
3845
contents: write
3946

4047
concurrency:
4148
group: ${{ github.workflow }}-${{ github.ref_name }}
4249
cancel-in-progress: false
4350

4451
jobs:
52+
validate-run-boundary:
53+
if: >-
54+
github.repository == 'QuantStrategyLab/PoliticalEventTrackingResearch' &&
55+
github.ref == 'refs/heads/main' &&
56+
github.workflow_ref == 'QuantStrategyLab/PoliticalEventTrackingResearch/.github/workflows/rss_source_pipeline.yml@refs/heads/main'
57+
runs-on: ubuntu-latest
58+
outputs:
59+
period_start: ${{ steps.guard.outputs.period_start }}
60+
period_end_exclusive: ${{ steps.guard.outputs.period_end_exclusive }}
61+
as_of: ${{ steps.guard.outputs.as_of }}
62+
producer_ref: ${{ steps.guard.outputs.producer_ref }}
63+
steps:
64+
- name: Validate run identity and period before checkout
65+
id: guard
66+
env:
67+
GH_TOKEN: ${{ github.token }}
68+
EVENT_NAME: ${{ github.event_name }}
69+
RUN_ID: ${{ github.run_id }}
70+
RUN_ATTEMPT: ${{ github.run_attempt }}
71+
WORKFLOW_REF: ${{ github.workflow_ref }}
72+
PERIOD_START: ${{ github.event.inputs.period_start || '' }}
73+
AS_OF: ${{ github.event.inputs.as_of || '' }}
74+
API_URL: ${{ github.api_url }}
75+
run: |
76+
set -euo pipefail
77+
payload="${RUNNER_TEMP}/pert-workflow-run.json"
78+
curl --fail --silent --show-error \
79+
-H "Accept: application/vnd.github+json" \
80+
-H "Authorization: Bearer ${GH_TOKEN}" \
81+
"${API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" > "${payload}"
82+
python3 - "${payload}" <<'PY'
83+
import json
84+
import os
85+
import sys
86+
from datetime import datetime, timedelta, timezone
87+
88+
expected_repo = "QuantStrategyLab/PoliticalEventTrackingResearch"
89+
expected_path = ".github/workflows/rss_source_pipeline.yml"
90+
expected_ref = f"{expected_repo}/{expected_path}@refs/heads/main"
91+
payload = json.load(open(sys.argv[1], encoding="utf-8"))
92+
if os.environ["WORKFLOW_REF"] != expected_ref or os.environ["GITHUB_REF"] != "refs/heads/main":
93+
raise SystemExit("workflow_identity_invalid")
94+
if payload.get("id") != int(os.environ["RUN_ID"]) or type(payload.get("run_attempt")) is not int or payload["run_attempt"] != 1:
95+
raise SystemExit("workflow_identity_invalid")
96+
if payload.get("event") != os.environ["EVENT_NAME"] or payload.get("path") != expected_path or payload.get("head_branch") != "main":
97+
raise SystemExit("workflow_identity_invalid")
98+
if payload.get("head_repository", {}).get("full_name") != expected_repo:
99+
raise SystemExit("workflow_identity_invalid")
100+
producer_ref = payload.get("head_sha")
101+
if type(producer_ref) is not str or producer_ref != os.environ["GITHUB_SHA"] or len(producer_ref) != 40 or any(char not in "0123456789abcdef" for char in producer_ref):
102+
raise SystemExit("workflow_identity_invalid")
103+
created = datetime.fromisoformat(payload["created_at"].replace("Z", "+00:00"))
104+
if created.tzinfo != timezone.utc:
105+
raise SystemExit("workflow_created_at_invalid")
106+
current_monday = created.date() - timedelta(days=created.date().weekday())
107+
start = current_monday - timedelta(days=7)
108+
end = current_monday
109+
as_of = end - timedelta(days=1)
110+
if os.environ["EVENT_NAME"] == "workflow_dispatch" and (os.environ["PERIOD_START"], os.environ["AS_OF"]) != (start.isoformat(), as_of.isoformat()):
111+
raise SystemExit("manual_period_not_immediate_prior")
112+
for key, value in (("period_start", start.isoformat()), ("period_end_exclusive", end.isoformat()), ("as_of", as_of.isoformat()), ("producer_ref", producer_ref)):
113+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
114+
output.write(f"{key}={value}\n")
115+
PY
116+
45117
build-rss-source-events:
118+
needs: validate-run-boundary
119+
if: needs.validate-run-boundary.result == 'success'
46120
runs-on: ubuntu-latest
47121
timeout-minutes: 30
48122
steps:
49123
- uses: actions/checkout@v6
124+
with:
125+
repository: QuantStrategyLab/PoliticalEventTrackingResearch
126+
ref: ${{ github.sha }}
127+
persist-credentials: false
50128
- uses: actions/setup-python@v6
51129
with:
52130
python-version: "3.11"
@@ -61,55 +139,44 @@ jobs:
61139
run: |
62140
set -euo pipefail
63141
mkdir -p data/output/rss_source_pipeline
64-
python scripts/fetch_rss_sources.py \
65-
--feeds "${FEEDS_PATH}" \
66-
--output data/output/rss_source_pipeline/source_items.csv \
67-
--max-items-per-feed "${MAX_ITEMS_PER_FEED}" \
68-
--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
142+
python scripts/fetch_rss_sources.py --feeds "${FEEDS_PATH}" --output data/output/rss_source_pipeline/source_items.csv --max-items-per-feed "${MAX_ITEMS_PER_FEED}" --continue-on-feed-error --status-output data/output/rss_source_pipeline/source_fetch_status.json
143+
python scripts/extract_source_mentions.py --raw-items data/output/rss_source_pipeline/source_items.csv --aliases "${ALIASES_PATH}" --output data/output/rss_source_pipeline/source_events.csv
144+
python scripts/build_tracker.py --watchlist "${WATCHLIST_PATH}" --events data/output/rss_source_pipeline/source_events.csv --output data/output/rss_source_pipeline/source_tracker.csv
78145
- name: Publish live CSV outputs to repository
79146
env:
80147
COMMIT_OUTPUTS: ${{ github.event_name == 'schedule' && 'true' || github.event.inputs.commit_outputs || 'false' }}
81148
run: |
82149
set -euo pipefail
83-
if [ "${COMMIT_OUTPUTS}" != "true" ]; then
84-
echo "Live output commit disabled."
85-
exit 0
86-
fi
150+
if [ "${COMMIT_OUTPUTS}" != "true" ]; then echo "Live output commit disabled."; exit 0; fi
87151
mkdir -p data/live
88152
cp data/output/rss_source_pipeline/source_items.csv data/live/source_items.csv
89153
cp data/output/rss_source_pipeline/source_events.csv data/live/source_events.csv
90154
cp data/output/rss_source_pipeline/source_events.csv data/live/political_events.csv
91155
cp data/output/rss_source_pipeline/source_tracker.csv data/live/source_tracker.csv
92156
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
157+
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
101158
git config user.name "github-actions[bot]"
102159
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
103-
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
104-
if git diff --cached --quiet; then
105-
echo "No live RSS output changes to commit."
106-
else
107-
git commit -m "Update live RSS source events [skip ci]"
108-
git push
109-
fi
160+
git add data/live
161+
if git diff --cached --quiet; then echo "No live RSS output changes to commit."; else git commit -m "Update live RSS source events [skip ci]"; git push; fi
110162
- name: Upload RSS source artifact
111163
uses: actions/upload-artifact@v7
112164
with:
113165
name: rss-source-pipeline
114166
path: data/output/rss_source_pipeline/
115167
if-no-files-found: error
168+
- name: Build completed weekly producer artifact
169+
env:
170+
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
171+
RUN_MODE: ${{ github.event_name == 'schedule' && 'scheduled' || 'manual' }}
172+
run: |
173+
set -euo pipefail
174+
mkdir -p data/output/political-event-weekly-v1
175+
python scripts/build_weekly_artifact.py --period-start "${{ needs.validate-run-boundary.outputs.period_start }}" --as-of "${{ needs.validate-run-boundary.outputs.as_of }}" --generated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000000Z)" --workflow-ref "${GITHUB_WORKFLOW_REF}" --source-run-id "${GITHUB_RUN_ID}" --producer-ref "${{ needs.validate-run-boundary.outputs.producer_ref }}" --source-events data/output/rss_source_pipeline/source_events.csv --watchlist "${WATCHLIST_PATH}" --feed-status data/output/rss_source_pipeline/source_fetch_status.json --output-dir data/output/political-event-weekly-v1 --run-mode "${RUN_MODE}"
176+
- name: Upload political weekly artifact
177+
uses: actions/upload-artifact@v7
178+
with:
179+
name: political-event-weekly-v1
180+
path: data/output/political-event-weekly-v1/
181+
if-no-files-found: error
182+
retention-days: 30

scripts/build_weekly_artifact.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python3
2+
"""Build the dedicated weekly artifact after the boundary guard."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import sys
8+
from datetime import date, datetime
9+
from pathlib import Path
10+
11+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12+
13+
from political_event_tracking_research.weekly_artifact import WeeklyArtifactError, build_weekly_artifact # noqa: E402
14+
15+
16+
def main() -> None:
17+
parser = argparse.ArgumentParser()
18+
parser.add_argument("--period-start", required=True)
19+
parser.add_argument("--as-of", required=True)
20+
parser.add_argument("--generated-at", required=True)
21+
parser.add_argument("--workflow-ref", required=True)
22+
parser.add_argument("--source-run-id", required=True)
23+
parser.add_argument("--producer-ref", required=True)
24+
parser.add_argument("--source-events", required=True, type=Path)
25+
parser.add_argument("--watchlist", required=True, type=Path)
26+
parser.add_argument("--feed-status", required=True, type=Path)
27+
parser.add_argument("--output-dir", required=True, type=Path)
28+
parser.add_argument("--run-mode", choices=("scheduled", "manual"), required=True)
29+
args = parser.parse_args()
30+
try:
31+
files = build_weekly_artifact(
32+
period_start=date.fromisoformat(args.period_start),
33+
as_of=date.fromisoformat(args.as_of),
34+
generated_at=datetime.fromisoformat(args.generated_at.replace("Z", "+00:00")),
35+
workflow_ref=args.workflow_ref,
36+
source_run_id=args.source_run_id,
37+
producer_ref=args.producer_ref,
38+
source_events=args.source_events.read_bytes(),
39+
watchlist=args.watchlist.read_bytes(),
40+
feed_status=json.loads(args.feed_status.read_text(encoding="utf-8")),
41+
run_mode=args.run_mode,
42+
)
43+
args.output_dir.mkdir(parents=True, exist_ok=True)
44+
if any(args.output_dir.iterdir()):
45+
raise WeeklyArtifactError("artifact_output_not_empty")
46+
for name, content in files.items():
47+
(args.output_dir / name).write_bytes(content)
48+
except WeeklyArtifactError as error:
49+
raise SystemExit(error.code) from None
50+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError):
51+
raise SystemExit("weekly_artifact_input_invalid") from None
52+
53+
54+
if __name__ == "__main__":
55+
main()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
"""Validate trusted run identity and the only permitted weekly period."""
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.workflow_boundary import ( # noqa: E402
13+
WorkflowBoundaryError,
14+
validate_manual_period,
15+
validate_manual_run,
16+
validate_scheduled_run,
17+
)
18+
19+
20+
def main() -> None:
21+
parser = argparse.ArgumentParser()
22+
parser.add_argument("--event", choices=("schedule", "workflow_dispatch"), required=True)
23+
parser.add_argument("--run-id", required=True)
24+
parser.add_argument("--run-attempt", required=True, type=int)
25+
parser.add_argument("--workflow-ref", required=True)
26+
parser.add_argument("--period-start", required=False)
27+
parser.add_argument("--as-of", required=False)
28+
parser.add_argument("--run-payload", required=True, type=Path)
29+
parser.add_argument("--output", required=True, type=Path)
30+
args = parser.parse_args()
31+
try:
32+
payload = json.loads(args.run_payload.read_text(encoding="utf-8"))
33+
if args.event == "schedule":
34+
evidence = validate_scheduled_run(payload, run_id=args.run_id, workflow_ref=args.workflow_ref)
35+
start, end, as_of = evidence.period_start, evidence.period_end_exclusive, evidence.as_of
36+
else:
37+
evidence = validate_manual_run(payload, run_id=args.run_id, workflow_ref=args.workflow_ref)
38+
start, as_of = validate_manual_period(args.period_start, args.as_of, run_created_at=evidence.created_at)
39+
end = start.fromordinal(start.toordinal() + 7)
40+
args.output.write_text(json.dumps({"period_start": start.isoformat(), "period_end_exclusive": end.isoformat(), "as_of": as_of.isoformat(), "producer_ref": evidence.producer_ref}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
41+
except WorkflowBoundaryError as error:
42+
raise SystemExit(error.code) from None
43+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
44+
raise SystemExit("workflow_boundary_invalid") from None
45+
46+
47+
if __name__ == "__main__":
48+
main()

0 commit comments

Comments
 (0)