Guide
Automating Reporting WorkflowsDeep dive

Run a Python Excel Report in GitHub Actions

Schedule a reporting job without a server — a cron workflow, pinned dependencies, secrets, LibreOffice for PDF, artifacts, and the timezone and skew details that bite.

A monthly report does not justify a server. GitHub Actions gives you a scheduled runner with Python already installed, secrets management, and somewhere to put the output — for a job that runs a few times a month, at no cost on most plans. The parts that need care are the schedule itself, which is UTC-only and not guaranteed to fire on time, and getting the finished workbook somewhere durable. This guide sets up the whole thing. It extends Scheduling Python Excel Scripts with Cron.

What happens when the schedule fires A cron trigger expressed in UTC queues a run. A fresh Ubuntu runner checks out the repository, installs pinned Python dependencies from a cached wheel directory, and runs the report script with credentials injected from repository secrets as environment variables. The finished workbook is then published — uploaded as a workflow artifact for retrieval and sent to its real destination. A note records that the queue time between the trigger and the run start can be several minutes. trigger cron, UTC "17 6 1 * *" runner checkout + pinned deps run build_report.py secrets as env vars artifact — for retrieval expires; not a delivery channel publish or email the real destination the queue between trigger and start can be several minutes never take the report's as-at timestamp from the trigger

Prerequisites

A repository containing the report script and a pinned requirements file. Pinning matters more here than locally: a runner installs from scratch every time, so an unpinned dependency means the job silently picks up a new major version one morning.

Text
# requirements.txt
pandas==2.2.3
openpyxl==3.1.5
XlsxWriter==3.2.0
boto3==1.35.60

A script that takes its configuration from the environment and writes to a known path:

Python
# build_report.py
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd


def build(out_dir="out"):
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)

    # Real jobs read a database or an API here.
    report = pd.DataFrame({
        "region": ["North", "South", "West"],
        "revenue": [5150.00, 4268.50, 3511.25],
    })

    stamp = datetime.now(timezone.utc)
    path = out / f"regional-{stamp:%Y-%m}.xlsx"

    with pd.ExcelWriter(path, engine="xlsxwriter") as writer:
        report.to_excel(writer, sheet_name="Summary", index=False)
        book, sheet = writer.book, writer.sheets["Summary"]
        sheet.set_column("A:A", 16)
        sheet.set_column("B:B", 14,
                         book.add_format({"num_format": "#,##0.00"}))
        sheet.write(len(report) + 2, 0,
                    f"Generated {stamp:%Y-%m-%d %H:%M} UTC")

    print(f"wrote {path} ({path.stat().st_size:,} bytes)")
    return path


if __name__ == "__main__":
    try:
        build()
    except Exception as exc:                    # a non-zero exit fails the run
        print(f"::error::report failed: {exc}", file=sys.stderr)
        raise

The ::error:: prefix is a GitHub Actions annotation — it surfaces the message at the top of the run summary rather than buried in the log, which is what you want when the notification arrives at 6 a.m.

Step 1 — Write the workflow

Yaml
# .github/workflows/monthly-report.yml
name: Monthly regional report

on:
  schedule:
    # 06:17 UTC on the first of each month. Odd minute, deliberately.
    - cron: "17 6 1 * *"
  workflow_dispatch:            # lets you run it by hand

permissions:
  contents: read

