Run Python Automation with a GitHub Actions Schedule
A nightly document job needs somewhere to run, and a scheduled workflow avoids provisioning a machine. The first attempt usually runs late, or not at all:
on:
schedule:
- cron: "0 2 * * *"
jobs:
process:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: python process.py
It fires at 03:00 during British Summer Time rather than 02:00, sometimes twenty minutes late, occasionally not at all, the output disappears when the runner is destroyed, and after two months of no commits the workflow stops firing entirely with no notification.
Root Cause
A scheduled workflow is not a guaranteed timer. Four separate behaviours account for the surprises. Schedules are evaluated in UTC with no timezone option, so any local-time expectation drifts by an hour when daylight saving changes. Scheduled runs go into a shared queue and are delayed under load — commonly by five to twenty minutes, occasionally dropped entirely when the queue is very busy, which happens most at the top of the hour. Runner storage is ephemeral, so anything written to disk is gone unless it is uploaded as an artifact or pushed somewhere. And GitHub disables scheduled workflows in a repository with no commit activity for 60 days, sending one email that is easy to miss.
Minimal Diagnostic
Have the job report the facts that explain its own behaviour, so the log answers the questions before they are asked.
# stdlib only
import os
import platform
import sys
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
LOCAL = ZoneInfo("Europe/London")
def run_context() -> dict:
now = datetime.now(timezone.utc)
scheduled = os.environ.get("SCHEDULED_FOR") # set from the cron expression in the workflow
context = {
"run_id": os.environ.get("GITHUB_RUN_ID", "local"),
"attempt": os.environ.get("GITHUB_RUN_ATTEMPT", "1"),
"event": os.environ.get("GITHUB_EVENT_NAME", "manual"),
"started_utc": now.isoformat(timespec="seconds"),
"started_local": now.astimezone(LOCAL).isoformat(timespec="seconds"),
"python": sys.version.split()[0],
"runner": platform.platform(),
}
if scheduled:
delay = (now - datetime.fromisoformat(scheduled)).total_seconds()
context["queue_delay_seconds"] = round(delay, 1)
return context
if __name__ == "__main__":
for key, value in run_context().items():
print(f" {key}: {value}")
run_id: 11847203915
attempt: 1
event: schedule
started_utc: 2026-09-17T02:14:38+00:00
started_local: 2026-09-17T03:14:38+01:00
python: 3.12.6
runner: Linux-6.8.0-1015-azure-x86_64
queue_delay_seconds: 878.2
Fourteen minutes of queue delay and an hour of daylight-saving offset, both visible in the first ten lines of the log rather than inferred from a complaint.
Fix: A Workflow That Handles Delay, Overlap and Output
Pin the environment, guard against concurrent runs, process a window rather than an instant, and keep the outputs.
# .github/workflows/nightly-documents.yml
name: nightly-documents
on:
schedule:
- cron: "0 2 * * *" # 02:00 UTC — 03:00 London in summer
workflow_dispatch: # manual trigger for reruns and testing
inputs:
since:
description: "ISO timestamp to process from (default: last successful run)"
required: false
concurrency:
group: nightly-documents # a late run never overlaps the next one
cancel-in-progress: false
permissions:
contents: read
jobs:
process:
runs-on: ubuntu-latest
timeout-minutes: 45 # fail rather than burn an hour of minutes
env:
MPLBACKEND: Agg
SCHEDULED_FOR: ${{ github.event.schedule && github.event.repository.updated_at || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- name: Process documents
env:
SOURCE_URL: ${{ secrets.SOURCE_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
run: python -m pipeline.run --since "${{ inputs.since }}" --out out/
- name: Upload results
if: always() # keep the logs even when the job failed
uses: actions/upload-artifact@v4
with:
name: run-${{ github.run_id }}
path: |
out/
logs/
retention-days: 30
- name: Notify on failure
if: failure()
run: python -m pipeline.notify --status failed --run "${{ github.run_id }}"
Three settings carry most of the reliability. The concurrency group means a run delayed past the next scheduled time queues instead of running alongside it — two copies of a document pipeline writing to the same destination is the failure that corrupts data rather than just losing time. if: always() on the artifact upload keeps the diagnostic output from failed runs, which is when it is actually needed. And timeout-minutes turns a hung job into a failure at a predictable cost rather than a six-hour run against the account's minutes.
The Python side must cooperate by processing a window:
# stdlib only
import argparse
from datetime import datetime, timedelta, timezone
from pathlib import Path
STATE = Path("state/last_success.txt")
def window(since_argument: str | None, default_hours: int = 26) -> tuple[datetime, datetime]:
now = datetime.now(timezone.utc)
if since_argument:
return datetime.fromisoformat(since_argument), now
if STATE.exists():
return datetime.fromisoformat(STATE.read_text().strip()), now # changed: resume from last success
return now - timedelta(hours=default_hours), now # changed: overlap, never a gap
A 26-hour default window on a daily job means a skipped night is picked up by the next run automatically. The job must then be idempotent — processing a document twice must be harmless — which is the property that makes every other reliability measure optional rather than critical.
Variant Fix 1: Keeping the Workflow Enabled
The 60-day disable rule catches automation repositories precisely because they receive no commits. Two approaches work:
- name: Keepalive
if: github.event_name == 'schedule'
run: |
git config user.name "automation"
git config user.email "[email protected]"
date -u +"%Y-%m-%dT%H:%M:%SZ" > .github/last-run
git add .github/last-run
git diff --staged --quiet || git commit -m "chore: record run timestamp"
git push
This needs permissions: contents: write on the job. The alternative, and the better one for a pipeline that matters, is an external heartbeat: the job pings a monitoring service on success, and that service alerts when a ping does not arrive. That covers the disable rule and every other reason a run might not happen — a broken workflow file, a suspended account, an expired secret — none of which a keepalive commit detects.
Variant Fix 2: Secrets and What Not to Put in Them
Repository secrets are the right place for credentials, with two caveats:
# stdlib only
import os
import sys
REQUIRED_SECRETS = ["SOURCE_URL", "API_TOKEN"]
def check_secrets() -> None:
missing = [name for name in REQUIRED_SECRETS if not os.environ.get(name)]
if missing:
print(f"::error::missing secret(s): {', '.join(missing)}") # annotates the run in the UI
sys.exit(78)
for name in REQUIRED_SECRETS:
value = os.environ[name]
if len(value) < 8:
print(f"::warning::{name} looks too short ({len(value)} chars) — wrong secret?")
A secret is not available to a workflow triggered by a pull request from a fork, so a job that also runs on pull requests needs to handle their absence rather than fail confusingly. And GitHub redacts secret values in logs only when they appear verbatim — a token printed base64-encoded, or split across lines, is not redacted. Never print a credential, even partially.
Cost and Limits Worth Knowing
Public repositories run for free; private ones consume the account's included minutes, and a nightly 20-minute job is about 600 minutes a month — most of a free plan's allowance before anything else runs. Larger runners multiply that cost per minute.
Other limits shape the design more than the price does. A job is killed at six hours and a whole workflow at 35 days of queuing. Artifacts have a retention limit, defaulting to 90 days and often reduced at the organisation level, so anything that must be kept belongs in object storage rather than an artifact. And the runner's disk is roughly 14GB free — ample for a few thousand documents, not for a corpus.
None of these make scheduled workflows a poor choice for document automation. They do mean the job should be written to move its results somewhere durable and treat the runner as scratch space, which is good practice wherever it runs.
Verification
Assert the run did what it claimed before recording success.
# stdlib only
import json
from datetime import datetime, timezone
from pathlib import Path
def verify_run(outdir: Path, state: Path, expected_min: int = 1) -> None:
manifest = outdir / "manifest.json"
assert manifest.exists(), "no manifest written — the job did not complete its final step"
data = json.loads(manifest.read_text())
processed, failed = data["processed"], data["failed"]
assert processed >= expected_min, f"only {processed} document(s) processed"
assert processed + failed == data["total"], \
f"counts do not reconcile: {processed} + {failed} != {data['total']}"
failure_rate = failed / max(data["total"], 1)
assert failure_rate < 0.1, f"{failure_rate:.1%} failure rate — not recording success"
outputs = list(outdir.glob("*.parquet"))
assert outputs, "no output files produced"
state.parent.mkdir(parents=True, exist_ok=True)
state.write_text(datetime.now(timezone.utc).isoformat(timespec="seconds"))
print(f"::notice::{processed} processed, {failed} failed, {len(outputs)} output file(s)")
if __name__ == "__main__":
verify_run(Path("out"), Path("state/last_success.txt"))
Writing the watermark only after the assertions pass is what makes the window logic safe: a run that produced nothing useful leaves the watermark where it was, so the next run reprocesses that period rather than skipping it. The ::notice:: prefix puts the summary on the run's own page in the UI, where it is visible without opening the log.
FAQ
Can I schedule in a local timezone? No — the cron expression is UTC. Either accept the daylight-saving shift or run twice and have the job decide which run to act on.
Why did my workflow not run at all last night? Either queue pressure dropped it, or the repository has been inactive for 60 days. The run list shows the first; the second sends one email.
How do I trigger a rerun for a specific date?workflow_dispatch with a since input, as in the workflow above. It is also how the job gets tested without waiting for the schedule.
Should the state file live in the repository? For a small watermark, yes, committed by the job. For anything larger use object storage — a repository is not a database.
Related
- Scheduling and Logging Automation Jobs — the scheduling workflow end to end
- Add Retries and Failure Alerts to Automation Jobs — what the job does when a step fails
- Run Python Scripts with Windows Task Scheduler — the same job on a Windows machine
- Watching Folders for Incoming Documents — event-driven instead of scheduled