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.

WorkWhere it belongsWhy
Lint, type check, unit tests, build of the changed targetPull requestBlocks the merge and finishes in minutes
Integration suite against real databases and brokersNightly, plus pull requests that touch the boundaryToo slow for every push, and most diffs cannot break it
Compatibility matrix over older runtimes and database versionsNightlyWide grid, rare breakage, no per-commit signal
Soak, fuzz, and long property runsNightly, time boxedFinds failures that need hours of execution to surface
Dependency audit and license scanNightly on the default branchFails on upstream changes rather than on your diff
Repeat runs that hunt flaky testsNightlyNeeds many executions of unchanged code
Base image and digest refreshNightly, opening a pull requestCovered by the base image update workflow
Packaging and publishing artifactsTag triggerBelongs 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 17 rather than 0, because GitHub names the start of every hour as a high load window for the schedule event and the run can start late (events that trigger workflows, checked on 2026-08-13).
  • cancel-in-progress: false keeps a long soak leg alive when a manual dispatch lands during the scheduled run. Cancelling a nightly loses the whole night.
  • fail-fast: false keeps 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 if on the test step is what makes workflow_dispatch useful: a manual run with leg: compat starts 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 the inputs context is empty on a scheduled run, so a condition written against inputs.leg alone 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 the github.event_name guard 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 labelOSvCPURAMUSD per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB$0.004
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB$0.016
warp-ubuntu-latest-x64-16xUbuntu 24.041664 GB$0.032
warp-ubuntu-latest-arm64-8xUbuntu 24.04832 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.

LineVolume per monthRateMonthly cost
Nightly matrix, 6 legs at 25 minutes4,500 minutes$0.016 per minute$72.00
Cache storage2.5 GB$0.20 per GB-month$0.50
Cache operations360 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 morningWithout a warm entryWith last night's entry
Cold install and build warm-up per run4.0 min0.3 min
40 branch runs160 min12 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.