[{"data":1,"prerenderedAt":2664},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions":2657},{"id":4,"title":5,"body":6,"dateModified":2634,"datePublished":2634,"description":2635,"extension":2636,"faq":2637,"meta":2648,"navigation":269,"path":2649,"seo":2650,"slug":2653,"stem":2654,"type":2655,"__hash__":2656},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002Findex.md","Run a Python Excel Report in GitHub Actions",{"type":7,"value":8,"toc":2621},"minimark",[9,19,182,187,190,200,203,836,843,847,1222,1225,1234,1242,1250,1258,1266,1270,1273,1344,1350,1367,1557,1564,1568,1576,1640,1643,1665,1668,1693,1697,1700,1763,1766,1840,1847,1851,1958,1961,2041,2251,2258,2261,2265,2389,2393,2396,2409,2415,2421,2427,2505,2511,2518,2525,2529,2538,2542,2548,2554,2564,2570,2576,2580,2617],[10,11,12,13,18],"p",{},"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 ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[20,21,30,31,30,35,30,39,30,46,30,56,30,63,30,68,30,73,30,79,30,84,30,90,30,94,30,98,30,101,30,105,30,109,30,115,30,119,30,123,30,126,30,130,30,134,30,140,30,146,30,151,30,154,30,159,30,163,30,171,30,177],"svg",{"viewBox":22,"role":23,"ariaLabel":24,"ariaLabelledBy":25,"xmlns":28,"style":29},"0 0 800 250","img","A scheduled workflow: a UTC cron trigger starts a runner that checks out the repository, installs pinned dependencies, runs the report with secrets injected, and publishes the output.",[26,27],"gha-t","gha-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:800px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[32,33,34],"title",{"id":26},"What happens when the schedule fires",[36,37,38],"desc",{"id":27},"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.",[40,41],"rect",{"x":42,"y":42,"width":43,"height":44,"fill":45},"0","800","250","#ffffff",[40,47],{"x":48,"y":49,"width":50,"height":51,"rx":52,"fill":53,"stroke":54,"style":55},"14","76","130","72","12","#fdefd8","var(--gold,#b4740a)","stroke-width:2px",[57,58,62],"text",{"x":59,"y":60,"style":61},"79","60","font-size:10.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","trigger",[57,64,67],{"x":59,"y":65,"style":66},"106","font-size:11.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","cron, UTC",[57,69,72],{"x":59,"y":70,"style":71},"126","font-size:10px;fill:var(--muted,#5b6780);text-anchor:middle","\"17 6 1 * *\"",[74,75],"line",{"x1":76,"y1":77,"x2":78,"y2":77,"stroke":54,"style":55},"144","112","172",[80,81],"polygon",{"points":82,"fill":83},"180,112 168,106 168,118","#b4740a",[40,85],{"x":86,"y":49,"width":87,"height":51,"rx":52,"fill":88,"stroke":89,"style":55},"188","140","#ebebfd","var(--brand,#5b5cf0)",[57,91,93],{"x":92,"y":60,"style":61},"258","runner",[57,95,97],{"x":92,"y":65,"style":96},"font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","checkout",[57,99,100],{"x":92,"y":70,"style":71},"+ pinned deps",[74,102],{"x1":103,"y1":77,"x2":104,"y2":77,"stroke":89,"style":55},"328","356",[80,106],{"points":107,"fill":108},"364,112 352,106 352,118","#5b5cf0",[40,110],{"x":111,"y":49,"width":112,"height":51,"rx":52,"fill":113,"stroke":114,"style":55},"372","150","#d9f4f1","var(--teal,#0f9488)",[57,116,118],{"x":117,"y":60,"style":61},"447","run",[57,120,122],{"x":117,"y":65,"style":121},"font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","build_report.py",[57,124,125],{"x":117,"y":70,"style":71},"secrets as env vars",[74,127],{"x1":128,"y1":77,"x2":129,"y2":77,"stroke":114,"style":55},"522","550",[80,131],{"points":132,"fill":133},"558,112 546,106 546,118","#0f9488",[40,135],{"x":136,"y":60,"width":137,"height":138,"rx":139,"fill":88,"stroke":89,"style":55},"566","218","48","11",[57,141,145],{"x":142,"y":143,"style":144},"675","80","font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","artifact — for retrieval",[57,147,150],{"x":142,"y":148,"style":149},"98","font-size:9.5px;fill:var(--muted,#5b6780);text-anchor:middle","expires; not a delivery channel",[40,152],{"x":136,"y":153,"width":137,"height":138,"rx":139,"fill":113,"stroke":114,"style":55},"118",[57,155,158],{"x":142,"y":156,"style":157},"138","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","publish or email",[57,160,162],{"x":142,"y":161,"style":149},"156","the real destination",[40,164],{"x":165,"y":166,"width":167,"height":168,"rx":139,"fill":169,"stroke":170,"style":55},"96","184","608","52","#fee8f2","var(--accent,#f43f8f)",[57,172,176],{"x":173,"y":174,"style":175},"400","206","font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","the queue between trigger and start can be several minutes",[57,178,181],{"x":173,"y":179,"style":180},"224","font-size:10.5px;fill:var(--muted,#5b6780);text-anchor:middle","never take the report's as-at timestamp from the trigger",[183,184,186],"h2",{"id":185},"prerequisites","Prerequisites",[10,188,189],{},"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.",[191,192,197],"pre",{"className":193,"code":195,"language":57,"meta":196},[194],"language-text","# requirements.txt\npandas==2.2.3\nopenpyxl==3.1.5\nXlsxWriter==3.2.0\nboto3==1.35.60\n","",[198,199,195],"code",{"__ignoreMap":196},[10,201,202],{},"A script that takes its configuration from the environment and writes to a known path:",[191,204,208],{"className":205,"code":206,"language":207,"meta":196,"style":196},"language-python shiki shiki-themes github-light github-dark-high-contrast","# build_report.py\nimport os\nimport sys\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport pandas as pd\n\n\ndef build(out_dir=\"out\"):\n    out = Path(out_dir)\n    out.mkdir(parents=True, exist_ok=True)\n\n    # Real jobs read a database or an API here.\n    report = pd.DataFrame({\n        \"region\": [\"North\", \"South\", \"West\"],\n        \"revenue\": [5150.00, 4268.50, 3511.25],\n    })\n\n    stamp = datetime.now(timezone.utc)\n    path = out \u002F f\"regional-{stamp:%Y-%m}.xlsx\"\n\n    with pd.ExcelWriter(path, engine=\"xlsxwriter\") as writer:\n        report.to_excel(writer, sheet_name=\"Summary\", index=False)\n        book, sheet = writer.book, writer.sheets[\"Summary\"]\n        sheet.set_column(\"A:A\", 16)\n        sheet.set_column(\"B:B\", 14,\n                         book.add_format({\"num_format\": \"#,##0.00\"}))\n        sheet.write(len(report) + 2, 0,\n                    f\"Generated {stamp:%Y-%m-%d %H:%M} UTC\")\n\n    print(f\"wrote {path} ({path.stat().st_size:,} bytes)\")\n    return path\n\n\nif __name__ == \"__main__\":\n    try:\n        build()\n    except Exception as exc:                    # a non-zero exit fails the run\n        print(f\"::error::report failed: {exc}\", file=sys.stderr)\n        raise\n","python",[198,209,210,218,229,237,251,264,271,285,290,295,318,329,358,363,369,380,405,428,434,439,450,495,500,525,551,567,583,598,616,640,683,688,728,737,742,747,765,773,779,797,830],{"__ignoreMap":196},[211,212,214],"span",{"class":74,"line":213},1,[211,215,217],{"class":216},"s-wDw","# build_report.py\n",[211,219,221,225],{"class":74,"line":220},2,[211,222,224],{"class":223},"s-kum","import",[211,226,228],{"class":227},"skGVy"," os\n",[211,230,232,234],{"class":74,"line":231},3,[211,233,224],{"class":223},[211,235,236],{"class":227}," sys\n",[211,238,240,243,246,248],{"class":74,"line":239},4,[211,241,242],{"class":223},"from",[211,244,245],{"class":227}," datetime ",[211,247,224],{"class":223},[211,249,250],{"class":227}," datetime, timezone\n",[211,252,254,256,259,261],{"class":74,"line":253},5,[211,255,242],{"class":223},[211,257,258],{"class":227}," pathlib ",[211,260,224],{"class":223},[211,262,263],{"class":227}," Path\n",[211,265,267],{"class":74,"line":266},6,[211,268,270],{"emptyLinePlaceholder":269},true,"\n",[211,272,274,276,279,282],{"class":74,"line":273},7,[211,275,224],{"class":223},[211,277,278],{"class":227}," pandas ",[211,280,281],{"class":223},"as",[211,283,284],{"class":227}," pd\n",[211,286,288],{"class":74,"line":287},8,[211,289,270],{"emptyLinePlaceholder":269},[211,291,293],{"class":74,"line":292},9,[211,294,270],{"emptyLinePlaceholder":269},[211,296,298,301,305,308,311,315],{"class":74,"line":297},10,[211,299,300],{"class":223},"def",[211,302,304],{"class":303},"s_Opv"," build",[211,306,307],{"class":227},"(out_dir",[211,309,310],{"class":223},"=",[211,312,314],{"class":313},"srMev","\"out\"",[211,316,317],{"class":227},"):\n",[211,319,321,324,326],{"class":74,"line":320},11,[211,322,323],{"class":227},"    out ",[211,325,310],{"class":223},[211,327,328],{"class":227}," Path(out_dir)\n",[211,330,332,335,339,341,345,348,351,353,355],{"class":74,"line":331},12,[211,333,334],{"class":227},"    out.mkdir(",[211,336,338],{"class":337},"sa561","parents",[211,340,310],{"class":223},[211,342,344],{"class":343},"sP0c6","True",[211,346,347],{"class":227},", ",[211,349,350],{"class":337},"exist_ok",[211,352,310],{"class":223},[211,354,344],{"class":343},[211,356,357],{"class":227},")\n",[211,359,361],{"class":74,"line":360},13,[211,362,270],{"emptyLinePlaceholder":269},[211,364,366],{"class":74,"line":365},14,[211,367,368],{"class":216},"    # Real jobs read a database or an API here.\n",[211,370,372,375,377],{"class":74,"line":371},15,[211,373,374],{"class":227},"    report ",[211,376,310],{"class":223},[211,378,379],{"class":227}," pd.DataFrame({\n",[211,381,383,386,389,392,394,397,399,402],{"class":74,"line":382},16,[211,384,385],{"class":313},"        \"region\"",[211,387,388],{"class":227},": [",[211,390,391],{"class":313},"\"North\"",[211,393,347],{"class":227},[211,395,396],{"class":313},"\"South\"",[211,398,347],{"class":227},[211,400,401],{"class":313},"\"West\"",[211,403,404],{"class":227},"],\n",[211,406,408,411,413,416,418,421,423,426],{"class":74,"line":407},17,[211,409,410],{"class":313},"        \"revenue\"",[211,412,388],{"class":227},[211,414,415],{"class":343},"5150.00",[211,417,347],{"class":227},[211,419,420],{"class":343},"4268.50",[211,422,347],{"class":227},[211,424,425],{"class":343},"3511.25",[211,427,404],{"class":227},[211,429,431],{"class":74,"line":430},18,[211,432,433],{"class":227},"    })\n",[211,435,437],{"class":74,"line":436},19,[211,438,270],{"emptyLinePlaceholder":269},[211,440,442,445,447],{"class":74,"line":441},20,[211,443,444],{"class":227},"    stamp ",[211,446,310],{"class":223},[211,448,449],{"class":227}," datetime.now(timezone.utc)\n",[211,451,453,456,458,461,464,467,470,474,477,480,483,486,489,492],{"class":74,"line":452},21,[211,454,455],{"class":227},"    path ",[211,457,310],{"class":223},[211,459,460],{"class":227}," out ",[211,462,463],{"class":223},"\u002F",[211,465,466],{"class":223}," f",[211,468,469],{"class":313},"\"regional-",[211,471,473],{"class":472},"sSjpA","{",[211,475,476],{"class":227},"stamp:",[211,478,479],{"class":223},"%",[211,481,482],{"class":227},"Y",[211,484,485],{"class":223},"-%",[211,487,488],{"class":227},"m",[211,490,491],{"class":472},"}",[211,493,494],{"class":313},".xlsx\"\n",[211,496,498],{"class":74,"line":497},22,[211,499,270],{"emptyLinePlaceholder":269},[211,501,503,506,509,512,514,517,520,522],{"class":74,"line":502},23,[211,504,505],{"class":223},"    with",[211,507,508],{"class":227}," pd.ExcelWriter(path, ",[211,510,511],{"class":337},"engine",[211,513,310],{"class":223},[211,515,516],{"class":313},"\"xlsxwriter\"",[211,518,519],{"class":227},") ",[211,521,281],{"class":223},[211,523,524],{"class":227}," writer:\n",[211,526,528,531,534,536,539,541,544,546,549],{"class":74,"line":527},24,[211,529,530],{"class":227},"        report.to_excel(writer, ",[211,532,533],{"class":337},"sheet_name",[211,535,310],{"class":223},[211,537,538],{"class":313},"\"Summary\"",[211,540,347],{"class":227},[211,542,543],{"class":337},"index",[211,545,310],{"class":223},[211,547,548],{"class":343},"False",[211,550,357],{"class":227},[211,552,554,557,559,562,564],{"class":74,"line":553},25,[211,555,556],{"class":227},"        book, sheet ",[211,558,310],{"class":223},[211,560,561],{"class":227}," writer.book, writer.sheets[",[211,563,538],{"class":313},[211,565,566],{"class":227},"]\n",[211,568,570,573,576,578,581],{"class":74,"line":569},26,[211,571,572],{"class":227},"        sheet.set_column(",[211,574,575],{"class":313},"\"A:A\"",[211,577,347],{"class":227},[211,579,580],{"class":343},"16",[211,582,357],{"class":227},[211,584,586,588,591,593,595],{"class":74,"line":585},27,[211,587,572],{"class":227},[211,589,590],{"class":313},"\"B:B\"",[211,592,347],{"class":227},[211,594,48],{"class":343},[211,596,597],{"class":227},",\n",[211,599,601,604,607,610,613],{"class":74,"line":600},28,[211,602,603],{"class":227},"                         book.add_format({",[211,605,606],{"class":313},"\"num_format\"",[211,608,609],{"class":227},": ",[211,611,612],{"class":313},"\"#,##0.00\"",[211,614,615],{"class":227},"}))\n",[211,617,619,622,625,628,631,634,636,638],{"class":74,"line":618},29,[211,620,621],{"class":227},"        sheet.write(",[211,623,624],{"class":343},"len",[211,626,627],{"class":227},"(report) ",[211,629,630],{"class":223},"+",[211,632,633],{"class":343}," 2",[211,635,347],{"class":227},[211,637,42],{"class":343},[211,639,597],{"class":227},[211,641,643,646,649,651,653,655,657,659,661,663,666,668,671,673,676,678,681],{"class":74,"line":642},30,[211,644,645],{"class":223},"                    f",[211,647,648],{"class":313},"\"Generated ",[211,650,473],{"class":472},[211,652,476],{"class":227},[211,654,479],{"class":223},[211,656,482],{"class":227},[211,658,485],{"class":223},[211,660,488],{"class":227},[211,662,485],{"class":223},[211,664,665],{"class":227},"d ",[211,667,479],{"class":223},[211,669,670],{"class":227},"H:",[211,672,479],{"class":223},[211,674,675],{"class":227},"M",[211,677,491],{"class":472},[211,679,680],{"class":313}," UTC\"",[211,682,357],{"class":227},[211,684,686],{"class":74,"line":685},31,[211,687,270],{"emptyLinePlaceholder":269},[211,689,691,694,697,700,703,705,708,710,713,715,718,721,723,726],{"class":74,"line":690},32,[211,692,693],{"class":343},"    print",[211,695,696],{"class":227},"(",[211,698,699],{"class":223},"f",[211,701,702],{"class":313},"\"wrote ",[211,704,473],{"class":472},[211,706,707],{"class":227},"path",[211,709,491],{"class":472},[211,711,712],{"class":313}," (",[211,714,473],{"class":472},[211,716,717],{"class":227},"path.stat().st_size",[211,719,720],{"class":223},":,",[211,722,491],{"class":472},[211,724,725],{"class":313}," bytes)\"",[211,727,357],{"class":227},[211,729,731,734],{"class":74,"line":730},33,[211,732,733],{"class":223},"    return",[211,735,736],{"class":227}," path\n",[211,738,740],{"class":74,"line":739},34,[211,741,270],{"emptyLinePlaceholder":269},[211,743,745],{"class":74,"line":744},35,[211,746,270],{"emptyLinePlaceholder":269},[211,748,750,753,756,759,762],{"class":74,"line":749},36,[211,751,752],{"class":223},"if",[211,754,755],{"class":343}," __name__",[211,757,758],{"class":223}," ==",[211,760,761],{"class":313}," \"__main__\"",[211,763,764],{"class":227},":\n",[211,766,768,771],{"class":74,"line":767},37,[211,769,770],{"class":223},"    try",[211,772,764],{"class":227},[211,774,776],{"class":74,"line":775},38,[211,777,778],{"class":227},"        build()\n",[211,780,782,785,788,791,794],{"class":74,"line":781},39,[211,783,784],{"class":223},"    except",[211,786,787],{"class":343}," Exception",[211,789,790],{"class":223}," as",[211,792,793],{"class":227}," exc:                    ",[211,795,796],{"class":216},"# a non-zero exit fails the run\n",[211,798,800,803,805,807,810,812,815,817,820,822,825,827],{"class":74,"line":799},40,[211,801,802],{"class":343},"        print",[211,804,696],{"class":227},[211,806,699],{"class":223},[211,808,809],{"class":313},"\"::error::report failed: ",[211,811,473],{"class":472},[211,813,814],{"class":227},"exc",[211,816,491],{"class":472},[211,818,819],{"class":313},"\"",[211,821,347],{"class":227},[211,823,824],{"class":337},"file",[211,826,310],{"class":223},[211,828,829],{"class":227},"sys.stderr)\n",[211,831,833],{"class":74,"line":832},41,[211,834,835],{"class":223},"        raise\n",[10,837,838,839,842],{},"The ",[198,840,841],{},"::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.",[183,844,846],{"id":845},"step-1-write-the-workflow","Step 1 — Write the workflow",[191,848,852],{"className":849,"code":850,"language":851,"meta":196,"style":196},"language-yaml shiki shiki-themes github-light github-dark-high-contrast","# .github\u002Fworkflows\u002Fmonthly-report.yml\nname: Monthly regional report\n\non:\n  schedule:\n    # 06:17 UTC on the first of each month. Odd minute, deliberately.\n    - cron: \"17 6 1 * *\"\n  workflow_dispatch:            # lets you run it by hand\n\npermissions:\n  contents: read\n\njobs:\n  report:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.12\"\n          cache: pip\n\n      - name: Install dependencies\n        run: pip install -r requirements.txt\n\n      - name: Build the report\n        env:\n          REPORT_BUCKET: ${{ secrets.REPORT_BUCKET }}\n          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          AWS_REGION: eu-west-1\n        run: python build_report.py\n\n      - name: Upload the workbook\n        if: always()\n        uses: actions\u002Fupload-artifact@v4\n        with:\n          name: regional-report\n          path: out\u002F*.xlsx\n          retention-days: 30\n          if-no-files-found: error\n","yaml",[198,853,854,859,870,874,881,888,893,906,917,921,928,938,942,949,956,966,976,980,987,1000,1004,1015,1022,1032,1042,1046,1057,1067,1071,1082,1089,1099,1109,1119,1129,1138,1142,1153,1163,1173,1179,1189,1200,1211],{"__ignoreMap":196},[211,855,856],{"class":74,"line":213},[211,857,858],{"class":216},"# .github\u002Fworkflows\u002Fmonthly-report.yml\n",[211,860,861,865,867],{"class":74,"line":220},[211,862,864],{"class":863},"sx1sP","name",[211,866,609],{"class":227},[211,868,869],{"class":313},"Monthly regional report\n",[211,871,872],{"class":74,"line":231},[211,873,270],{"emptyLinePlaceholder":269},[211,875,876,879],{"class":74,"line":239},[211,877,878],{"class":343},"on",[211,880,764],{"class":227},[211,882,883,886],{"class":74,"line":253},[211,884,885],{"class":863},"  schedule",[211,887,764],{"class":227},[211,889,890],{"class":74,"line":266},[211,891,892],{"class":216},"    # 06:17 UTC on the first of each month. Odd minute, deliberately.\n",[211,894,895,898,901,903],{"class":74,"line":273},[211,896,897],{"class":227},"    - ",[211,899,900],{"class":863},"cron",[211,902,609],{"class":227},[211,904,905],{"class":313},"\"17 6 1 * *\"\n",[211,907,908,911,914],{"class":74,"line":287},[211,909,910],{"class":863},"  workflow_dispatch",[211,912,913],{"class":227},":            ",[211,915,916],{"class":216},"# lets you run it by hand\n",[211,918,919],{"class":74,"line":292},[211,920,270],{"emptyLinePlaceholder":269},[211,922,923,926],{"class":74,"line":297},[211,924,925],{"class":863},"permissions",[211,927,764],{"class":227},[211,929,930,933,935],{"class":74,"line":320},[211,931,932],{"class":863},"  contents",[211,934,609],{"class":227},[211,936,937],{"class":313},"read\n",[211,939,940],{"class":74,"line":331},[211,941,270],{"emptyLinePlaceholder":269},[211,943,944,947],{"class":74,"line":360},[211,945,946],{"class":863},"jobs",[211,948,764],{"class":227},[211,950,951,954],{"class":74,"line":365},[211,952,953],{"class":863},"  report",[211,955,764],{"class":227},[211,957,958,961,963],{"class":74,"line":371},[211,959,960],{"class":863},"    runs-on",[211,962,609],{"class":227},[211,964,965],{"class":313},"ubuntu-latest\n",[211,967,968,971,973],{"class":74,"line":382},[211,969,970],{"class":863},"    timeout-minutes",[211,972,609],{"class":227},[211,974,975],{"class":343},"20\n",[211,977,978],{"class":74,"line":407},[211,979,270],{"emptyLinePlaceholder":269},[211,981,982,985],{"class":74,"line":430},[211,983,984],{"class":863},"    steps",[211,986,764],{"class":227},[211,988,989,992,995,997],{"class":74,"line":436},[211,990,991],{"class":227},"      - ",[211,993,994],{"class":863},"uses",[211,996,609],{"class":227},[211,998,999],{"class":313},"actions\u002Fcheckout@v4\n",[211,1001,1002],{"class":74,"line":441},[211,1003,270],{"emptyLinePlaceholder":269},[211,1005,1006,1008,1010,1012],{"class":74,"line":452},[211,1007,991],{"class":227},[211,1009,994],{"class":863},[211,1011,609],{"class":227},[211,1013,1014],{"class":313},"actions\u002Fsetup-python@v5\n",[211,1016,1017,1020],{"class":74,"line":497},[211,1018,1019],{"class":863},"        with",[211,1021,764],{"class":227},[211,1023,1024,1027,1029],{"class":74,"line":502},[211,1025,1026],{"class":863},"          python-version",[211,1028,609],{"class":227},[211,1030,1031],{"class":313},"\"3.12\"\n",[211,1033,1034,1037,1039],{"class":74,"line":527},[211,1035,1036],{"class":863},"          cache",[211,1038,609],{"class":227},[211,1040,1041],{"class":313},"pip\n",[211,1043,1044],{"class":74,"line":553},[211,1045,270],{"emptyLinePlaceholder":269},[211,1047,1048,1050,1052,1054],{"class":74,"line":569},[211,1049,991],{"class":227},[211,1051,864],{"class":863},[211,1053,609],{"class":227},[211,1055,1056],{"class":313},"Install dependencies\n",[211,1058,1059,1062,1064],{"class":74,"line":585},[211,1060,1061],{"class":863},"        run",[211,1063,609],{"class":227},[211,1065,1066],{"class":313},"pip install -r requirements.txt\n",[211,1068,1069],{"class":74,"line":600},[211,1070,270],{"emptyLinePlaceholder":269},[211,1072,1073,1075,1077,1079],{"class":74,"line":618},[211,1074,991],{"class":227},[211,1076,864],{"class":863},[211,1078,609],{"class":227},[211,1080,1081],{"class":313},"Build the report\n",[211,1083,1084,1087],{"class":74,"line":642},[211,1085,1086],{"class":863},"        env",[211,1088,764],{"class":227},[211,1090,1091,1094,1096],{"class":74,"line":685},[211,1092,1093],{"class":863},"          REPORT_BUCKET",[211,1095,609],{"class":227},[211,1097,1098],{"class":313},"${{ secrets.REPORT_BUCKET }}\n",[211,1100,1101,1104,1106],{"class":74,"line":690},[211,1102,1103],{"class":863},"          AWS_ACCESS_KEY_ID",[211,1105,609],{"class":227},[211,1107,1108],{"class":313},"${{ secrets.AWS_ACCESS_KEY_ID }}\n",[211,1110,1111,1114,1116],{"class":74,"line":730},[211,1112,1113],{"class":863},"          AWS_SECRET_ACCESS_KEY",[211,1115,609],{"class":227},[211,1117,1118],{"class":313},"${{ secrets.AWS_SECRET_ACCESS_KEY }}\n",[211,1120,1121,1124,1126],{"class":74,"line":739},[211,1122,1123],{"class":863},"          AWS_REGION",[211,1125,609],{"class":227},[211,1127,1128],{"class":313},"eu-west-1\n",[211,1130,1131,1133,1135],{"class":74,"line":744},[211,1132,1061],{"class":863},[211,1134,609],{"class":227},[211,1136,1137],{"class":313},"python build_report.py\n",[211,1139,1140],{"class":74,"line":749},[211,1141,270],{"emptyLinePlaceholder":269},[211,1143,1144,1146,1148,1150],{"class":74,"line":767},[211,1145,991],{"class":227},[211,1147,864],{"class":863},[211,1149,609],{"class":227},[211,1151,1152],{"class":313},"Upload the workbook\n",[211,1154,1155,1158,1160],{"class":74,"line":775},[211,1156,1157],{"class":863},"        if",[211,1159,609],{"class":227},[211,1161,1162],{"class":313},"always()\n",[211,1164,1165,1168,1170],{"class":74,"line":781},[211,1166,1167],{"class":863},"        uses",[211,1169,609],{"class":227},[211,1171,1172],{"class":313},"actions\u002Fupload-artifact@v4\n",[211,1174,1175,1177],{"class":74,"line":799},[211,1176,1019],{"class":863},[211,1178,764],{"class":227},[211,1180,1181,1184,1186],{"class":74,"line":832},[211,1182,1183],{"class":863},"          name",[211,1185,609],{"class":227},[211,1187,1188],{"class":313},"regional-report\n",[211,1190,1192,1195,1197],{"class":74,"line":1191},42,[211,1193,1194],{"class":863},"          path",[211,1196,609],{"class":227},[211,1198,1199],{"class":313},"out\u002F*.xlsx\n",[211,1201,1203,1206,1208],{"class":74,"line":1202},43,[211,1204,1205],{"class":863},"          retention-days",[211,1207,609],{"class":227},[211,1209,1210],{"class":343},"30\n",[211,1212,1214,1217,1219],{"class":74,"line":1213},44,[211,1215,1216],{"class":863},"          if-no-files-found",[211,1218,609],{"class":227},[211,1220,1221],{"class":313},"error\n",[10,1223,1224],{},"Five things in there earn their place.",[10,1226,1227,1233],{},[1228,1229,1230],"strong",{},[198,1231,1232],{},"workflow_dispatch"," adds a \"Run workflow\" button. Without it, testing a monthly job means waiting a month or committing a temporary schedule change.",[10,1235,1236,1241],{},[1228,1237,1238],{},[198,1239,1240],{},"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.",[10,1243,1244,1249],{},[1228,1245,1246],{},[198,1247,1248],{},"cache: pip"," reuses the wheel cache between runs, cutting the install step from a minute to a few seconds.",[10,1251,1252,1257],{},[1228,1253,1254],{},[198,1255,1256],{},"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.",[10,1259,1260,1265],{},[1228,1261,1262],{},[198,1263,1264],{},"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.",[183,1267,1269],{"id":1268},"step-2-understand-the-schedule","Step 2 — Understand the schedule",[10,1271,1272],{},"Two properties of the Actions scheduler cause most of the confusion.",[20,1274,30,1280,30,1283,30,1286,30,1289,30,1294,30,1298,30,1303,30,1307,30,1311,30,1314,30,1317,30,1320,30,1324,30,1326,30,1331,30,1336,30,1341],{"viewBox":1275,"role":23,"ariaLabel":1276,"ariaLabelledBy":1277,"xmlns":28,"style":29},"0 0 800 240","Cron in UTC against local time: a fixed 06:00 UTC schedule lands at 07:00 local in winter and 08:00 in summer, so the local run time shifts twice a year.",[1278,1279],"tz-t","tz-d",[32,1281,1282],{"id":1278},"A fixed UTC schedule moves in local time twice a year",[36,1284,1285],{"id":1279},"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.",[40,1287],{"x":42,"y":42,"width":43,"height":1288,"fill":45},"240",[57,1290,1293],{"x":173,"y":1291,"style":1292},"28","font-size:12px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","cron \"0 6 1 * *\" — always 06:00 UTC",[40,1295],{"x":1296,"y":168,"width":86,"height":60,"rx":1297,"fill":88,"stroke":89,"style":55},"30","10",[57,1299,1302],{"x":1300,"y":1301,"style":96},"124","78","Jan – Mar",[57,1304,1306],{"x":1300,"y":148,"style":1305},"font-size:11px;fill:var(--text,#172033);text-anchor:middle","07:00 local",[40,1308],{"x":1309,"y":168,"width":1310,"height":60,"rx":1297,"fill":53,"stroke":54,"style":55},"228","344",[57,1312,1313],{"x":173,"y":1301,"style":66},"Apr – Oct",[57,1315,1316],{"x":173,"y":148,"style":1305},"08:00 local — the offset changed, the cron did not",[40,1318],{"x":1319,"y":168,"width":86,"height":60,"rx":1297,"fill":88,"stroke":89,"style":55},"582",[57,1321,1323],{"x":1322,"y":1301,"style":96},"676","Nov – Dec",[57,1325,1306],{"x":1322,"y":148,"style":1305},[40,1327],{"x":1328,"y":1329,"width":1330,"height":143,"rx":52,"fill":169,"stroke":170,"style":55},"120","136","560",[57,1332,1335],{"x":173,"y":1333,"style":1334},"162","font-size:11.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","two consequences",[57,1337,1340],{"x":173,"y":1338,"style":1339},"186","font-size:10.5px;fill:var(--text,#172033);text-anchor:middle","schedule an hour early if readers expect it before their day starts",[57,1342,1343],{"x":173,"y":174,"style":1339},"compute period boundaries in local time, not from the trigger",[10,1345,1346,1349],{},[1228,1347,1348],{},"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.",[10,1351,1352,1355,1356,1359,1360,1362,1363,1366],{},[1228,1353,1354],{},"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 ",[198,1357,1358],{},"17"," rather than ",[198,1361,42],{}," in the example. The practical rule that follows is to ",[1228,1364,1365],{},"never derive the report's period from the trigger time",". Compute it explicitly:",[191,1368,1370],{"className":205,"code":1369,"language":207,"meta":196,"style":196},"from datetime import datetime, timezone\nfrom zoneinfo import ZoneInfo\n\ndef reporting_period(zone=\"Europe\u002FBerlin\"):\n    \"\"\"The month that just ended, in the readers' timezone.\"\"\"\n    now_local = datetime.now(timezone.utc).astimezone(ZoneInfo(zone))\n    first_of_this = now_local.replace(day=1, hour=0, minute=0, second=0,\n                                      microsecond=0)\n    last_month_end = first_of_this\n    last_month_start = (first_of_this.replace(day=1) -\n                        __import__(\"datetime\").timedelta(days=1)).replace(day=1)\n    return last_month_start, last_month_end\n",[198,1371,1372,1382,1394,1398,1415,1420,1430,1477,1488,1498,1519,1550],{"__ignoreMap":196},[211,1373,1374,1376,1378,1380],{"class":74,"line":213},[211,1375,242],{"class":223},[211,1377,245],{"class":227},[211,1379,224],{"class":223},[211,1381,250],{"class":227},[211,1383,1384,1386,1389,1391],{"class":74,"line":220},[211,1385,242],{"class":223},[211,1387,1388],{"class":227}," zoneinfo ",[211,1390,224],{"class":223},[211,1392,1393],{"class":227}," ZoneInfo\n",[211,1395,1396],{"class":74,"line":231},[211,1397,270],{"emptyLinePlaceholder":269},[211,1399,1400,1402,1405,1408,1410,1413],{"class":74,"line":239},[211,1401,300],{"class":223},[211,1403,1404],{"class":303}," reporting_period",[211,1406,1407],{"class":227},"(zone",[211,1409,310],{"class":223},[211,1411,1412],{"class":313},"\"Europe\u002FBerlin\"",[211,1414,317],{"class":227},[211,1416,1417],{"class":74,"line":253},[211,1418,1419],{"class":313},"    \"\"\"The month that just ended, in the readers' timezone.\"\"\"\n",[211,1421,1422,1425,1427],{"class":74,"line":266},[211,1423,1424],{"class":227},"    now_local ",[211,1426,310],{"class":223},[211,1428,1429],{"class":227}," datetime.now(timezone.utc).astimezone(ZoneInfo(zone))\n",[211,1431,1432,1435,1437,1440,1443,1445,1448,1450,1453,1455,1457,1459,1462,1464,1466,1468,1471,1473,1475],{"class":74,"line":273},[211,1433,1434],{"class":227},"    first_of_this ",[211,1436,310],{"class":223},[211,1438,1439],{"class":227}," now_local.replace(",[211,1441,1442],{"class":337},"day",[211,1444,310],{"class":223},[211,1446,1447],{"class":343},"1",[211,1449,347],{"class":227},[211,1451,1452],{"class":337},"hour",[211,1454,310],{"class":223},[211,1456,42],{"class":343},[211,1458,347],{"class":227},[211,1460,1461],{"class":337},"minute",[211,1463,310],{"class":223},[211,1465,42],{"class":343},[211,1467,347],{"class":227},[211,1469,1470],{"class":337},"second",[211,1472,310],{"class":223},[211,1474,42],{"class":343},[211,1476,597],{"class":227},[211,1478,1479,1482,1484,1486],{"class":74,"line":287},[211,1480,1481],{"class":337},"                                      microsecond",[211,1483,310],{"class":223},[211,1485,42],{"class":343},[211,1487,357],{"class":227},[211,1489,1490,1493,1495],{"class":74,"line":292},[211,1491,1492],{"class":227},"    last_month_end ",[211,1494,310],{"class":223},[211,1496,1497],{"class":227}," first_of_this\n",[211,1499,1500,1503,1505,1508,1510,1512,1514,1516],{"class":74,"line":297},[211,1501,1502],{"class":227},"    last_month_start ",[211,1504,310],{"class":223},[211,1506,1507],{"class":227}," (first_of_this.replace(",[211,1509,1442],{"class":337},[211,1511,310],{"class":223},[211,1513,1447],{"class":343},[211,1515,519],{"class":227},[211,1517,1518],{"class":223},"-\n",[211,1520,1521,1524,1526,1529,1532,1535,1537,1539,1542,1544,1546,1548],{"class":74,"line":320},[211,1522,1523],{"class":343},"                        __import__",[211,1525,696],{"class":227},[211,1527,1528],{"class":313},"\"datetime\"",[211,1530,1531],{"class":227},").timedelta(",[211,1533,1534],{"class":337},"days",[211,1536,310],{"class":223},[211,1538,1447],{"class":343},[211,1540,1541],{"class":227},")).replace(",[211,1543,1442],{"class":337},[211,1545,310],{"class":223},[211,1547,1447],{"class":343},[211,1549,357],{"class":227},[211,1551,1552,1554],{"class":74,"line":331},[211,1553,733],{"class":223},[211,1555,1556],{"class":227}," last_month_start, last_month_end\n",[10,1558,1559,1560,18],{},"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 ",[14,1561,1563],{"href":1562},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002F","working with dates and times in Excel data",[183,1565,1567],{"id":1566},"step-3-add-pdf-conversion","Step 3 — Add PDF conversion",[10,1569,1570,1571,1575],{},"The Ubuntu runner can install LibreOffice, so the conversion described in ",[14,1572,1574],{"href":1573},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-file-to-pdf-with-python\u002F","converting an Excel file to PDF"," works unchanged:",[191,1577,1579],{"className":849,"code":1578,"language":851,"meta":196,"style":196},"      - name: Install LibreOffice\n        run: |\n          sudo apt-get update -qq\n          sudo apt-get install -y --no-install-recommends libreoffice-calc\n          soffice --version\n\n      - name: Export to PDF\n        run: python export_pdf.py\n",[198,1580,1581,1592,1601,1606,1611,1616,1620,1631],{"__ignoreMap":196},[211,1582,1583,1585,1587,1589],{"class":74,"line":213},[211,1584,991],{"class":227},[211,1586,864],{"class":863},[211,1588,609],{"class":227},[211,1590,1591],{"class":313},"Install LibreOffice\n",[211,1593,1594,1596,1598],{"class":74,"line":220},[211,1595,1061],{"class":863},[211,1597,609],{"class":227},[211,1599,1600],{"class":223},"|\n",[211,1602,1603],{"class":74,"line":231},[211,1604,1605],{"class":313},"          sudo apt-get update -qq\n",[211,1607,1608],{"class":74,"line":239},[211,1609,1610],{"class":313},"          sudo apt-get install -y --no-install-recommends libreoffice-calc\n",[211,1612,1613],{"class":74,"line":253},[211,1614,1615],{"class":313},"          soffice --version\n",[211,1617,1618],{"class":74,"line":266},[211,1619,270],{"emptyLinePlaceholder":269},[211,1621,1622,1624,1626,1628],{"class":74,"line":273},[211,1623,991],{"class":227},[211,1625,864],{"class":863},[211,1627,609],{"class":227},[211,1629,1630],{"class":313},"Export to PDF\n",[211,1632,1633,1635,1637],{"class":74,"line":287},[211,1634,1061],{"class":863},[211,1636,609],{"class":227},[211,1638,1639],{"class":313},"python export_pdf.py\n",[10,1641,1642],{},"It adds a minute or two per run. Where that matters, run the job in a container image with LibreOffice baked in:",[191,1644,1646],{"className":849,"code":1645,"language":851,"meta":196,"style":196},"    container:\n      image: ghcr.io\u002Fyour-org\u002Freport-runner:2026-08\n",[198,1647,1648,1655],{"__ignoreMap":196},[211,1649,1650,1653],{"class":74,"line":213},[211,1651,1652],{"class":863},"    container",[211,1654,764],{"class":227},[211,1656,1657,1660,1662],{"class":74,"line":220},[211,1658,1659],{"class":863},"      image",[211,1661,609],{"class":227},[211,1663,1664],{"class":313},"ghcr.io\u002Fyour-org\u002Freport-runner:2026-08\n",[10,1666,1667],{},"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:",[191,1669,1671],{"className":849,"code":1670,"language":851,"meta":196,"style":196},"      - name: Install fonts\n        run: sudo apt-get install -y --no-install-recommends fonts-liberation\n",[198,1672,1673,1684],{"__ignoreMap":196},[211,1674,1675,1677,1679,1681],{"class":74,"line":213},[211,1676,991],{"class":227},[211,1678,864],{"class":863},[211,1680,609],{"class":227},[211,1682,1683],{"class":313},"Install fonts\n",[211,1685,1686,1688,1690],{"class":74,"line":220},[211,1687,1061],{"class":863},[211,1689,609],{"class":227},[211,1691,1692],{"class":313},"sudo apt-get install -y --no-install-recommends fonts-liberation\n",[183,1694,1696],{"id":1695},"step-4-deliver-the-output","Step 4 — Deliver the output",[10,1698,1699],{},"An artifact is retrievable and expires, which makes it a debugging convenience rather than a delivery channel. Publish or email for real delivery:",[191,1701,1703],{"className":849,"code":1702,"language":851,"meta":196,"style":196},"      - name: Publish to S3\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          AWS_REGION: eu-west-1\n          REPORT_BUCKET: ${{ secrets.REPORT_BUCKET }}\n        run: python publish.py\n",[198,1704,1705,1716,1722,1730,1738,1746,1754],{"__ignoreMap":196},[211,1706,1707,1709,1711,1713],{"class":74,"line":213},[211,1708,991],{"class":227},[211,1710,864],{"class":863},[211,1712,609],{"class":227},[211,1714,1715],{"class":313},"Publish to S3\n",[211,1717,1718,1720],{"class":74,"line":220},[211,1719,1086],{"class":863},[211,1721,764],{"class":227},[211,1723,1724,1726,1728],{"class":74,"line":231},[211,1725,1103],{"class":863},[211,1727,609],{"class":227},[211,1729,1108],{"class":313},[211,1731,1732,1734,1736],{"class":74,"line":239},[211,1733,1113],{"class":863},[211,1735,609],{"class":227},[211,1737,1118],{"class":313},[211,1739,1740,1742,1744],{"class":74,"line":253},[211,1741,1123],{"class":863},[211,1743,609],{"class":227},[211,1745,1128],{"class":313},[211,1747,1748,1750,1752],{"class":74,"line":266},[211,1749,1093],{"class":863},[211,1751,609],{"class":227},[211,1753,1098],{"class":313},[211,1755,1756,1758,1760],{"class":74,"line":273},[211,1757,1061],{"class":863},[211,1759,609],{"class":227},[211,1761,1762],{"class":313},"python publish.py\n",[10,1764,1765],{},"Better still, drop the long-lived keys entirely and let the runner assume a role through OIDC:",[191,1767,1769],{"className":849,"code":1768,"language":851,"meta":196,"style":196},"permissions:\n  id-token: write\n  contents: read\n\n# ...\n      - uses: aws-actions\u002Fconfigure-aws-credentials@v4\n        with:\n          role-to-assume: arn:aws:iam::123456789012:role\u002Freport-publisher\n          aws-region: eu-west-1\n",[198,1770,1771,1777,1787,1795,1799,1804,1815,1821,1831],{"__ignoreMap":196},[211,1772,1773,1775],{"class":74,"line":213},[211,1774,925],{"class":863},[211,1776,764],{"class":227},[211,1778,1779,1782,1784],{"class":74,"line":220},[211,1780,1781],{"class":863},"  id-token",[211,1783,609],{"class":227},[211,1785,1786],{"class":313},"write\n",[211,1788,1789,1791,1793],{"class":74,"line":231},[211,1790,932],{"class":863},[211,1792,609],{"class":227},[211,1794,937],{"class":313},[211,1796,1797],{"class":74,"line":239},[211,1798,270],{"emptyLinePlaceholder":269},[211,1800,1801],{"class":74,"line":253},[211,1802,1803],{"class":216},"# ...\n",[211,1805,1806,1808,1810,1812],{"class":74,"line":266},[211,1807,991],{"class":227},[211,1809,994],{"class":863},[211,1811,609],{"class":227},[211,1813,1814],{"class":313},"aws-actions\u002Fconfigure-aws-credentials@v4\n",[211,1816,1817,1819],{"class":74,"line":273},[211,1818,1019],{"class":863},[211,1820,764],{"class":227},[211,1822,1823,1826,1828],{"class":74,"line":287},[211,1824,1825],{"class":863},"          role-to-assume",[211,1827,609],{"class":227},[211,1829,1830],{"class":313},"arn:aws:iam::123456789012:role\u002Freport-publisher\n",[211,1832,1833,1836,1838],{"class":74,"line":292},[211,1834,1835],{"class":863},"          aws-region",[211,1837,609],{"class":227},[211,1839,1128],{"class":313},[10,1841,1842,1843,18],{},"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 ",[14,1844,1846],{"href":1845},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002F","publishing Excel reports to cloud storage",[183,1848,1850],{"id":1849},"step-5-make-failures-visible","Step 5 — Make failures visible",[20,1852,30,1858,30,1861,30,1864,30,1867,30,1872,30,1878,30,1882,30,1886,30,1890,30,1894,30,1897,30,1901,30,1906,30,1910,30,1930,30,1933,30,1936,30,1948,30,1953],{"viewBox":1853,"role":23,"ariaLabel":1854,"ariaLabelledBy":1855,"xmlns":28,"style":29},"0 0 800 226","Where a workflow's minutes go: provisioning and checkout are fixed costs, dependency install dominates without caching, and the report script itself is often the smallest part.",[1856,1857],"mins-t","mins-d",[32,1859,1860],{"id":1856},"Where a short scheduled run actually spends its time",[36,1862,1863],{"id":1857},"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.",[40,1865],{"x":42,"y":42,"width":43,"height":1866,"fill":45},"226",[40,1868],{"x":50,"y":1869,"width":48,"height":48,"rx":1870,"fill":1871},"20","3","#a3b1c9",[57,1873,1877],{"x":1874,"y":1875,"style":1876},"152","31","font-size:11px;fill:var(--muted,#5b6780)","provision + checkout",[40,1879],{"x":1880,"y":1869,"width":48,"height":48,"rx":1870,"fill":1881},"316","#f43f8f",[57,1883,1885],{"x":1884,"y":1875,"style":1876},"338","pip install",[40,1887],{"x":1888,"y":1869,"width":48,"height":48,"rx":1870,"fill":1889},"446","var(--teal,#14b8a6)",[57,1891,1893],{"x":1892,"y":1875,"style":1876},"468","the report script",[40,1895],{"x":1896,"y":1869,"width":48,"height":48,"rx":1870,"fill":108},"596",[57,1898,1900],{"x":1899,"y":1875,"style":1876},"618","upload",[40,1902],{"x":48,"y":60,"width":50,"height":1291,"rx":1903,"fill":1904,"stroke":1905},"6","#f0f2f5","var(--line,#cdd5e6)",[57,1907,1909],{"x":59,"y":59,"style":1908},"font-size:10.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","no cache",[1911,1912,1913,1914,1913,1918,1913,1922,1913,1926,30],"g",{},"\n    ",[40,1915],{"x":1916,"y":60,"width":165,"height":1291,"rx":1917,"fill":1871},"158","4",[40,1919],{"x":1920,"y":60,"width":1921,"height":1291,"rx":1917,"fill":1881},"254","404",[40,1923],{"x":1924,"y":60,"width":1925,"height":1291,"rx":1917,"fill":1889},"658","86",[40,1927],{"x":1928,"y":60,"width":1929,"height":1291,"rx":1917,"fill":108},"744","34",[40,1931],{"x":48,"y":1932,"width":50,"height":1291,"rx":1903,"fill":1904,"stroke":1905},"122",[57,1934,1248],{"x":59,"y":1935,"style":1908},"141",[1911,1937,1913,1938,1913,1940,1913,1942,1913,1945,30],{},[40,1939],{"x":1916,"y":1932,"width":165,"height":1291,"rx":1917,"fill":1871},[40,1941],{"x":1920,"y":1932,"width":168,"height":1291,"rx":1917,"fill":1881},[40,1943],{"x":1944,"y":1932,"width":1925,"height":1291,"rx":1917,"fill":1889},"306",[40,1946],{"x":1947,"y":1932,"width":1929,"height":1291,"rx":1917,"fill":108},"392",[57,1949,1952],{"x":1950,"y":1935,"style":1951},"440","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157)","roughly half the wall clock",[57,1954,1957],{"x":173,"y":1955,"style":1956},"194","font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","splitting dev dependencies out of requirements.txt shrinks the pink band further",[10,1959,1960],{},"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:",[191,1962,1964],{"className":849,"code":1963,"language":851,"meta":196,"style":196},"      - name: Notify on failure\n        if: failure()\n        env:\n          SMTP_HOST: ${{ secrets.SMTP_HOST }}\n          SMTP_USER: ${{ secrets.SMTP_USER }}\n          SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}\n          RUN_URL: ${{ github.server_url }}\u002F${{ github.repository }}\u002Factions\u002Fruns\u002F${{ github.run_id }}\n        run: python notify_failure.py\n",[198,1965,1966,1977,1986,1992,2002,2012,2022,2032],{"__ignoreMap":196},[211,1967,1968,1970,1972,1974],{"class":74,"line":213},[211,1969,991],{"class":227},[211,1971,864],{"class":863},[211,1973,609],{"class":227},[211,1975,1976],{"class":313},"Notify on failure\n",[211,1978,1979,1981,1983],{"class":74,"line":220},[211,1980,1157],{"class":863},[211,1982,609],{"class":227},[211,1984,1985],{"class":313},"failure()\n",[211,1987,1988,1990],{"class":74,"line":231},[211,1989,1086],{"class":863},[211,1991,764],{"class":227},[211,1993,1994,1997,1999],{"class":74,"line":239},[211,1995,1996],{"class":863},"          SMTP_HOST",[211,1998,609],{"class":227},[211,2000,2001],{"class":313},"${{ secrets.SMTP_HOST }}\n",[211,2003,2004,2007,2009],{"class":74,"line":253},[211,2005,2006],{"class":863},"          SMTP_USER",[211,2008,609],{"class":227},[211,2010,2011],{"class":313},"${{ secrets.SMTP_USER }}\n",[211,2013,2014,2017,2019],{"class":74,"line":266},[211,2015,2016],{"class":863},"          SMTP_PASSWORD",[211,2018,609],{"class":227},[211,2020,2021],{"class":313},"${{ secrets.SMTP_PASSWORD }}\n",[211,2023,2024,2027,2029],{"class":74,"line":273},[211,2025,2026],{"class":863},"          RUN_URL",[211,2028,609],{"class":227},[211,2030,2031],{"class":313},"${{ github.server_url }}\u002F${{ github.repository }}\u002Factions\u002Fruns\u002F${{ github.run_id }}\n",[211,2033,2034,2036,2038],{"class":74,"line":287},[211,2035,1061],{"class":863},[211,2037,609],{"class":227},[211,2039,2040],{"class":313},"python notify_failure.py\n",[191,2042,2044],{"className":205,"code":2043,"language":207,"meta":196,"style":196},"# notify_failure.py\nimport os\nimport smtplib\nfrom email.message import EmailMessage\n\nmsg = EmailMessage()\nmsg[\"From\"] = os.environ[\"SMTP_USER\"]\nmsg[\"To\"] = \"reporting-team@example.com\"\nmsg[\"Subject\"] = \"Monthly regional report FAILED\"\nmsg.set_content(\n    \"The scheduled regional report did not complete.\\n\\n\"\n    f\"Run log: {os.environ['RUN_URL']}\\n\\n\"\n    \"Last month's published report is unchanged.\"\n)\n\nwith smtplib.SMTP(os.environ[\"SMTP_HOST\"], 587, timeout=30) as smtp:\n    smtp.starttls()\n    smtp.login(os.environ[\"SMTP_USER\"], os.environ[\"SMTP_PASSWORD\"])\n    smtp.send_message(msg)\n",[198,2045,2046,2051,2057,2064,2076,2080,2090,2111,2125,2139,2144,2155,2179,2184,2188,2192,2225,2230,2246],{"__ignoreMap":196},[211,2047,2048],{"class":74,"line":213},[211,2049,2050],{"class":216},"# notify_failure.py\n",[211,2052,2053,2055],{"class":74,"line":220},[211,2054,224],{"class":223},[211,2056,228],{"class":227},[211,2058,2059,2061],{"class":74,"line":231},[211,2060,224],{"class":223},[211,2062,2063],{"class":227}," smtplib\n",[211,2065,2066,2068,2071,2073],{"class":74,"line":239},[211,2067,242],{"class":223},[211,2069,2070],{"class":227}," email.message ",[211,2072,224],{"class":223},[211,2074,2075],{"class":227}," EmailMessage\n",[211,2077,2078],{"class":74,"line":253},[211,2079,270],{"emptyLinePlaceholder":269},[211,2081,2082,2085,2087],{"class":74,"line":266},[211,2083,2084],{"class":227},"msg ",[211,2086,310],{"class":223},[211,2088,2089],{"class":227}," EmailMessage()\n",[211,2091,2092,2095,2098,2101,2103,2106,2109],{"class":74,"line":273},[211,2093,2094],{"class":227},"msg[",[211,2096,2097],{"class":313},"\"From\"",[211,2099,2100],{"class":227},"] ",[211,2102,310],{"class":223},[211,2104,2105],{"class":227}," os.environ[",[211,2107,2108],{"class":313},"\"SMTP_USER\"",[211,2110,566],{"class":227},[211,2112,2113,2115,2118,2120,2122],{"class":74,"line":287},[211,2114,2094],{"class":227},[211,2116,2117],{"class":313},"\"To\"",[211,2119,2100],{"class":227},[211,2121,310],{"class":223},[211,2123,2124],{"class":313}," \"reporting-team@example.com\"\n",[211,2126,2127,2129,2132,2134,2136],{"class":74,"line":292},[211,2128,2094],{"class":227},[211,2130,2131],{"class":313},"\"Subject\"",[211,2133,2100],{"class":227},[211,2135,310],{"class":223},[211,2137,2138],{"class":313}," \"Monthly regional report FAILED\"\n",[211,2140,2141],{"class":74,"line":297},[211,2142,2143],{"class":227},"msg.set_content(\n",[211,2145,2146,2149,2152],{"class":74,"line":320},[211,2147,2148],{"class":313},"    \"The scheduled regional report did not complete.",[211,2150,2151],{"class":472},"\\n\\n",[211,2153,2154],{"class":313},"\"\n",[211,2156,2157,2160,2163,2165,2168,2171,2174,2177],{"class":74,"line":331},[211,2158,2159],{"class":223},"    f",[211,2161,2162],{"class":313},"\"Run log: ",[211,2164,473],{"class":472},[211,2166,2167],{"class":227},"os.environ[",[211,2169,2170],{"class":313},"'RUN_URL'",[211,2172,2173],{"class":227},"]",[211,2175,2176],{"class":472},"}\\n\\n",[211,2178,2154],{"class":313},[211,2180,2181],{"class":74,"line":360},[211,2182,2183],{"class":313},"    \"Last month's published report is unchanged.\"\n",[211,2185,2186],{"class":74,"line":365},[211,2187,357],{"class":227},[211,2189,2190],{"class":74,"line":371},[211,2191,270],{"emptyLinePlaceholder":269},[211,2193,2194,2197,2200,2203,2206,2209,2211,2214,2216,2218,2220,2222],{"class":74,"line":382},[211,2195,2196],{"class":223},"with",[211,2198,2199],{"class":227}," smtplib.SMTP(os.environ[",[211,2201,2202],{"class":313},"\"SMTP_HOST\"",[211,2204,2205],{"class":227},"], ",[211,2207,2208],{"class":343},"587",[211,2210,347],{"class":227},[211,2212,2213],{"class":337},"timeout",[211,2215,310],{"class":223},[211,2217,1296],{"class":343},[211,2219,519],{"class":227},[211,2221,281],{"class":223},[211,2223,2224],{"class":227}," smtp:\n",[211,2226,2227],{"class":74,"line":407},[211,2228,2229],{"class":227},"    smtp.starttls()\n",[211,2231,2232,2235,2237,2240,2243],{"class":74,"line":430},[211,2233,2234],{"class":227},"    smtp.login(os.environ[",[211,2236,2108],{"class":313},[211,2238,2239],{"class":227},"], os.environ[",[211,2241,2242],{"class":313},"\"SMTP_PASSWORD\"",[211,2244,2245],{"class":227},"])\n",[211,2247,2248],{"class":74,"line":436},[211,2249,2250],{"class":227},"    smtp.send_message(msg)\n",[10,2252,2253,2254,18],{},"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 ",[14,2255,2257],{"href":2256},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","error handling and logging in Excel automation",[10,2259,2260],{},"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.",[183,2262,2264],{"id":2263},"common-pitfalls-and-fixes","Common pitfalls and fixes",[2266,2267,2268,2284],"table",{},[2269,2270,2271],"thead",{},[2272,2273,2274,2278,2281],"tr",{},[2275,2276,2277],"th",{},"Symptom",[2275,2279,2280],{},"Cause",[2275,2282,2283],{},"Fix",[2285,2286,2287,2299,2310,2324,2337,2352,2363,2377],"tbody",{},[2272,2288,2289,2293,2296],{},[2290,2291,2292],"td",{},"Job runs an hour off",[2290,2294,2295],{},"Cron is UTC, no timezone option",[2290,2297,2298],{},"Schedule for the offset you need; compute periods in code.",[2272,2300,2301,2304,2307],{},[2290,2302,2303],{},"Run starts minutes late",[2290,2305,2306],{},"Schedules are best-effort",[2290,2308,2309],{},"Use an odd minute; never timestamp from the trigger.",[2272,2311,2312,2315,2318],{},[2290,2313,2314],{},"Worked yesterday, broke today",[2290,2316,2317],{},"Unpinned dependency updated",[2290,2319,2320,2321,18],{},"Pin every version in ",[198,2322,2323],{},"requirements.txt",[2272,2325,2326,2329,2332],{},[2290,2327,2328],{},"Empty artifact, run passed",[2290,2330,2331],{},"No files matched the path",[2290,2333,2334,2335,18],{},"Set ",[198,2336,1264],{},[2272,2338,2339,2342,2345],{},[2290,2340,2341],{},"Secret appears in the log",[2290,2343,2344],{},"Echoed or interpolated into output",[2290,2346,2347,2348,2351],{},"Use ",[198,2349,2350],{},"secrets.*"," as env vars; never print them.",[2272,2353,2354,2357,2360],{},[2290,2355,2356],{},"Scheduled runs stopped",[2290,2358,2359],{},"Repository inactive for 60 days",[2290,2361,2362],{},"Keep it active, or re-enable the workflow.",[2272,2364,2365,2368,2371],{},[2290,2366,2367],{},"PDF fonts look wrong",[2290,2369,2370],{},"Fonts missing on the runner",[2290,2372,2373,2376],{},[198,2374,2375],{},"apt-get install"," the font packages.",[2272,2378,2379,2382,2385],{},[2290,2380,2381],{},"Job runs for hours",[2290,2383,2384],{},"No timeout",[2290,2386,2334,2387,18],{},[198,2388,1240],{},[183,2390,2392],{"id":2391},"performance-and-scale-notes","Performance and scale notes",[10,2394,2395],{},"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.",[10,2397,2398,2401,2402,2404,2405,2408],{},[1228,2399,2400],{},"Caching pip is the single biggest win."," ",[198,2403,1248],{}," on ",[198,2406,2407],{},"setup-python"," typically cuts a forty-second install to under five.",[10,2410,2411,2414],{},[1228,2412,2413],{},"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:",[191,2416,2419],{"className":2417,"code":2418,"language":57,"meta":196},[194],"# requirements.txt — what the report needs\npandas==2.2.3\nopenpyxl==3.1.5\nXlsxWriter==3.2.0\n\n# requirements-dev.txt — only for CI on pull requests\n-r requirements.txt\npytest==8.3.3\nruff==0.7.4\n",[198,2420,2418],{"__ignoreMap":196},[10,2422,2423,2426],{},[1228,2424,2425],{},"Use a matrix for a fan-out."," A report per region runs the regions in parallel across runners rather than sequentially in one:",[191,2428,2430],{"className":849,"code":2429,"language":851,"meta":196,"style":196},"    strategy:\n      max-parallel: 4\n      matrix:\n        region: [north, south, west, east]\n    steps:\n      # ...\n      - run: python build_report.py --region ${{ matrix.region }}\n",[198,2431,2432,2439,2449,2456,2483,2489,2494],{"__ignoreMap":196},[211,2433,2434,2437],{"class":74,"line":213},[211,2435,2436],{"class":863},"    strategy",[211,2438,764],{"class":227},[211,2440,2441,2444,2446],{"class":74,"line":220},[211,2442,2443],{"class":863},"      max-parallel",[211,2445,609],{"class":227},[211,2447,2448],{"class":343},"4\n",[211,2450,2451,2454],{"class":74,"line":231},[211,2452,2453],{"class":863},"      matrix",[211,2455,764],{"class":227},[211,2457,2458,2461,2463,2466,2468,2471,2473,2476,2478,2481],{"class":74,"line":239},[211,2459,2460],{"class":863},"        region",[211,2462,388],{"class":227},[211,2464,2465],{"class":313},"north",[211,2467,347],{"class":227},[211,2469,2470],{"class":313},"south",[211,2472,347],{"class":227},[211,2474,2475],{"class":313},"west",[211,2477,347],{"class":227},[211,2479,2480],{"class":313},"east",[211,2482,566],{"class":227},[211,2484,2485,2487],{"class":74,"line":253},[211,2486,984],{"class":863},[211,2488,764],{"class":227},[211,2490,2491],{"class":74,"line":266},[211,2492,2493],{"class":216},"      # ...\n",[211,2495,2496,2498,2500,2502],{"class":74,"line":273},[211,2497,991],{"class":227},[211,2499,118],{"class":863},[211,2501,609],{"class":227},[211,2503,2504],{"class":313},"python build_report.py --region ${{ matrix.region }}\n",[10,2506,2507,2510],{},[198,2508,2509],{},"max-parallel"," is worth setting: without it a large matrix consumes every available runner, delaying other work in the organisation.",[10,2512,2513,2514,2517],{},"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 ",[198,2515,2516],{},"retention-days"," to the shortest window that is actually useful, and treat published storage as the durable copy.",[10,2519,2520,2521,2524],{},"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 ",[14,2522,2523],{"href":16},"parent topic"," are the right tools there.",[183,2526,2528],{"id":2527},"conclusion","Conclusion",[10,2530,2531,2532,2534,2535,2537],{},"GitHub Actions runs a scheduled Excel report without a server, and the workflow is short: checkout, ",[198,2533,2407],{}," 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 ",[198,2536,1232],{}," 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.",[183,2539,2541],{"id":2540},"frequently-asked-questions","Frequently asked questions",[10,2543,2544,2547],{},[1228,2545,2546],{},"What timezone does the GitHub Actions cron use?","\nUTC, 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.",[10,2549,2550,2553],{},[1228,2551,2552],{},"Why did my scheduled run start late?","\nScheduled 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.",[10,2555,2556,2559,2560,2563],{},[1228,2557,2558],{},"How do I store SMTP or cloud credentials?","\nAs repository or environment secrets, referenced in the workflow as ",[198,2561,2562],{},"secrets.NAME"," and injected as environment variables. They are masked in the logs and never appear in the repository.",[10,2565,2566,2569],{},[1228,2567,2568],{},"Can I convert to PDF in a runner?","\nYes. 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.",[10,2571,2572,2575],{},[1228,2573,2574],{},"How do I get the workbook out of the run?","\nUpload 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.",[183,2577,2579],{"id":2578},"related","Related",[2581,2582,2583,2590,2597,2604,2610],"ul",{},[2584,2585,2586,2587,2589],"li",{},"Up to the parent: ",[14,2588,17],{"href":16}," — the server-based alternatives.",[2584,2591,2592,2596],{},[14,2593,2595],{"href":2594},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler\u002F","Schedule Recurring Excel Reports with APScheduler"," — scheduling inside a long-running process.",[2584,2598,2599,2603],{},[14,2600,2602],{"href":2601},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-python-excel-script-on-windows-task-scheduler\u002F","Run a Python Excel Script on Windows Task Scheduler"," — the Windows equivalent.",[2584,2605,2606,2609],{},[14,2607,2608],{"href":1845},"Publishing Excel Reports to Cloud Storage"," — where the workflow should send its output.",[2584,2611,2612,2616],{},[14,2613,2615],{"href":2614},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002F","Package a Python Excel Script as an .exe with PyInstaller"," — the other end of the distribution spectrum.",[2618,2619,2620],"style",{},"html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sx1sP, html code.shiki .sx1sP{--shiki-default:#22863A;--shiki-dark:#72F088}",{"title":196,"searchDepth":220,"depth":220,"links":2622},[2623,2624,2625,2626,2627,2628,2629,2630,2631,2632,2633],{"id":185,"depth":220,"text":186},{"id":845,"depth":220,"text":846},{"id":1268,"depth":220,"text":1269},{"id":1566,"depth":220,"text":1567},{"id":1695,"depth":220,"text":1696},{"id":1849,"depth":220,"text":1850},{"id":2263,"depth":220,"text":2264},{"id":2391,"depth":220,"text":2392},{"id":2527,"depth":220,"text":2528},{"id":2540,"depth":220,"text":2541},{"id":2578,"depth":220,"text":2579},"2026-08-15","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.","md",[2638,2640,2642,2644,2646],{"q":2546,"a":2639},"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.",{"q":2552,"a":2641},"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.",{"q":2558,"a":2643},"As repository or environment secrets, referenced in the workflow as secrets dot NAME and injected as environment variables. They are masked in the logs and never appear in the repository.",{"q":2568,"a":2645},"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 cache it or use a container image if that matters.",{"q":2574,"a":2647},"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.",{},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions",{"title":2651,"description":2652},"Schedule an Excel Report with GitHub Actions (Python)","Run a Python Excel report on a schedule in GitHub Actions: cron syntax in UTC, dependency caching, secrets, headless LibreOffice, uploading artifacts and failure alerts.","run-a-python-excel-report-in-github-actions","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002Findex","how-to","M8N1PVijS6T_4a2ACyP_KOU0ZbxoqUjEdrxwmni1M_g",[2658,2661],{"title":17,"path":2659,"stem":2660,"children":-1},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Findex",{"title":2602,"path":2662,"stem":2663,"children":-1},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-python-excel-script-on-windows-task-scheduler","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-python-excel-script-on-windows-task-scheduler\u002Findex",1786800028848]