Skip to content

Commit 9137cd7

Browse files
Pigbibicodex
andcommitted
Add deterministic weekly workflow boundary
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf0dcaf commit 9137cd7

9 files changed

Lines changed: 716 additions & 1 deletion

File tree

.github/workflows/rss_source_pipeline.yml

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ on:
2323
required: false
2424
default: "50"
2525
type: string
26+
period_start:
27+
description: "Manual completed ISO-week Monday date."
28+
required: false
29+
default: ""
30+
type: string
31+
as_of:
32+
description: "Manual completed ISO-week Sunday date."
33+
required: false
34+
default: ""
35+
type: string
2636
commit_outputs:
2737
description: "Commit generated live CSV outputs back to data/live."
2838
required: false
@@ -32,9 +42,10 @@ on:
3242
- "false"
3343
- "true"
3444
schedule:
35-
- cron: "15 12 * * 6"
45+
- cron: "15 12 * * 1"
3646

3747
permissions:
48+
actions: read
3849
contents: write
3950

4051
concurrency:
@@ -52,6 +63,43 @@ jobs:
5263
python-version: "3.11"
5364
- name: Install package
5465
run: python -m pip install -e .
66+
- name: Validate workflow dispatch before side effects
67+
env:
68+
PERIOD_START: ${{ github.event.inputs.period_start || '' }}
69+
AS_OF: ${{ github.event.inputs.as_of || '' }}
70+
GITHUB_TOKEN: ${{ github.token }}
71+
run: |
72+
set -euo pipefail
73+
RUN_PAYLOAD="${RUNNER_TEMP}/pert-workflow-run.json"
74+
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
75+
curl --fail --silent --show-error \
76+
-H "Accept: application/vnd.github+json" \
77+
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
78+
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" > "${RUN_PAYLOAD}"
79+
else
80+
rm -f "${RUN_PAYLOAD}"
81+
fi
82+
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
83+
PAYLOAD_ARGS=(--run-payload "${RUN_PAYLOAD}")
84+
else
85+
PAYLOAD_ARGS=()
86+
fi
87+
python scripts/validate_weekly_workflow.py \
88+
--event "${GITHUB_EVENT_NAME}" \
89+
--run-id "${GITHUB_RUN_ID}" \
90+
--run-attempt "${GITHUB_RUN_ATTEMPT}" \
91+
--workflow-ref "${GITHUB_WORKFLOW_REF}" \
92+
--producer-ref "${GITHUB_SHA}" \
93+
--period-start "${PERIOD_START}" \
94+
--as-of "${AS_OF}" \
95+
"${PAYLOAD_ARGS[@]}" \
96+
--output "${RUNNER_TEMP}/pert-period-context.json"
97+
python - <<'PY' "${RUNNER_TEMP}/pert-period-context.json" >> "${GITHUB_ENV}"
98+
import json, sys
99+
value = json.load(open(sys.argv[1], encoding="utf-8"))
100+
for key in ("period_start", "period_end_exclusive", "as_of", "producer_ref"):
101+
print(f"PERT_{key.upper()}={value[key]}")
102+
PY
55103
- name: Fetch RSS sources and extract mentions
56104
env:
57105
FEEDS_PATH: ${{ github.event.inputs.feeds_path || 'config/free_rss_feeds.csv' }}
@@ -113,3 +161,33 @@ jobs:
113161
name: rss-source-pipeline
114162
path: data/output/rss_source_pipeline/
115163
if-no-files-found: error
164+
- name: Build completed weekly producer artifact
165+
env:
166+
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
167+
run: |
168+
set -euo pipefail
169+
mkdir -p data/output/political-event-weekly-v1
170+
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
171+
RUN_MODE=scheduled
172+
else
173+
RUN_MODE=manual
174+
fi
175+
python scripts/build_weekly_artifact.py \
176+
--period-start "${PERT_PERIOD_START}" \
177+
--as-of "${PERT_AS_OF}" \
178+
--generated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000000Z)" \
179+
--workflow-ref "${GITHUB_WORKFLOW_REF}" \
180+
--source-run-id "${GITHUB_RUN_ID}" \
181+
--producer-ref "${PERT_PRODUCER_REF}" \
182+
--source-events data/output/rss_source_pipeline/source_events.csv \
183+
--watchlist "${WATCHLIST_PATH}" \
184+
--feed-status data/output/rss_source_pipeline/source_fetch_status.json \
185+
--output-dir data/output/political-event-weekly-v1 \
186+
--run-mode "${RUN_MODE}"
187+
- name: Upload political weekly artifact
188+
uses: actions/upload-artifact@v7
189+
with:
190+
name: political-event-weekly-v1
191+
path: data/output/political-event-weekly-v1/
192+
if-no-files-found: error
193+
retention-days: 30
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Weekly Workflow Boundary Contract
2+
3+
The RSS workflow validates dispatch identity before fetching, writing `data/live`,
4+
committing, or uploading artifacts.
5+
6+
- Manual runs require an exact completed ISO week: Monday `period_start` and
7+
Sunday `as_of`.
8+
- Scheduled runs fetch the current run from GitHub's workflow-run REST endpoint
9+
using `actions:read`. The response must match the fixed repository, workflow
10+
path, `refs/heads/main`, `event=schedule`, `run_id`, `run_attempt=1`, and a
11+
full producer SHA. The immutable API `created_at` date determines the previous
12+
complete UTC ISO week; runner `date -u` is not period authority.
13+
- The existing `rss-source-pipeline` upload occurs before dedicated weekly build.
14+
A weekly contract failure leaves that legacy/source artifact available while
15+
still failing the workflow and preventing dedicated upload.
16+
- Dedicated `political-event-weekly-v1` remains the exact five-file, 30-day,
17+
complete-feed, digest-bound artifact defined by `weekly_artifact.py`.
18+
19+
No new secrets, id-token, external store, identity registry, consumer, Pages,
20+
publisher, or trading capability is introduced.