jobs:
  report:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Build the report
        env:
          REPORT_BUCKET: ${{ secrets.REPORT_BUCKET }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: eu-west-1
        run: python build_report.py

      - name: Upload the workbook
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: regional-report
          path: out/*.xlsx
          retention-days: 30
          if-no-files-found: error

Five things in there earn their place.

workflow_dispatch adds a "Run workflow" button. Without it, testing a monthly job means waiting a month or committing a temporary schedule change.

timeout-minutes stops a hung job consuming its full six-hour default allowance. A report that normally takes two minutes should never run for twenty.

cache: pip reuses the wheel cache between runs, cutting the install step from a minute to a few seconds.

if: always() on the upload means the artifact is captured even when a later step fails — which is exactly when you want to see what the job produced.

if-no-files-found: error turns a silently empty upload into a failure. Without it, a job whose build step produced nothing still reports success.

Step 2 — Understand the schedule

Two properties of the Actions scheduler cause most of the confusion.

A fixed UTC schedule moves in local time twice a year A schedule of six in the morning UTC is shown against Central European local time across a year. Through the winter months it fires at seven local. From the spring transition to the autumn one it fires at eight local, because the offset changes while the UTC schedule does not. A report whose readers expect it before their working day starts must account for that hour, and a report that summarises a local period must compute the period boundaries in local time rather than from the trigger. cron "0 6 1 * *" — always 06:00 UTC Jan – Mar 07:00 local Apr – Oct 08:00 local — the offset changed, the cron did not Nov – Dec 07:00 local two consequences schedule an hour early if readers expect it before their day starts compute period boundaries in local time, not from the trigger

The cron is UTC and has no timezone option. A job scheduled for 06:00 UTC arrives an hour later in local terms for half the year. If the report must land before the working day starts, schedule for the summer offset and accept it arriving early in winter.

Scheduled runs are best-effort. The documented behaviour is that a schedule may be delayed under load, and runs on the hour are the most contended — hence 17 rather than 0 in the example. The practical rule that follows is to never derive the report's period from the trigger time. Compute it explicitly:

Python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def reporting_period(zone="Europe/Berlin"):
    """The month that just ended, in the readers' timezone."""
    now_local = datetime.now(timezone.utc).astimezone(ZoneInfo(zone))
    first_of_this = now_local.replace(day=1, hour=0, minute=0, second=0,
                                      microsecond=0)
    last_month_end = first_of_this
    last_month_start = (first_of_this.replace(day=1) -
                        __import__("datetime").timedelta(days=1)).replace(day=1)
    return last_month_start, last_month_end

A job that fires twenty minutes late still reports on the right month, because the month came from the calendar rather than from when the runner happened to start. The wider date handling is in working with dates and times in Excel data.

Step 3 — Add PDF conversion

The Ubuntu runner can install LibreOffice, so the conversion described in converting an Excel file to PDF works unchanged:

Yaml
      - name: Install LibreOffice
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y --no-install-recommends libreoffice-calc
          soffice --version

      - name: Export to PDF
        run: python export_pdf.py

It adds a minute or two per run. Where that matters, run the job in a container image with LibreOffice baked in:

Yaml
    container:
      image: ghcr.io/your-org/report-runner:2026-08

One runner-specific detail: fonts. A conversion on a machine missing the report's fonts silently substitutes others, so the PDF looks subtly wrong and nobody can say why. Install what the report uses:

Yaml
      - name: Install fonts
        run: sudo apt-get install -y --no-install-recommends fonts-liberation

Step 4 — Deliver the output

An artifact is retrievable and expires, which makes it a debugging convenience rather than a delivery channel. Publish or email for real delivery:

Yaml
      - name: Publish to S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: eu-west-1
          REPORT_BUCKET: ${{ secrets.REPORT_BUCKET }}
        run: python publish.py

Better still, drop the long-lived keys entirely and let the runner assume a role through OIDC:

Yaml
permissions:
  id-token: write
  contents: read

# ...
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/report-publisher
          aws-region: eu-west-1

That removes the secret from the repository altogether — the runner exchanges a short-lived token for temporary credentials, and there is nothing to rotate or leak. It is the same "authenticate as a machine" principle developed in publishing Excel reports to cloud storage.

Step 5 — Make failures visible

Where a short scheduled run actually spends its time Two runs of the same workflow. Without pip caching, provisioning the runner and checking out take a few seconds each, the dependency install takes around forty seconds, and the report script itself takes about ten — so the install dominates. With caching enabled the install drops to a few seconds and the total roughly halves, making the report script the largest single component. provision + checkout pip install the report script upload no cache cache: pip roughly half the wall clock splitting dev dependencies out of requirements.txt shrinks the pink band further

A scheduled job that fails silently is worse than no job, because everybody assumes the report is coming. GitHub emails the repository owner on a failed scheduled run, which is often not the person who cares:

Yaml
      - name: Notify on failure
        if: failure()
        env:
          SMTP_HOST: ${{ secrets.SMTP_HOST }}
          SMTP_USER: ${{ secrets.SMTP_USER }}
          SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: python notify_failure.py
Python
# notify_failure.py
import os
import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = os.environ["SMTP_USER"]
msg["To"] = "reporting-team@example.com"
msg["Subject"] = "Monthly regional report FAILED"
msg.set_content(
    "The scheduled regional report did not complete.\n\n"
    f"Run log: {os.environ['RUN_URL']}\n\n"
    "Last month's published report is unchanged."
)

with smtplib.SMTP(os.environ["SMTP_HOST"], 587, timeout=30) as smtp:
    smtp.starttls()
    smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
    smtp.send_message(msg)

Saying explicitly that the previous report is unchanged is the useful part — it tells the reader that what they can see is stale rather than wrong, which is a different and much calmer problem. The error-handling patterns behind this are in error handling and logging in Excel automation.

One more scheduling detail: GitHub disables scheduled workflows in repositories with no activity for sixty days. A monthly report in a quiet repository stops running, with no notification. Either keep the repository active or add a keep-alive step that touches something on each run.

Common pitfalls and fixes

SymptomCauseFix
Job runs an hour offCron is UTC, no timezone optionSchedule for the offset you need; compute periods in code.
Run starts minutes lateSchedules are best-effortUse an odd minute; never timestamp from the trigger.
Worked yesterday, broke todayUnpinned dependency updatedPin every version in requirements.txt.
Empty artifact, run passedNo files matched the pathSet if-no-files-found: error.
Secret appears in the logEchoed or interpolated into outputUse secrets.* as env vars; never print them.
Scheduled runs stoppedRepository inactive for 60 daysKeep it active, or re-enable the workflow.
PDF fonts look wrongFonts missing on the runnerapt-get install the font packages.
Job runs for hoursNo timeoutSet timeout-minutes.

Performance and scale notes

A runner starts cold every time, so the fixed costs dominate a short job: roughly ten to twenty seconds to provision, a few seconds for checkout, and however long the dependency install takes.

Caching pip is the single biggest win. cache: pip on setup-python typically cuts a forty-second install to under five.

Install only what the job needs. A requirements file carrying the whole development toolchain — pytest, linters, notebook dependencies — makes every scheduled run pay for them. Split them:

Text
# requirements.txt — what the report needs
pandas==2.2.3
openpyxl==3.1.5
XlsxWriter==3.2.0

# requirements-dev.txt — only for CI on pull requests
-r requirements.txt
pytest==8.3.3
ruff==0.7.4

Use a matrix for a fan-out. A report per region runs the regions in parallel across runners rather than sequentially in one:

Yaml
    strategy:
      max-parallel: 4
      matrix:
        region: [north, south, west, east]
    steps:
      # ...
      - run: python build_report.py --region ${{ matrix.region }}

max-parallel is worth setting: without it a large matrix consumes every available runner, delaying other work in the organisation.

Two limits to keep in view. Runner disk is generous but finite, so a job producing many large workbooks should upload and delete as it goes rather than accumulating everything. And artifact storage counts against the account's quota, so a daily job with a ninety-day retention accumulates real usage — set retention-days to the shortest window that is actually useful, and treat published storage as the durable copy.

Finally, the honest boundary: Actions suits jobs that run on a schedule measured in days, take minutes, and need no persistent state. A report that must run every five minutes, hold a warm database connection, or complete within seconds of a fixed time belongs on a real scheduler — the cron and APScheduler approaches in the parent topic are the right tools there.

Conclusion

GitHub Actions runs a scheduled Excel report without a server, and the workflow is short: checkout, setup-python with pip caching, pinned dependencies, the script with secrets as environment variables, then upload and publish. The two things to internalise are that the cron is UTC-only — so the local time shifts twice a year — and that scheduled runs are best-effort, so the report's period must come from the calendar rather than from when the runner started. Add workflow_dispatch so you can test it, a timeout so a hang cannot run for hours, and a failure notification that says the previous report is unchanged.

Frequently asked questions

What timezone does the GitHub Actions cron use? UTC, always. There is no timezone setting, so a job scheduled for 06:00 UTC runs at 07:00 or 08:00 local time depending on daylight saving. Compute the local time inside the job if the report is period-sensitive.

Why did my scheduled run start late? Scheduled workflows are queued rather than guaranteed, and runs on the hour are heavily contended. Schedule at an odd minute such as 17 past, and never rely on the trigger time as the report's as-at timestamp.

How do I store SMTP or cloud credentials? As repository or environment secrets, referenced in the workflow as secrets.NAME and injected as environment variables. They are masked in the logs and never appear in the repository.

Can I convert to PDF in a runner? Yes. The Ubuntu runner can install LibreOffice with apt, and headless conversion works exactly as it does locally. It adds a minute or two to the run, so use a prebuilt container image if that matters.

How do I get the workbook out of the run? Upload it as a workflow artifact for ad-hoc retrieval, and publish it to storage or email it for real delivery. Artifacts expire, so they are a debugging convenience rather than a distribution channel.