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.

Scheduled-workflow behaviours worth designing around Schedules run in UTC only, so a local time expectation drifts with daylight saving; convert in the job or accept UTC. Runs are queued and often delayed, so the job must be idempotent rather than assume a precise time. Runs can be skipped under load, so the job should process a window of work rather than only the last period. Storage is ephemeral, so outputs must be uploaded as artifacts or pushed elsewhere. Workflows are disabled after sixty days without activity, so a keepalive or an external heartbeat is needed. Behaviour Consequence What to do UTC only drifts with DST convert in the job Queued, often late not a precise timer make the job idempotent Can be skipped a night with no run process a window Ephemeral storage output disappears upload artifacts Disabled after 60 days silently stops keepalive or heartbeat Concurrent runs allowed two jobs at once concurrency group

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.

What a reliable scheduled run does The schedule fires, possibly late. The job determines its work window from the last recorded success rather than from the current time, so a skipped run is covered. The concurrency group ensures only one run proceeds. Documents are processed idempotently, so overlap with a previous window is harmless. On success the new watermark is recorded. Outputs and logs are uploaded as artifacts whether the job passed or failed. A failure sends a notification rather than relying on someone reading the run list. Schedule fires possibly late Compute the window from last success Concurrency guard one run at a time Process idempotently re-runs are harmless Record the watermark only on success Upload and notify artifacts always, alert on fail

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.

Is a scheduled workflow the right host? If the job runs in under an hour, tolerates several minutes of delay and touches no sensitive data that must stay inside a network, a scheduled workflow is a good fit with no server to maintain. If exact timing matters, a dedicated scheduler on a machine you control is better because the queue delay is not controllable. If the job runs for hours or needs large local storage, a container on your own infrastructure fits better. If the work arrives as events rather than on a clock, a watched folder or a queue beats any schedule. What does the job need? timing, runtime, data locality short, delay-tolerant GitHub Actions no server to run exact timing Own scheduler queue delay is not yours hours, large storage Own container runner limits bite event-driven Watch a folder or queue a clock is the wrong trigger

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.

Part of Scheduling and Logging Automation Jobs.