Wesley Dean
Moving MegaLinter Off GitHub Actions and What Actually Made it Faster image

Moving MegaLinter Off GitHub Actions and What Actually Made it Faster

10 min read

I did not set out to investigate CI architecture. I set out to understand why I had burned through roughly 3,000 GitHub Actions minutes in about five days.

The usage came from three private repositories, one of which was under active development. My development workflow contributed significantly to the volume. I use GitHub issues to describe and track individual work items, and an automation creates a branch and an associated draft pull request whenever I create an issue. I like this arrangement because the draft pull request gives me a natural place to accumulate work, comments, commits, and related context while the issue is still underway. The arrangement had an unintended consequence: MegaLinter was running against those draft pull requests as I pushed incremental changes.

The workflow was effectively this:

An issue creates a branch and draft pull request; incremental pushes repeatedly trigger MegaLinter until the pull request is ready for review

That behavior makes sense for a pull request that is ready for review. It makes less sense for a draft pull request whose primary purpose is to provide a workspace for something that may change ten more times before anyone needs a complete CI result. Because I use issues aggressively to describe and organize work, the number of runs accumulated faster than I expected.

GitHub includes 3,000 Actions minutes per month with the plans I was using for these private repositories. As of September 2026, GitHub lists the baseline actions_linux runner at $0.006 per minute. My included allowance absorbed those 3,000 minutes, so I did not receive an $18 bill for five days of development. The equivalent paid usage was still useful for understanding the scale of what I was consuming.

3,000 minutes x $0.006/minute = $18.00

If I repeated that five-day development pattern across a 25-workday month, the comparison becomes more interesting:

3,000 minutes / 5 days = 600 minutes/day

600 minutes/day x 25 workdays = 15,000 minutes

15,000 minutes x $0.006/minute = $90.00/month

A straight 30-calendar-day extrapolation would be $108. I prefer the $90 figure because I am treating this as an approximate working-month comparison rather than pretending that five unusually active days constitute a reliable billing forecast. Either way, I was spending enough compute, and consuming it wastefully enough, that the problem was worth investigating.

GitHub’s current billing details are available here:

github-actions#paying-for-additional-github-actions-use

Moving the compute gave me control

I already own machines capable of doing this work, so one of the first changes was to move much of my MegaLinter execution from GitHub-hosted runners to Jenkins running on my own infrastructure.

I did not want to replace GitHub as the collaboration layer. My repositories, issues, pull requests, and build status still belong there. I wanted Jenkins to become the compute layer underneath that workflow. Jenkins watches the GitHub repositories that participate in this CI model, repositories opt in by containing a Jenkinsfile, and a Jenkins node with Docker available ultimately invokes:

make lint

The Makefile remains responsible for running MegaLinter. That boundary matters to me: Jenkins knows that a repository should be linted and provides the environment in which the job runs; the repository knows how to lint itself.

Moving the workload to Jenkins did not make MegaLinter faster by itself; initially, it made the situation considerably worse. A representative MegaLinter run on GitHub Actions had taken roughly 170 seconds. When I first moved the same general workload onto Jenkins, the runtime climbed to approximately 660 seconds. That was not the result I had been hoping for, although it was useful evidence: owning the compute did not eliminate the costs associated with acquiring large container images, preparing scanner databases, and starting each run from a cold state.

The progression eventually looked approximately like this:

Execution environmentRuntime
GitHub-hosted execution~170 seconds
Initial Jenkins execution~660 seconds
Partially optimized Jenkins~224 seconds
Optimized Jenkins execution~75 seconds

The final 75-second result came from several optimizations that became practical once I controlled the execution environment, particularly caching the MegaLinter container image and preserving scanner state between otherwise disposable runs. It does not establish that Jenkins is intrinsically faster than GitHub Actions.

If I could reproduce the same caching behavior efficiently on GitHub-hosted runners, I have no evidence that Jenkins would still be faster; GitHub might even outperform my local infrastructure. The advantage I gained from Jenkins was control over persistence, locality, and the lifecycle of expensive state.

The MegaLinter v10.1.0 image I was using was approximately 4.03 GB. Repeatedly pulling and unpacking a multi-gigabyte image is substantial work regardless of how small the repository being analyzed happens to be. On infrastructure I control, I could keep that image local and place the upstream registry behind a repository proxy. Later, the same principle became even more important for Grype’s vulnerability database.

As those costs were removed one by one, the overall runtime fell from roughly 660 seconds to about 224 seconds and eventually to approximately 75 seconds. A controlled environment gave me more opportunities to keep expensive state nearby rather than reconstructing it during every job, which was the more useful lesson.

Keeping the repository independent of my infrastructure

Moving the work to Jenkins raised another question: how much should a repository know about the infrastructure that happens to run its CI job?

The most direct implementation would have been to hardcode my environment into the Makefile. I could point the MegaLinter image at my local registry proxy, point scanners at local mirrors, and add Docker volume names for persistent caches. That would work on my Jenkins workers. It would also make the repository dependent upon my network.

Someone cloning one of my repositories should not discover that:

make lint

