Skip to content
Stribog

CI/CD

All writing

GitHub Actions vs Jenkins: Renting or Owning Your CI

GitHub Actions vs Jenkins is a choice of who runs the CI control plane. Compare self-hosted runners, workflow portability, secrets, caches and exit cost.

Stribog13 min read

Most comparisons of the two tools are feature checklists: marketplace size, syntax, plugin counts. Those matter less than they look. A CI system is the one piece of infrastructure that holds credentials to everything it deploys to, and it is usually the system most likely to be rewritten during a platform move and least likely to have been designed for one. This article compares GitHub Actions and Jenkins on the axis that decides how hard that rewrite will be: which layers you own, which you rent, and what a migration in either direction actually costs once secrets, caches and runners are counted. Figures are from each project's documentation as of October 2026.

The question is who runs the control plane

Every CI system has four layers, and it helps to name them before comparing products:

  1. Definition — the pipeline file: .github/workflows/*.yml or a Jenkinsfile. In both cases it lives in your repository, so you own it.
  2. Control plane — the scheduler: it receives events, queues jobs, assigns them to machines, stores logs and enforces policy.
  3. Compute — the machines that run steps: hosted runners, self-hosted runners, Jenkins agents, Kubernetes pods.
  4. State — secrets, dependency caches and artifacts that outlive a single job.

GitHub Actions rents you layers two and four, and lets you choose for layer three. Jenkins makes you own all four. That is the whole comparison in one sentence; everything below is what it means in practice. A self-hosted GitHub runner is often described as "owning your CI". It is owning your compute. The scheduler that decides whether your runner gets a job is still GitHub's, and so is the policy that decides which runner versions are allowed to receive one.

Both tools keep the pipeline definition in your repository. The difference is the control plane and the state: rented from GitHub, or owned and patched by you.

What renting looks like: GitHub Actions, hosted and self-hosted

The pricing model tells you where the control plane sits. GitHub's billing documentation says Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners. Private repositories get included minutes per month: 2,000 on GitHub Free and Free for organizations, 3,000 on Pro and Team, 50,000 on Enterprise Cloud. Beyond that, a Linux 2-core x64 runner is $0.006 per minute, Windows 2-core $0.010 and a 3- or 4-core macOS runner $0.062, with each job's partial minute rounded up. Cache storage is 10 GB per repository on every plan.

Hosted runners also come with hard limits. A job on a GitHub-hosted runner can run for at most 6 hours; on a self-hosted runner the ceiling is 5 days. A whole workflow run is capped at 35 days, and that period includes waiting and approval time, not only execution.

Self-hosted is free today. That is a policy, not a property

On 16 December 2025 GitHub announced two changes: hosted-runner prices cut by up to 39% from 1 January 2026, and a new $0.002 per minute "cloud platform charge" on self-hosted runner usage from 1 March 2026. It then postponed the self-hosted change "to take time to re-evaluate our approach", while keeping the hosted price cut. GitHub's changelog gives no new date as of writing, and the billing docs still say self-hosted usage is free. The point is not the number. It is that the price of running GitHub Actions on your own hardware is a lever GitHub holds, and has already reached for once.

Runner versions are GitHub's call

By default a self-hosted runner updates itself: when a new runner release ships, it updates when a job is assigned, or within a week if it has been idle. If you register it with config.sh --disableupdate to control upgrades yourself, the self-hosted runner reference says you must install each new version within 30 days; after that, GitHub stops queuing jobs to the runner. And when a critical security update is required, such a runner gets no jobs until it is updated.

That 30-day rule has teeth now. On GitHub Enterprise Cloud, full enforcement of the runner minimum began on 29 September 2026 (31 July 2026 for Enterprise Cloud with data residency; GitHub Enterprise Server is not affected). Runners below 2.329.0 can no longer register or reregister. But 2.329.0 is only the registration floor. GitHub's June 2026 timeline is explicit that a runner must install each new release within 30 days to keep executing jobs, so the effective minimum moves forward with every release, and a runner pinned to 2.329.0 that never updates will not pick up jobs. Auto-updating runners satisfy this as long as they can reach the update service, which matters for anything behind an egress allow-list.

This lands hardest on Kubernetes. Current Actions Runner Controller creates runner scale sets with RunnerSetting.DisableUpdate hardcoded to true in the controller. It is not a Helm value you can flip. Scale-set runners therefore never update themselves, and the runner image has to be rebuilt and rolled within every 30-day window. Stribog's ARC deep dive covers the image pipeline; the point here is that "self-hosted" includes a monthly obligation set by someone else. To see a version's schedule, an organization admin can call GET /orgs/{org}/actions/runners/deprecations/{version}, which returns registration_deprecates_at and runtime_deprecates_at. The runtime date may be null, so treat a missing value as "not yet announced", not as "safe forever".

What owning looks like: a Jenkins controller you patch

Jenkins core is MIT-licensed, and so, for what it is worth, is the GitHub Actions runner agent; the Actions service that schedules it is not open source. With Jenkins, the scheduler is yours, and so is every duty that comes with it.

  • An upgrade cadence. Every 12 weeks the community picks a recent weekly release as the baseline for the next LTS line. Skipping several lines turns a routine upgrade into a project.
  • A Java baseline. Jenkins publishes a Java support policy, and an unsupported Java version stops the controller from running. Java 17's last LTS was 2.541.3 (March 2026). Java 21 has been supported since LTS 2.479.1 (October 2024), with planned end of support in September 2027; Java 25 since LTS 2.541.1 (January 2026), planned end of support September 2029. Both end dates are plans, not guarantees.
  • Script approval. Jenkins Pipelines, Declarative and Scripted, run in the Script Security plugin's Groovy sandbox by default. A pipeline that calls a method the sandbox does not allow halts and does not run until an administrator approves that method signature. Someone owns that queue.
  • Ephemeral compute. The Kubernetes plugin creates one pod per agent and stops it after the build, with the agent connecting inbound to the controller. Clean builds, but every build starts with an empty cache unless something like the Job Cacher plugin restores one.

The controller is also an attack surface you expose. In January 2024 Jenkins disclosed CVE-2024-23897, rated critical: the CLI's argument parser expanded an @ followed by a file path into that file's contents, an arbitrary file read that the advisory says can lead to remote code execution. Attackers with Overall/Read permission could read entire files; those without it could read only the first few lines. The fix landed in Jenkins 2.442 and LTS 2.426.3 and 2.440.1; the interim workaround was to disable CLI access. On GitHub Actions that patch is GitHub's job. On Jenkins it is yours, and so is knowing within hours that you need it.

The way to make a controller survivable is to make it reproducible. The Configuration as Code plugin (JCasC) configures Jenkins from declarative YAML, located through CASC_JENKINS_CONFIG, so a controller can be rebuilt from Git rather than restored from someone's memory of the UI:

yaml
jenkins:
  systemMessage: "Managed by JCasC: change jenkins.yaml, not the UI."
  securityRealm:
    local:
      allowsSignup: false
      users:
        - id: admin
          password: ${JENKINS_ADMIN_PASSWORD}
  authorizationStrategy:
    loggedInUsersCanDoAnything:
      allowAnonymousRead: false
credentials:
  system:
    domainCredentials:
      - credentials:
          - string:
              scope: GLOBAL
              id: registry-token
              description: "Registry push token"
              secret: ${REGISTRY_TOKEN}
A minimal JCasC jenkins.yaml. ${VAR} values are resolved from the environment at load time, so no secret is committed alongside the file.

Pipeline definitions: how portable is each format?

Jenkins Pipeline has two syntaxes, Declarative and Scripted, both of which can live in a Jenkinsfile in source control. Declarative pipelines map onto GitHub workflows almost line for line. Here is a typical one: a pod-based agent, a test stage, and a publish stage that binds a credential.

groovy
pipeline {
  agent {
    kubernetes {
      yaml '''
        apiVersion: v1
        kind: Pod
        spec:
          containers:
            - name: build
              image: golang:1.25
              command: ["sleep"]
              args: ["infinity"]
      '''
      defaultContainer 'build'
    }
  }
  options {
    timeout(time: 30, unit: 'MINUTES')
  }
  stages {
    stage('Test') {
      steps {
        sh 'make test'
      }
    }
    stage('Publish') {
      when { branch 'main' }
      steps {
        withCredentials([string(credentialsId: 'registry-token', variable: 'REGISTRY_TOKEN')]) {
          sh 'make publish'
        }
      }
    }
  }
}
Declarative Jenkinsfile in a multibranch pipeline. The pod template, stage names and the registry-token credential ID are illustrative.

And the equivalent workflow. Note what changes and what does not: make test and make publish are identical, the Kubernetes agent block becomes a runs-on that names an Actions Runner Controller scale set, and withCredentials becomes an environment variable read from the secrets context.

yaml
name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  id-token: write # only if a step exchanges an OIDC token for cloud credentials

jobs:
  build:
    runs-on: arc-linux-amd64 # ARC runner scale set name (illustrative)
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod
          key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
      - run: make test
      - name: Publish
        if: github.ref == 'refs/heads/main'
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: make publish
Equivalent GitHub workflow on a self-hosted Actions Runner Controller scale set; `arc-linux-amd64` is an illustrative scale-set name. Do not substitute `[self-hosted, linux, x64]`: that array matches only a runner registered with `config.sh` and its default labels, and GitHub documents that ARC does not support the `self-hosted` label, so the job would queue and never run. Actions are pinned to full-length commit SHAs with the tag in a comment, the documented fallback for repositories that cannot adopt a lockfile. Assumes Go is preinstalled on the runner image.

GitHub ships a tool for this translation, the GitHub Actions Importer, installed as a gh CLI extension. Its Jenkins tutorial is candid about the boundary: it cannot automatically migrate mandatory build tools, scripted pipelines, secrets, self-hosted runners or unknown plugins, and those must be migrated by hand. Shared libraries are not on that list by name. Since they are Groovy loaded at run time, the safe engineering assumption is that they land in the manual pile too; that is judgement, not something the documentation states.

Portability is therefore asymmetric. A Declarative Jenkinsfile with standard steps translates mechanically. A Scripted pipeline full of Groovy logic is a rewrite. A workflow built from marketplace actions does not translate back to Jenkins at all; each action has to be replaced by a plugin or a script.

The migration bill: secrets, caches and runners

Syntax is the cheap part. The expensive parts are the state and the machines, and they cost the same in either direction.

Secrets you cannot read back

GitHub's REST API lists and gets Actions secrets "without revealing their encrypted values"; values go in encrypted with LibSodium and do not come back out. That is an inference from the API reference, not a documented statement: there is no export endpoint. Leaving GitHub means either re-entering each secret from its original source or writing a one-off workflow that reads each secret and pushes it into the new store, which runs secret values through a CI job, where one careless step can leak them past log masking, and is itself a reason to rotate afterwards. If the original source was a person's clipboard, the migration starts with a credential rotation. Arriving on GitHub has its own ceilings. You can store up to 1,000 organization secrets, 100 per repository and 100 per environment, each up to 48 KB, but if a repository is granted more than 100 organization secrets, a workflow sees only the first 100, sorted alphabetically by name. A flat import of a large Jenkins credential store can silently hide secrets from the jobs that need them.

Jenkins has the opposite trap. Credentials are encrypted with the controller key, secrets/hudson.util.Secret, which is itself encrypted with secrets/master.key. The Jenkins backup documentation says never to include the controller key in a regular backup, and to back up master.key separately in a very secure location. The page's restore paragraph names only reapplying master.key, but a restore of credentials needs both files put back. A JENKINS_HOME tarball without them restores a controller whose credentials cannot be decrypted; a tarball with them is a credential dump with no separation of duties.

bash
# Inventory before a Jenkins-to-Actions move. TARGET_REPO is owner/name.
# Prerequisite: Docker installed and running (the Importer runs as a container).
gh extension install github/gh-actions-importer

# Interactive: asks for a GitHub personal access token with the workflow scope,
# a Jenkins API token and the Jenkins username, and saves them for later commands.
gh actions-importer configure

# Pull or refresh the Importer container image (tutorial step 4).
gh actions-importer update

# Read-only audit of the Jenkins instance; writes a report you can diff.
gh actions-importer audit jenkins --output-dir tmp/audit

# Secret NAMES already on the target repository. Values are never returned.
gh secret list --repo "$TARGET_REPO"

# Back up JENKINS_HOME without the controller key or master.key,
tar --exclude='secrets/master.key' --exclude='secrets/hudson.util.Secret' \
  -czf jenkins-home.tgz -C "$JENKINS_HOME" .
# then keep those two files apart, somewhere more tightly controlled.
install -m 600 "$JENKINS_HOME/secrets/master.key" \
  "$JENKINS_HOME/secrets/hudson.util.Secret" /secure/jenkins-keys/
Inventory commands in the Importer tutorial's order, plus a JENKINS_HOME backup that keeps the controller key and master.key out of the main archive.

The durable answer in both directions is that the CI system should never be the source of truth for a secret. Keep them in an external store, OpenBao or Vault, and have CI fetch them at run time. For cloud access, GitHub's OIDC support lets a workflow request a short-lived token from the cloud provider instead of storing long-lived credentials as secrets at all; that is what the id-token: write permission in the workflow above is for.

Caches and runners

Caches do not migrate; they warm up. GitHub removes cache entries not accessed in over 7 days, and the default limit is 10 GB per repository. Admins can raise it, usage beyond 10 GB is billed, and at the limit eviction deletes the least recently accessed caches first. A monorepo with several toolchains can churn through that, so plan for slow first builds after a cutover and measure them, rather than assuming parity on day one. Runners and agents are re-provisioned, not moved: Jenkins agent images, labels and tool installations become runner images and labels, and on the GitHub side they then inherit the 30-day update duty described above.

The Importer handles syntax. Secrets, caches, runners, scripted pipelines and unknown plugins are the real bill, and the external store, registry and build entrypoint are what carry over unchanged.

Security and audit: what you show an auditor

The threat surfaces differ in shape. On GitHub Actions the largest one is third-party actions. In March 2025, the compromise of tj-actions/changed-files tracked as CVE-2025-30066 showed it: attackers retroactively pointed multiple version tags at a malicious commit that exposed CI secrets in workflow logs, and the advisory counts over 23,000 affected repositories between 14 and 15 March. Version tags are mutable; that is the lesson.

GitHub's secure-use reference now ranks the controls. Locking workflow dependencies with a lockfile, which records the exact commit of every action including transitive ones and verifies each source repository at run time, is "the strongest protection available" and supersedes SHA pinning for repositories that adopt it. For repositories that cannot, the fallback is pinning each action to a full-length commit SHA, as in the workflow above. The same page says self-hosted runners should almost never be used on public repositories, because anyone can open a pull request and compromise the environment.

On Jenkins the equivalent surface is the controller and its plugins: you choose which plugins to trust, you patch them, and CVE-2024-23897 shows what an exposed controller looks like. The broader supply-chain controls — SBOMs, signatures and admission policy — apply to both. The audit question is who produces the evidence. On GitHub, run logs and audit logs live in GitHub's system and you retrieve them through GitHub's interfaces. On Jenkins they live on infrastructure you control, which means you can retain them as long as your regulator requires, and also that nobody retains them unless you set it up.

Exit ramps and the long game

Whichever you choose, the decision should stay reversible. Four patterns do most of the work:

  • Keep build logic out of the CI file. The pipeline should call a Makefile, scripts or a container, never contain the logic itself. Then a migration rewrites a thin wrapper, not the build.
  • Keep secrets in an external store that both systems can read, managed through something like External Secrets Operator on the cluster side.
  • Keep artifacts and images in a [registry you run](/blog/harbor-zot-self-hosted-container-registry-supply-chain-replication), so build outputs do not live in the CI system's artifact store.
  • Do not assume a compatible third option. Forgejo Actions is designed to be familiar to GitHub Actions users but explicitly not compatible; moving a workflow usually needs some tweaking. The Forgejo and Woodpecker route is real, but it is a port, not a copy.
makefile
# The only build logic either CI is allowed to know about.
REGISTRY_HOST ?= registry.example.internal
IMAGE         ?= $(REGISTRY_HOST)/platform/app
VERSION       ?= $(shell git describe --tags --always --dirty)

.PHONY: test build publish

test:
	go test ./...

build:
	CGO_ENABLED=0 go build -o bin/app ./cmd/app

publish: build
	@printf '%s' "$$REGISTRY_TOKEN" | oras login $(REGISTRY_HOST) -u ci --password-stdin
	oras push $(IMAGE):$(VERSION) bin/app
The CI-agnostic entrypoint both pipelines above call. Swapping CI changes the wrapper, not this file. Registry host and paths are illustrative; assumes oras is on the build image.

Seen over a decade, CI tends to be replaced when the forge or vendor it was coupled to changes, and usually under deadline. The forge itself is a separate rent-or-own question. Price the exit before you need it: Stribog's exit-cost model applies directly: inventory CI's secrets, caches and runner images, then price them against its four terms, which are egress, proprietary API surface, growth and human cost in engineer-days.

Decision: which one, for whom

  • Choose GitHub Actions if your code already lives on GitHub, nobody on the team wants to be on call for a CI controller, and you accept GitHub's runner-version policy and pricing as a dependency you track.
  • Choose Jenkins if you need the control plane on your own infrastructure for regulatory or air-gap reasons, and you can staff the LTS upgrades, Java moves, plugin patching and script approvals.
  • Either way, keep build logic, secrets and artifacts outside the CI system, so that the next move is a wrapper rewrite rather than a rebuild.

§FAQ/Common questions

Frequently asked

Is GitHub Actions better than Jenkins?

Neither is better in general. GitHub Actions rents you the control plane, secrets storage and cache, and removes the controller from your on-call rota. Jenkins makes you own and patch all of it, which gives you full control over where it runs and how long its records are kept. Choose based on who is on call and what you must be able to leave.

Do self-hosted GitHub runners mean I own my CI?

They mean you own the compute. GitHub still schedules jobs, stores secrets and caches, and sets runner-version policy: runners with auto-update disabled must install each new release within 30 days or stop receiving jobs, and on GitHub Enterprise Cloud runners below 2.329.0 cannot register since 29 September 2026.

Can the GitHub Actions Importer migrate everything from Jenkins?

No. Its Jenkins documentation lists mandatory build tools, scripted pipelines, secrets, self-hosted runners and unknown plugins as constructs that must be migrated manually. Declarative pipelines with standard steps convert most cleanly.

How do I back up Jenkins credentials safely?

Credentials are encrypted with the controller key in secrets/hudson.util.Secret, which is encrypted with secrets/master.key. Jenkins documentation says to keep the controller key out of regular backups and to back up master.key separately in a secure location. Restoring credentials needs both files.

github actions vs jenkinsmigrate jenkins to github actionsgithub actions importer jenkins limitationsjenkins controller backup master.keygithub self-hosted runner update 30 daysgithub actions cache limit 10 GB eviction

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.