{"id":653,"date":"2026-09-06T05:00:30","date_gmt":"2026-09-06T05:00:30","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times\/"},"modified":"2026-09-06T05:00:30","modified_gmt":"2026-09-06T05:00:30","slug":"github-actions-reusable-workflows-the-bug-i-fixed-eleven-times","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times\/","title":{"rendered":"GitHub Actions Reusable Workflows: The Bug I Fixed Eleven Times"},"content":{"rendered":"<p>Okay, this is going to sound dumb, but last month I fixed the same bug in eleven repositories. Not eleven bugs. One bug, eleven times. A Node version pin in a CI workflow that I&rsquo;d copy-pasted across every client project I set up in 2024, and every one of them started failing the week a dependency dropped support for Node 18.<\/p>\n<p>Eleven pull requests. Eleven &ldquo;bump node to 22&rdquo; commits. Eleven times waiting for the green tick. Somewhere around repo seven I stopped and asked myself why I was doing this by hand when GitHub Actions has had reusable workflows since 2021 and I had, apparently, never bothered to learn them properly.<\/p>\n<p>So this is the post I should have read three years ago. It covers what reusable workflows are, how they differ from composite actions (the two get confused constantly, including by me), the before and after of my own setup, and the handful of gotchas that cost me an afternoon. If you maintain more than three repos with CI, you probably have this problem too, you just haven&rsquo;t hit the eleven-PR morning yet.<\/p>\n<h2 id=\"the-copy-paste-workflow-and-why-it-rots\">The copy-paste workflow, and why it rots<\/h2>\n<p>Here&rsquo;s roughly what lived in <code>.github\/workflows\/ci.yml<\/code> in every one of those repos. You&rsquo;ve written this file. Everyone has.<\/p>\n<pre><code class=\"language-yaml\"># Before: the same file, pasted into every repo\nname: CI\non:\n  push:\n    branches: [main]\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: 18\n          cache: npm\n      - run: npm ci\n      - run: npm run lint\n      - run: npm test -- --coverage\n      - uses: actions\/upload-artifact@v4\n        with:\n          name: coverage\n          path: coverage\/\n<\/code><\/pre>\n<p>Nothing wrong with it on day one. The rot sets in slowly. One repo gets a fix for flaky caching. Another gets a matrix added because a client needed Node 20 support. A third gets a security tweak after I read something about pinning action versions to SHAs. None of those improvements travel. Six months later I have eleven workflows that are 80% identical and 20% mysteriously different, and I couldn&rsquo;t tell you which one is the &ldquo;good&rdquo; one.<\/p>\n<p>The Node pin was just the symptom that finally hurt enough to notice.<\/p>\n<h2 id=\"reusable-workflows-vs-composite-actions\">Reusable workflows vs composite actions<\/h2>\n<p>This is where I got stuck the first time, so let me be blunt about the distinction before showing code.<\/p>\n<p>A reusable workflow is an entire workflow, with its own jobs and runners, that another workflow can call as if it were a job. You trigger it with <code>workflow_call<\/code>, and the caller uses <code>uses:<\/code> at the job level. The <a href=\"https:\/\/docs.github.com\/en\/actions\/how-tos\/reuse-automations\/reuse-workflows\" rel=\"nofollow noopener\" target=\"_blank\">GitHub docs on reusing workflows<\/a> are decent here, but they bury the important part: the called workflow runs in the context of the caller&rsquo;s repository, on the caller&rsquo;s runners, with the caller&rsquo;s secrets if you pass them.<\/p>\n<p>A composite action is a bundle of steps. It runs inside a job that already exists. You can&rsquo;t define a runner or a matrix in a composite action, because it doesn&rsquo;t own the job. It&rsquo;s closer to a function you call from within a step list. The <a href=\"https:\/\/docs.github.com\/en\/actions\/tutorials\/create-actions\/create-a-composite-action\" rel=\"nofollow noopener\" target=\"_blank\">composite action docs<\/a> cover the mechanics.<\/p>\n<p>The rule I use now: if the thing I want to share is &ldquo;a job&rdquo; (test this, build this, deploy this), it&rsquo;s a reusable workflow. If it&rsquo;s &ldquo;a few steps that always go together&rdquo; (set up Node with our cache config, or log in to our registry), it&rsquo;s a composite action. I ended up needing both, and they nest fine: a reusable workflow can call composite actions.<\/p>\n<p>I got this wrong for two weeks by trying to make one composite action do everything, then wondering why I couldn&rsquo;t give it a matrix.<\/p>\n<h2 id=\"the-after-one-workflow-one-line-per-repo\">The after: one workflow, one line per repo<\/h2>\n<p>I made a repo called <code>qasim\/workflows<\/code> (public, because reusable workflows in private repos need extra access config and I didn&rsquo;t want to think about it yet). The shared workflow lives at <code>.github\/workflows\/node-ci.yml<\/code>:<\/p>\n<pre><code class=\"language-yaml\"># .github\/workflows\/node-ci.yml in the shared repo\nname: Node CI (reusable)\non:\n  workflow_call:\n    inputs:\n      node-versions:\n        description: JSON array of Node versions to test\n        type: string\n        default: '[&quot;22&quot;]'\n      run-lint:\n        type: boolean\n        default: true\n      coverage:\n        type: boolean\n        default: false\n    secrets:\n      NPM_TOKEN:\n        required: false\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node: ${{ fromJSON(inputs.node-versions) }}\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ matrix.node }}\n          cache: npm\n      - run: npm ci\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n      - if: ${{ inputs.run-lint }}\n        run: npm run lint\n      - run: npm test ${{ inputs.coverage &amp;&amp; '-- --coverage' || '' }}\n      - if: ${{ inputs.coverage }}\n        uses: actions\/upload-artifact@v4\n        with:\n          name: coverage-node${{ matrix.node }}\n          path: coverage\/\n<\/code><\/pre>\n<p>And in each of the eleven repos, <code>ci.yml<\/code> became this:<\/p>\n<pre><code class=\"language-yaml\"># After: the caller, in every project repo\nname: CI\non:\n  push:\n    branches: [main]\n  pull_request:\n\njobs:\n  ci:\n    uses: qasim\/workflows\/.github\/workflows\/node-ci.yml@v1\n    with:\n      node-versions: '[&quot;20&quot;, &quot;22&quot;]'\n      coverage: true\n    secrets:\n      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n<\/code><\/pre>\n<p>Eight lines. The next time a Node version dies, I change one file, tag <code>v1.1<\/code>, and move the <code>v1<\/code> tag. Every repo picks it up on its next run.<\/p>\n<p>Notice the matrix lives in the shared workflow but the versions come from the caller. That&rsquo;s the bit composite actions can&rsquo;t do, and it&rsquo;s the reason I picked reusable workflows for this layer. The <code>fromJSON<\/code> trick is ugly but it&rsquo;s the documented way to pass an array through a string input; matrix inputs can&rsquo;t be typed as arrays directly. Yes, I find that annoying too.<\/p>\n<h2 id=\"secrets-permissions-and-the-inherit-shortcut\">Secrets, permissions, and the <code>inherit<\/code> shortcut<\/h2>\n<p>Two things bit me here.<\/p>\n<p>First, secrets don&rsquo;t flow automatically. The called workflow only sees what you explicitly pass in the <code>secrets:<\/code> block, unless you write <code>secrets: inherit<\/code>, which hands over everything the caller has access to. I used <code>inherit<\/code> for a day, then changed my mind. It&rsquo;s convenient, but it means the shared workflow can read every secret in every repo that calls it, and if I ever add a step that echoes environment for debugging (I have, more than once), that&rsquo;s a bad afternoon. Passing secrets by name costs one extra line per secret and lets me see exactly what crosses the boundary.<\/p>\n<p>Second, <code>permissions<\/code>. If your shared workflow needs to write to the repo (pushing a tag, commenting on a PR), the caller has to grant that. The called workflow can only narrow permissions, never widen them beyond what the caller allowed. GitHub&rsquo;s <a href=\"https:\/\/docs.github.com\/en\/actions\/reference\/security\/secure-use\" rel=\"nofollow noopener\" target=\"_blank\">secure use guide for Actions<\/a> covers the reasoning, and the short version is: put <code>permissions: contents: read<\/code> at the top of your caller and only open things up per job when you have to.<\/p>\n<p>On the topic of security, pin the shared workflow reference to something you control. <code>@v1<\/code> is a moving tag I own, so I&rsquo;m fine with it. If you&rsquo;re calling someone else&rsquo;s reusable workflow, pin to a full commit SHA. Same rule as third-party actions.<\/p>\n<h2 id=\"composite-actions-for-the-boring-steps\">Composite actions for the boring steps<\/h2>\n<p>Once the job-level workflow was shared, I noticed the same four setup steps kept appearing in my other workflows too: the deploy workflow, the release workflow, a nightly one. Node setup, cache, <code>npm ci<\/code>, registry auth. That&rsquo;s a composite action.<\/p>\n<pre><code class=\"language-yaml\"># action.yml in qasim\/workflows\/setup-node-project\/\nname: Setup Node project\ninputs:\n  node-version:\n    default: '22'\n  npm-token:\n    default: ''\nruns:\n  using: composite\n  steps:\n    - uses: actions\/setup-node@v4\n      with:\n        node-version: ${{ inputs.node-version }}\n        cache: npm\n    - run: npm ci\n      shell: bash\n      env:\n        NODE_AUTH_TOKEN: ${{ inputs.npm-token }}\n<\/code><\/pre>\n<p>Called with <code>uses: qasim\/workflows\/setup-node-project@v1<\/code>. The <code>shell: bash<\/code> line is required on every <code>run<\/code> step in a composite action, and forgetting it produces an error message that does not mention the word &ldquo;shell&rdquo;. You&rsquo;ve been warned.<\/p>\n<p>One genuine limitation: composite actions can&rsquo;t use <code>secrets.*<\/code> directly. You pass the secret in as an input from the caller, which is what the <code>npm-token<\/code> input is doing above. It feels slightly wrong to pass a secret as an input, but it&rsquo;s how the model works, and the value is still masked in logs.<\/p>\n<h2 id=\"caching-still-needs-care\">Caching still needs care<\/h2>\n<p>I assumed moving to a shared workflow would make caching &ldquo;just work&rdquo; everywhere. It mostly did, with one wrinkle: cache keys are scoped to the repo that runs the workflow, not the repo that defines it. That&rsquo;s the right behaviour, and it means each of the eleven repos has its own <code>node_modules<\/code> cache, which is what you want. But the 10 GB per-repo cache limit that GitHub documents in its <a href=\"https:\/\/docs.github.com\/en\/actions\/reference\/workflows-and-actions\/dependency-caching\" rel=\"nofollow noopener\" target=\"_blank\">dependency caching reference<\/a> applies per caller, so a shared workflow that caches aggressively can fill a small repo&rsquo;s quota faster than you&rsquo;d expect. I trimmed the coverage artifacts to seven days of retention and stopped caching Playwright browsers in the shared job. Not a big deal, just not free.<\/p>\n<p>If you&rsquo;re also shrinking Docker images in CI, the same &ldquo;measure before you assume&rdquo; attitude applies. I wrote about that in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/docker-multi-stage-builds-the-1-2gb-image-i-stopped-shipping\" rel=\"noopener\">multi-stage builds and the 1.2 GB image I stopped shipping<\/a>, and about half of that post&rsquo;s advice is really about what your CI cache is silently doing.<\/p>\n<h2 id=\"what-id-tell-someone-starting-today\">What I&rsquo;d tell someone starting today<\/h2>\n<p>Don&rsquo;t extract too early. I wrote the copy-paste version of that workflow in maybe fifteen minutes back in 2024 and it served me fine for a year. The shared version took most of a day, including the two weeks of low-level confusion about composite actions that I&rsquo;m compressing here. Three repos with slightly different CI is fine. Eight is where it starts to hurt. Eleven is where you write a blog post about it.<\/p>\n<p>Also, version the shared repo like a library. Moving <code>v1<\/code> is fine for backward-compatible fixes. Anything that changes an input name gets <code>v2<\/code>, and the old tag stays around. I learned this the cheap way, by breaking one repo instead of all of them, because one repo happened to still pin <code>@main<\/code>. Don&rsquo;t pin <code>@main<\/code>.<\/p>\n<p>I&rsquo;m still not sure whether I should have just used a monorepo for the client projects and skipped all of this. Probably not; the clients own their repos and want them separate. But I wrote about that tradeoff at length in the <a href=\"https:\/\/abrarqasim.com\/blog\/turborepo-vs-nx-the-question-i-ask-before-either-one\" rel=\"noopener\">Turborepo vs Nx post<\/a>, and the honest answer is that shared CI workflows are the &ldquo;we have multiple repos and that&rsquo;s not changing&rdquo; solution.<\/p>\n<h2 id=\"something-to-do-this-week\">Something to do this week<\/h2>\n<p>Open your most-copied workflow file. Grep your org for its job name. If it appears in more than three repos, make a <code>workflows<\/code> repo, move that job into a <code>workflow_call<\/code> file with two or three inputs, and replace the copies with an eight-line caller. Tag it <code>v1<\/code>. You&rsquo;ll spend an hour, and the next time a runtime version dies you&rsquo;ll fix it once.<\/p>\n<p>If you want to see how I set up CI for the projects I run for clients, the <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work section of my site<\/a> has a few examples where the pipeline setup was most of the job.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I fixed one CI bug in eleven repos before learning GitHub Actions reusable workflows properly. Before\/after YAML, composite actions, secrets, and the gotchas.<\/p>\n","protected":false},"author":2,"featured_media":652,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I fixed one CI bug in eleven repos before learning GitHub Actions reusable workflows properly. Before\/after YAML, composite actions, secrets, and the gotchas.","rank_math_focus_keyword":"github actions reusable workflows","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[716,718,80,708,717],"class_list":["post-653","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-ci-cd-2","tag-composite-actions","tag-devops","tag-github-actions","tag-reusable-workflows"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/653","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=653"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/653\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/652"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=653"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=653"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=653"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}