requires access to my registry, my Nexus server, my Docker volume naming conventions, or any other part of my private CI environment.

The repository should know how to lint itself. It should not need to know where I happen to run the lint job.

I therefore split the configuration into two layers. The Makefile provides portable defaults:

LINT_REGISTRY ?= ghcr.io
LINT_IMAGE ?= oxsecurity/megalinter
LINT_TAG ?= latest

LINTER ?= $(LINT_REGISTRY)/$(LINT_IMAGE):$(LINT_TAG)

LINT_GRYPE_DB_URL ?= https://grype.anchore.io/databases
LINT_GRYPE_CACHE_VOLUME ?=
LINT_GRYPE_CACHE_DIR ?= /var/cache/grype/db

The significant operator is ?=. GNU Make assigns those values only when something else has not already supplied them. A developer can therefore clone the repository, run make lint, and receive the public MegaLinter image and public Grype database endpoint without knowing anything about my infrastructure.

My Jenkins environment supplies different values through a Jenkins Organization Folder, which I also use to discover the repositories participating in this CI model. It gives me a convenient place to define organization-level properties such as:

LINT_REGISTRY
LINT_IMAGE
LINT_TAG
LINT_GRYPE_DB_URL
LINT_GRYPE_CACHE_VOLUME

The actual private values do not live in the repository. Jenkins injects them into the job environment, and the Jenkinsfile enables those folder properties before invoking the Makefile:

pipeline {
  agent { label 'docker' }

  options {
    withFolderProperties()
  }

  stages {
    stage('Lint') {
      steps {
        sh 'make lint'
      }
    }
  }
}

From Make’s perspective, those variables already exist, so its portable defaults are never assigned. This also lets make lint remain the canonical entry point. A developer can reproduce the operation locally, another CI platform can call the same target, and Jenkins does not need to understand the details of MegaLinter’s Docker invocation. Changes to linting behavior stay with the repository instead of becoming scattered through Jenkins configuration.

Jenkins supplies policy and environment; the Makefile supplies behavior.

There is a tradeoff. Someone looking only at the Makefile sees ghcr.io and https://grype.anchore.io/databases, while someone looking at the Jenkins console sees the private endpoints supplied by the environment. I encountered that ambiguity myself while reviewing the work later and briefly wondered where some configuration had gone. Nothing had disappeared; I had intentionally moved environment-specific policy outside the repository.

I judge that to be the better boundary. My infrastructure can improve execution without becoming a prerequisite for using the project. If the Nexus server disappears, the public Grype endpoint still exists. If the registry proxy is unavailable, the public MegaLinter image remains the default. If no persistent cache exists, the scanner still runs. The optimized environment improves the repository’s execution without determining whether the repository can execute at all.

The CI job that should not have run yet

There is another lesson in this investigation that has little to do with the choice between GitHub Actions and Jenkins.

I have a book coming out shortly titled Getting Started with MegaLinter, and one of its chapters is devoted entirely to optimizing MegaLinter runs. It discusses reducing unnecessary work, understanding where execution time goes, and making CI feedback faster and less expensive.

This experience reinforced much of that material. It also exposed something I would add if the manuscript had not already gone to the publisher: the most wasteful CI job may be the one that did not need to run yet.

My issue workflow automatically creates a branch and a draft pull request for each work item. Those draft pull requests may receive many incremental pushes while I am still developing an idea. Triggering the full MegaLinter path on each of those pushes means spending compute to answer a question I may not care about yet.

If I were configuring that workflow today, I would consider beginning the expensive remote CI path when a pull request moves into the “ready for review” state rather than when the draft pull request first exists. That does not mean abandoning validation while development is underway. make lint remains available locally, individual linters can run independently, and pre-commit checks or smaller validation targets can provide rapid feedback while the work changes. The distinction concerns when the full CI system needs to spend its resources.

Caching a four-gigabyte image, preserving scanner databases, moving compute onto infrastructure where I can control persistence and locality, and optimizing individual linters can all reduce the cost of CI execution. Avoiding an unnecessary execution consumes zero compute.

That lesson arrived after the first edition of Getting Started with MegaLinter was already finished, so it seems destined for the second.

What actually made it faster

The numbers tell a less convenient and more useful story than “Jenkins is faster than GitHub Actions.”

My representative run started around 170 seconds on GitHub Actions. The first Jenkins implementation increased that to roughly 660 seconds. Progressive changes brought the runtime down to about 224 seconds and eventually to roughly 75 seconds. The platform migration began the experiment; the performance improvement came later.

What changed was my ability to control the environment around the workload. I could keep a four-gigabyte MegaLinter image near the workers, proxy upstream artifacts locally, and preserve expensive scanner state across disposable containers. Those choices fit naturally on infrastructure I control, although nothing in the measurements establishes that the same techniques could not make GitHub-hosted execution equally fast or faster if implemented well there.

Once the broad execution costs came down, one remaining tool became the next clear target. Grype was taking roughly three minutes to scan a tiny Bash project. Understanding why turned into a separate investigation of its own.

Posts in this Series

  1. Moving MegaLinter Off GitHub Actions and What Actually Made it Faster

Tags