How to Make CI Scripts Portable Between Providers Properly
Make CI scripts portable between providers: keep logic in scripts, thin YAML, no provider variables inside, a local runner and the cost of lock-in.

Pipelines written entirely in one provider's YAML cannot be tested without pushing a commit and cannot move without a rewrite. The fix is a separation that every maintainable pipeline ends up with: the CI file is glue, and the work lives in scripts that run anywhere. That also means the developer's laptop runs the same checks as the server. The idea is in CI scripts in the repository, and the examples assume Bash 5.2 and the three most common providers.
Thin YAML, fat script
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./ci/run.sh test# .gitlab-ci.yml
test:
image: debian:13
script:
- ./ci/run.sh test// Jenkinsfile
pipeline {
agent any
stages { stage('test') { steps { sh './ci/run.sh test' } } }
}
The three files say when to run and on what image, and then call the same command. A change to the build touches one script, reviewed in one place. The Jenkins example is written as plain text, since Groovy is not on the editor's language list.
One script with commands
#!/usr/bin/env bash
# ci/run.sh
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
cmd_lint() { shellcheck scripts/*.sh; }
cmd_test() { bats tests/; }
cmd_build() { docker build -t "app:${CI_COMMIT_ID}" .; }
case "${1:-}" in
lint|test|build) "cmd_$1" ;;
all) cmd_lint; cmd_test; cmd_build ;;
*) echo "usage: $0 {lint|test|build|all}" >&2; exit 2 ;;
esacRun ./ci/run.sh all on a laptop and you have what CI will do. Using a Makefile or a task runner for the same entry points works equally well, as in Makefile automation. Always start with strict mode, set -euo pipefail, as described in failing fast in CI.
An adapter for provider variables
# ci/env.sh: map each provider's variables to neutral names
if [[ -n ${GITHUB_ACTIONS:-} ]]; then
CI_COMMIT_ID=$GITHUB_SHA; CI_BRANCH=${GITHUB_REF_NAME}
elif [[ -n ${GITLAB_CI:-} ]]; then
CI_COMMIT_ID=$CI_COMMIT_SHA; CI_BRANCH=$CI_COMMIT_REF_NAME
elif [[ -n ${JENKINS_URL:-} ]]; then
CI_COMMIT_ID=${GIT_COMMIT}; CI_BRANCH=${BRANCH_NAME}
else
CI_COMMIT_ID=$(git rev-parse HEAD); CI_BRANCH=$(git branch --show-current)
fi
export CI_COMMIT_ID CI_BRANCHProvider variable names (GITHUB_SHA, CI_COMMIT_SHA, GIT_COMMIT) appear in exactly one file. The rest of the scripts use neutral names and work locally, where the final branch of the if reads Git directly. Nearly every provider sets CI=true, which scripts can use to turn off colours and prompts.
What cannot be portable
Caching, artifacts, secrets, matrix builds and service containers are provider features with different syntax; keep them in the YAML and keep the script ignorant of them. Cache directories can be passed in as environment variables. Secrets arrive as environment variables or files in every provider, so scripts read them from the environment and never from a provider-specific store. The syntax for YAML-level features is in GitHub Actions matrix and cache and GitLab rules and cache.
Keeping provider syntax thin
Every CI provider has its own syntax for triggers, caching, artifacts, matrices and secrets, and none of it is portable. The way to limit the lock-in is to keep that syntax to the minimum needed to start a job: check out the code, set up the runtime, restore a cache and call one script. All decisions, such as which tests run, how the build is versioned and what gets published, live in scripts in the repository. Moving to another provider then means rewriting a few dozen lines of configuration, not the pipeline's logic. It also means developers run the same scripts locally, so a failure in CI can be reproduced at a desk.
Resist the temptation of provider-specific actions or plugins for steps the script could do itself. A marketplace action is convenient and can change under you, and its behaviour cannot be tested outside the provider.
What does not port
Some features resist abstraction. Caching keys and restore semantics differ, and so do artifact storage, service containers, concurrency controls, environment protection rules and the way secrets are exposed. Treat these as part of the adapter layer: write the script to take paths and values from environment variables, and let each provider's file map its own mechanism onto those names. A script that reads CI_BRANCH and CI_RUN_ID from variables set by a small mapping step does not care whether it runs on GitHub Actions or GitLab.
Be honest about which parts you will not move. If the deployment relies on a provider's OIDC federation to a cloud, that integration is provider-specific, and the plan should record it as a piece to rebuild during a migration.
Local reproducibility
Running the pipeline locally is the best test of portability. Provide one command, through a Makefile or task runner, for each stage: lint, test, build and package. Run them in a container image that matches the CI environment, pinned to a version, so the toolchain does not drift. A developer can then run the exact job with the same image and the same script. Where the build needs secrets, document how to supply test versions locally, and never require production credentials to run a test.
Use tools that detect problems early: ShellCheck for the scripts and a workflow linter for the provider files. Both are cheap to run in the pipeline and catch quoting mistakes and syntax slips before they cost a run.
Migration without a freeze
When you do change providers, run both for a while. Mirror the pipeline on the new system, compare results on the same commits and switch required checks once they agree. Because the scripts are shared, the work is the adapter, and a failing difference is a real behavioural difference worth understanding. Keep the old configuration in the repository history, remove it after the switch and update the contribution guide. Teams that keep logic in scripts usually find the migration takes days, where teams with deep provider syntax find it takes weeks and carries risk. The investment is repaid whenever pricing, features or policy change, which they eventually do.
Finally, review the pipeline files regularly for logic that has crept in. A conditional that decides which tests to run, a version computed inline or a deployment decision encoded in YAML conditions is easy to add and hard to move later. When you see one, extract it into a script with a name, give it tests and call it from the pipeline. The habit costs minutes at the time and keeps the option of changing providers open, along with the ability to debug failures on a laptop instead of by pushing commits and waiting for the runner.
Measure how long the pipeline takes and where the time goes. Caching, parallel stages and skipping unaffected work are provider features, but the decisions about what can run in parallel belong in your scripts and dependency graph, where they survive a migration.
Where this does not apply
Using a provider's marketplace actions (for deployment, release notes, security scanning) is a trade of portability for convenience, and that is often reasonable; just keep the core build portable. Over-abstracting for a move you never make adds cost, so the thin-YAML rule is mostly about being able to run CI locally. A few lines of glue per provider are acceptable. See the GitHub default environment variables and the GitLab predefined variables.


