-
Notifications
You must be signed in to change notification settings - Fork 0
Add deterministic weekly workflow boundary #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Weekly Workflow Boundary Contract | ||
|
|
||
| The RSS workflow validates dispatch identity before fetching, writing `data/live`, | ||
| committing, or uploading artifacts. | ||
|
|
||
| - Manual runs require an exact completed ISO week: Monday `period_start` and | ||
| Sunday `as_of`. | ||
| - Scheduled runs fetch the current run from GitHub's workflow-run REST endpoint | ||
| using `actions:read`. The response must match the fixed repository, workflow | ||
| path, `refs/heads/main`, `event=schedule`, `run_id`, `run_attempt=1`, and a | ||
| full producer SHA. The immutable API `created_at` date determines the previous | ||
| complete UTC ISO week; runner `date -u` is not period authority. | ||
| - The existing `rss-source-pipeline` upload occurs before dedicated weekly build. | ||
| A weekly contract failure leaves that legacy/source artifact available while | ||
| still failing the workflow and preventing dedicated upload. | ||
| - Dedicated `political-event-weekly-v1` remains the exact five-file, 30-day, | ||
| complete-feed, digest-bound artifact defined by `weekly_artifact.py`. | ||
|
|
||
| No new secrets, id-token, external store, identity registry, consumer, Pages, | ||
| publisher, or trading capability is introduced. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| #!/usr/bin/env python3 | ||
| """Build the dedicated weekly artifact after dispatch validation.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import sys | ||
| from datetime import date, datetime | ||
| from pathlib import Path | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
|
|
||
| from political_event_tracking_research.weekly_artifact import WeeklyArtifactError, build_weekly_artifact # noqa: E402 | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--period-start", required=True) | ||
| parser.add_argument("--as-of", required=True) | ||
| parser.add_argument("--generated-at", required=True) | ||
| parser.add_argument("--workflow-ref", required=True) | ||
| parser.add_argument("--source-run-id", required=True) | ||
| parser.add_argument("--producer-ref", required=True) | ||
| parser.add_argument("--source-events", required=True, type=Path) | ||
| parser.add_argument("--watchlist", required=True, type=Path) | ||
| parser.add_argument("--feed-status", required=True, type=Path) | ||
| parser.add_argument("--output-dir", required=True, type=Path) | ||
| parser.add_argument("--run-mode", choices=("scheduled", "manual"), required=True) | ||
| args = parser.parse_args() | ||
| try: | ||
| period_start = date.fromisoformat(args.period_start) | ||
| as_of = date.fromisoformat(args.as_of) | ||
| generated_at = datetime.fromisoformat(args.generated_at.replace("Z", "+00:00")) | ||
| feed_status = json.loads(args.feed_status.read_text(encoding="utf-8")) | ||
| files = build_weekly_artifact( | ||
| period_start=period_start, | ||
| as_of=as_of, | ||
| generated_at=generated_at, | ||
| workflow_ref=args.workflow_ref, | ||
| source_run_id=args.source_run_id, | ||
| producer_ref=args.producer_ref, | ||
| source_events=args.source_events.read_bytes(), | ||
| watchlist=args.watchlist.read_bytes(), | ||
| feed_status=feed_status, | ||
| source_provenance="official_rss_source_pipeline_v1", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| run_mode=args.run_mode, | ||
| ) | ||
| args.output_dir.mkdir(parents=True, exist_ok=True) | ||
| if any(args.output_dir.iterdir()): | ||
| raise WeeklyArtifactError("artifact_output_not_empty") | ||
| for name, content in files.items(): | ||
| (args.output_dir / name).write_bytes(content) | ||
| except WeeklyArtifactError as exc: | ||
| raise SystemExit(exc.code) from None | ||
| except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError): | ||
| raise SystemExit("weekly_artifact_input_invalid") from None | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| #!/usr/bin/env python3 | ||
| """Validate dispatch inputs before PERT fetch/live side effects.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import re | ||
| import sys | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | ||
|
|
||
| from political_event_tracking_research.workflow_boundary import ( # noqa: E402 | ||
| WORKFLOW_REF, | ||
| WorkflowBoundaryError, | ||
| validate_manual_run, | ||
| validate_manual_period, | ||
| validate_scheduled_run, | ||
| ) | ||
|
|
||
| _SHA_RE = re.compile(r"^[0-9a-f]{40}$") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--event", choices=("schedule", "workflow_dispatch"), required=True) | ||
| parser.add_argument("--run-id", required=True) | ||
| parser.add_argument("--run-attempt", required=True, type=int) | ||
| parser.add_argument("--workflow-ref", required=True) | ||
| parser.add_argument("--producer-ref", required=True) | ||
| parser.add_argument("--period-start") | ||
| parser.add_argument("--as-of") | ||
| parser.add_argument("--run-payload", type=Path) | ||
| parser.add_argument("--output", required=True, type=Path) | ||
| args = parser.parse_args() | ||
| try: | ||
| if args.run_attempt != 1: | ||
| raise WorkflowBoundaryError("run_attempt_invalid") | ||
|
Comment on lines
+39
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a GitHub re-run, Useful? React with 👍 / 👎. |
||
| if args.event in {"schedule", "workflow_dispatch"}: | ||
| if args.run_payload is None: | ||
| raise WorkflowBoundaryError("workflow_run_payload_missing") | ||
| payload = json.loads(args.run_payload.read_text(encoding="utf-8")) | ||
| if args.event == "schedule": | ||
| evidence = validate_scheduled_run(payload, run_id=args.run_id, workflow_ref=args.workflow_ref) | ||
| result = { | ||
| "period_start": evidence.period_start.isoformat(), | ||
| "period_end_exclusive": evidence.period_end_exclusive.isoformat(), | ||
| "as_of": evidence.as_of.isoformat(), | ||
| "scheduled_created_at": evidence.created_at.isoformat(timespec="seconds").replace("+00:00", "Z"), | ||
| "producer_ref": evidence.producer_ref, | ||
| } | ||
| else: | ||
| evidence = validate_manual_run(payload, run_id=args.run_id, workflow_ref=args.workflow_ref) | ||
| start, as_of = validate_manual_period(args.period_start, args.as_of, run_created_at=evidence.created_at) | ||
| result = {"period_start": start.isoformat(), "period_end_exclusive": (start + timedelta(days=7)).isoformat(), "as_of": as_of.isoformat(), "producer_ref": evidence.producer_ref} | ||
| else: | ||
| start, as_of = validate_manual_period(args.period_start, args.as_of) | ||
| if args.workflow_ref != WORKFLOW_REF or type(args.producer_ref) is not str or not _SHA_RE.fullmatch(args.producer_ref): | ||
| raise WorkflowBoundaryError("workflow_identity_invalid") | ||
| result = {"period_start": start.isoformat(), "period_end_exclusive": (start + timedelta(days=7)).isoformat(), "as_of": as_of.isoformat(), "producer_ref": args.producer_ref} | ||
| args.output.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") | ||
| except WorkflowBoundaryError as exc: | ||
| raise SystemExit(exc.code) from None | ||
| except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): | ||
| raise SystemExit("workflow_boundary_invalid") from None | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
COMMIT_OUTPUTSis true (scheduled runs and manual publish runs), the later publish step commits and invokes plaingit push. This checkout is no longer pushable:ref: ${{ github.sha }}checks out a commit instead of themainbranch, andpersist-credentials: falseopts out of the checkout token that actions/checkout says enables authenticated git commands (README); as soon as generated CSVs change, the push fails before either artifact upload runs. Check out a branch/push an explicit ref with token auth before keeping this hardening.Useful? React with 👍 / 👎.