Nightly Build Jobs on GitHub Actions
A nightly build runs the work that is too slow or too broad for a pull request check. Pick what belongs there, route failures to a person, and price the matrix.
Last verified:
A nightly build is a scheduled GitHub Actions workflow that runs the checks a pull request cannot afford to wait for: full integration suites, compatibility matrices, soak runs, and audits. It earns its minutes when the work it holds would either blow the review latency budget or catch failures that no single pull request can produce.
This guide sets the rule for what moves out of the pull request lane, fixes the two failure modes that make nightly runs decorative, gives a workflow with a schedule trigger, a manual fallback, and a failure notification, and prices a six leg nightly matrix at real rates. It sits under the guide to speeding up GitHub Actions builds, which covers the pull request lane itself.
Diagnosis
A nightly workflow fails quietly by design, because nobody is waiting on it. Four symptoms account for most broken ones.
Nobody reads the failures. GitHub sends notifications for a scheduled workflow to the user who created it, and to whoever last edited the cron syntax after that (notifications for workflow runs, checked on 2026-08-13). One person inherits every nightly alert, and a scheduled run has no pull request to annotate, so a red run sits in the Actions tab until someone opens it on purpose.
The nightly repeats the pull request. The same unit suite that already ran on merge runs again at 04:00 against the same commit. The minutes are real and the information is zero, which is the first thing to look for when a nightly bill grows.
Everything slow was dumped in it. A nightly that started as one integration suite becomes the place slow work goes to be forgotten. It grows past the 6 hour job limit and starts getting cancelled mid-run (GitHub Actions usage limits, checked on 2026-08-13), and the default job timeout of 360 minutes means a hung step burns the full window before anyone hears about it.
The schedule cannot be tested. The schedule event runs the workflow from the latest commit on the default branch, so a fix pushed to a branch produces no run and the loop is edit, merge, wait a day. The answer on running jobs on a schedule covers the cron mechanics, the UTC evaluation, and the delay behavior at the top of the hour.
Read duration and queue time at P75 and P90 per job in the Jobs report to see which legs actually grew (reports documentation). A nightly leg whose P90 doubled over a month is the one to split before the workflow hits the job limit.
Fix
Apply one decision rule. A job stays in the pull request lane when a failure should block the merge and the job finishes inside the review latency budget. Move it to the nightly workflow when either half of that is false. Everything that gates a merge stays where the merge happens.
| Work | Where it belongs | Why |
|---|---|---|
| Lint, type check, unit tests, build of the changed target | Pull request | Blocks the merge and finishes in minutes |
| Integration suite against real databases and brokers | Nightly, plus pull requests that touch the boundary | Too slow for every push, and most diffs cannot break it |
| Compatibility matrix over older runtimes and database versions | Nightly | Wide grid, rare breakage, no per-commit signal |
| Soak, fuzz, and long property runs | Nightly, time boxed | Finds failures that need hours of execution to surface |
| Dependency audit and license scan | Nightly on the default branch | Fails on upstream changes rather than on your diff |
| Repeat runs that hunt flaky tests | Nightly | Needs many executions of unchanged code |
| Base image and digest refresh | Nightly, opening a pull request | Covered by the base image update workflow |
| Packaging and publishing artifacts | Tag trigger | Belongs to release build workflows |
Send failures to a place people look. Add a notification job that depends on the matrix, runs on failure(), and opens an issue with a label your team triages. An issue survives the night, carries the run URL, and can be assigned. Gate the same job on github.event_name == 'schedule' when you want manual reruns to stay silent.
Give the schedule a manual twin. Put workflow_dispatch next to the schedule key so the same jobs run from any ref while you are debugging, and add a matrix input when you want to reproduce a single leg.
Time box every leg. Set timeout-minutes on the job rather than relying on the 360 minute default (workflow syntax, checked on 2026-08-13). A leg that stops finishing should fail inside 90 minutes so the next night still runs on schedule.
Let the nightly leave something behind. A scheduled run executes on the default branch, and a run may restore cache entries from its own ref and from the default branch (dependency caching reference, checked on 2026-08-13). A nightly that installs dependencies and saves the cache hands the next morning's first pull request run a current entry. The answer on caches across branches has the full scope grid.
Configuration
This workflow runs six legs on a schedule, keeps a manual trigger, saves the shared cache entry, and opens an issue when anything fails.
name: nightly
on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
inputs:
leg:
description: Single leg to rerun, or all
default: all
concurrency:
group: nightly
cancel-in-progress: false
permissions:
contents: read
jobs:
suite:
name: nightly-${{ matrix.leg }}
runs-on: warp-ubuntu-latest-x64-8x
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
leg: [integration-1, integration-2, integration-3, integration-4, compat, soak]
steps:
- uses: actions/checkout@v5
- uses: WarpBuilds/cache@v1
with:
path: |
node_modules
.cache/build
key: nightly-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
nightly-${{ runner.os }}-
- run: bun install --frozen-lockfile
- run: bun run test:${{ matrix.leg }}
if: github.event_name != 'workflow_dispatch' || inputs.leg == 'all' || inputs.leg == matrix.leg
notify:
needs: [suite]
if: failure() && github.event_name == 'schedule'
runs-on: warp-ubuntu-latest-x64-2x
permissions:
issues: write
steps:
- name: Open a nightly failure issue
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue create --repo "$GITHUB_REPOSITORY" \
--title "Nightly build failed on $(date -u +%Y-%m-%d)" \
--label nightly-failure \
--body "Run: $RUN_URL"Six details carry the design.
- The cron minute is
17rather than0, because GitHub names the start of every hour as a high load window for thescheduleevent and the run can start late (events that trigger workflows, checked on 2026-08-13). cancel-in-progress: falsekeeps a long soak leg alive when a manual dispatch lands during the scheduled run. Cancelling a nightly loses the whole night.fail-fast: falsekeeps every leg reporting. A nightly exists to produce a list of what is broken, and the default cancels the siblings after the first failure.- The
ifon the test step is what makesworkflow_dispatchuseful: a manual run withleg: compatstarts all six jobs and executes only the one you named, which is cheap because the other five stop after checkout and restore. The event check comes first because theinputscontext is empty on a scheduled run, so a condition written againstinputs.legalone would skip every leg at 04:17. - The notify job depends on the matrix and runs on
failure(), so it fires once for the run rather than once per leg, and thegithub.event_nameguard keeps manual debugging out of the issue tracker. - The cache step writes on the default branch, which is the scope every branch can read the next morning. WarpBuild Cache entries expire 7 days after their last use and a restore counts as a use (caching documentation), so the nightly also keeps the shared entry alive through a quiet week.
The labels in this file come from the cloud runners documentation, checked on 2026-08-13. A compatibility leg that needs a different platform is a label change inside the same matrix.
runs-on label | OS | vCPU | RAM | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | $0.004 |
warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | $0.016 |
warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | $0.032 |
warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | $0.012 |
Cost or Time Model
Price the nightly as a fixed monthly line, because it runs the same shape every night whether or not anyone pushed code.
Assumptions: six legs, 25 minutes each, on warp-ubuntu-latest-x64-8x, 30 nights a month, one restore and one save per leg, and a 2.5 GB entry held on the default branch. Substitute the durations your Jobs report shows.
| Line | Volume per month | Rate | Monthly cost |
|---|---|---|---|
| Nightly matrix, 6 legs at 25 minutes | 4,500 minutes | $0.016 per minute | $72.00 |
| Cache storage | 2.5 GB | $0.20 per GB-month | $0.50 |
| Cache operations | 360 operations | $0.0001 each | $0.04 |
| Total | $72.54 |
The same 4,500 minutes on a GitHub-hosted runner of the same shape bill $99.00. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, with the GitHub list price checked on 2026-08-13 (GitHub Actions billing reference). That is $27.00 a month of difference on one nightly workflow, before the cache line below.
Now count what the nightly gives back the next morning. The run writes its cache entry on the default branch, so every branch created after it restores that entry on the first try instead of installing cold.
| Next morning | Without a warm entry | With last night's entry |
|---|---|---|
| Cold install and build warm-up per run | 4.0 min | 0.3 min |
| 40 branch runs | 160 min | 12 min |
| Minutes at $0.016 | $2.56 | $0.19 |
| Over 22 working days | $56.32 | $4.18 |
The nightly costs $72.54 a month and removes about $52 a month of repeated setup on branch runs, plus roughly 148 minutes a day of engineer waiting. That trade holds while the entry stays current, which is why the save step belongs in the nightly rather than in a job that only runs on merges.
Full rates by runner type are on the pricing page.
FAQ
What belongs in a nightly build rather than in a pull request check?
Keep a job in the pull request lane when a failure should block the merge and the job finishes inside the review latency budget. Move it to the nightly workflow when either half of that is false, which covers full integration suites, compatibility matrices against older runtimes, soak and fuzz runs, dependency and license audits, and repeat runs that hunt flaky tests. The table above lists the common cases and where each one lands.
Who gets notified when a nightly GitHub Actions run fails?
GitHub sends notifications for a scheduled workflow to the user who created the workflow, and to whoever last edited the cron syntax after that (notifications for workflow runs, checked on 2026-08-13). A scheduled run has no pull request to annotate, so add an explicit notification job that opens an issue or posts to a channel, gate it on failure(), and gate it on the schedule event when manual reruns should stay silent.
Does a nightly run make the next morning's builds faster?
It can, because a scheduled run executes on the default branch and every branch may restore entries written there. A nightly job that installs dependencies and saves the cache leaves a current entry for the first pull request run of the day, and the restore also resets the 7 day expiry on WarpBuild Cache entries. The scope rules are in the answer on caches across branches.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.