scripts/build_weekly_artifact.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
"""Build the dedicated weekly artifact after dispatch validation."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import sys
9+
from datetime import date, datetime
10+
from pathlib import Path
11+
12+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13+
14+
from political_event_tracking_research.weekly_artifact import WeeklyArtifactError, build_weekly_artifact # noqa: E402
15+
16+
17+
def main() -> None:
18+
parser = argparse.ArgumentParser(description=__doc__)
19+
parser.add_argument("--period-start", required=True)
20+
parser.add_argument("--as-of", required=True)
21+
parser.add_argument("--generated-at", required=True)
22+
parser.add_argument("--workflow-ref", required=True)
23+
parser.add_argument("--source-run-id", required=True)
24+
parser.add_argument("--producer-ref", required=True)
25+
parser.add_argument("--source-events", required=True, type=Path)
26+
parser.add_argument("--watchlist", required=True, type=Path)
27+
parser.add_argument("--feed-status", required=True, type=Path)
28+
parser.add_argument("--output-dir", required=True, type=Path)
29+
parser.add_argument("--run-mode", choices=("scheduled", "manual"), required=True)
30+
args = parser.parse_args()
31+
try:
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+
feed_status = json.loads(args.feed_status.read_text(encoding="utf-8"))
36+
files = build_weekly_artifact(
37+
period_start=period_start,
38+
as_of=as_of,
39+
generated_at=generated_at,
40+
workflow_ref=args.workflow_ref,
41+
source_run_id=args.source_run_id,
42+
producer_ref=args.producer_ref,
43+
source_events=args.source_events.read_bytes(),
44+
watchlist=args.watchlist.read_bytes(),
45+
feed_status=feed_status,
46+
source_provenance="official_rss_source_pipeline_v1",
47+
run_mode=args.run_mode,
48+
)
49+
args.output_dir.mkdir(parents=True, exist_ok=True)
50+
if any(args.output_dir.iterdir()):
51+
raise WeeklyArtifactError("artifact_output_not_empty")
52+
for name, content in files.items():
53+
(args.output_dir / name).write_bytes(content)
54+
except WeeklyArtifactError as exc:
55+
raise SystemExit(exc.code) from None
56+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError):
57+
raise SystemExit("weekly_artifact_input_invalid") from None
58+
59+
60+
if __name__ == "__main__":
61+
main()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""Validate dispatch inputs before PERT fetch/live side effects."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import re
9+
import sys
10+
from datetime import timedelta
11+
from pathlib import Path
12+
13+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14+
15+
from political_event_tracking_research.workflow_boundary import ( # noqa: E402
16+
WORKFLOW_REF,
17+
WorkflowBoundaryError,
18+
validate_manual_period,
19+
validate_scheduled_run,
20+
)
21+
22+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
23+
24+
25+
def main() -> None:
26+
parser = argparse.ArgumentParser(description=__doc__)
27+
parser.add_argument("--event", choices=("schedule", "workflow_dispatch"), required=True)
28+
parser.add_argument("--run-id", required=True)
29+
parser.add_argument("--run-attempt", required=True, type=int)
30+
parser.add_argument("--workflow-ref", required=True)
31+
parser.add_argument("--producer-ref", required=True)
32+
parser.add_argument("--period-start")
33+
parser.add_argument("--as-of")
34+
parser.add_argument("--run-payload", type=Path)
35+
parser.add_argument("--output", required=True, type=Path)
36+
args = parser.parse_args()
37+
try:
38+
if args.run_attempt != 1:
39+
raise WorkflowBoundaryError("run_attempt_invalid")
40+
if args.event == "schedule":
41+
if args.run_payload is None:
42+
raise WorkflowBoundaryError("scheduled_run_payload_missing")
43+
payload = json.loads(args.run_payload.read_text(encoding="utf-8"))
44+
evidence = validate_scheduled_run(payload, run_id=args.run_id, workflow_ref=args.workflow_ref)
45+
result = {
46+
"period_start": evidence.period_start.isoformat(),
47+
"period_end_exclusive": evidence.period_end_exclusive.isoformat(),
48+
"as_of": evidence.as_of.isoformat(),
49+
"scheduled_created_at": evidence.created_at.isoformat(timespec="seconds").replace("+00:00", "Z"),
50+
"producer_ref": evidence.producer_ref,
51+
}
52+
else:
53+
start, as_of = validate_manual_period(args.period_start, args.as_of)
54+
if args.workflow_ref != WORKFLOW_REF or type(args.producer_ref) is not str or not _SHA_RE.fullmatch(args.producer_ref):
55+
raise WorkflowBoundaryError("workflow_identity_invalid")
56+
result = {"period_start": start.isoformat(), "period_end_exclusive": (start + timedelta(days=7)).isoformat(), "as_of": as_of.isoformat(), "producer_ref": args.producer_ref}
57+
args.output.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
58+
except WorkflowBoundaryError as exc:
59+
raise SystemExit(exc.code) from None
60+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
61+
raise SystemExit("workflow_boundary_invalid") from None
62+
63+
64+
if __name__ == "__main__":
65+
main()

0 commit comments

Comments
 (0)