All Articles
CategoryDevOps
Reading Time
8 min read
Published
2026-10-15
Word Count
1,502words

Grab a coffee — this one is a deep dive!

One repository for everyone else's CI: designing reusable GitHub workflows

Summary

How muhittincamdali/.github keeps CI for Swift, Node.js and Flutter repositories in seven reusable workflows, pinned by SHA and versioned by tag.

  • The account muhittincamdali has 57 public repositories that are not archived.
  • Reusable workflows rather than composite actions.
  • The repository checks itself with ci.yml: actionlint 1.7.12 (downloaded and verified against a SHA-256), plus its own hygiene and links workflows called by relative path.
  • Source code: [github.com/muhittincamdali/.github](https://github.com/muhittincamdali/.github)
One repository for everyone else's CI: designing reusable GitHub workflows

Table of Contents


The problem

The account muhittincamdali has 57 public repositories that are not archived. Many of them are

Swift packages and two are Node.js command line tools. Each of them needs roughly

the same checks: build and test, build for every Apple platform the package claims, publish DocC

documentation, check that links and #anchors in the README still resolve, and check that the

translated READMEs still match the English one.

Writing those checks into every repository means one copy per repository. A fix to one of them, a

new runner image or a security update to a pinned action then has to be applied by hand in each

place, and the copies drift. The .github repository is the answer to that: the checks live in one

place, and each repository keeps only a short file that calls them.

As of 15 October 2026, 18 repositories call these workflows: 16 Swift packages use swift-ci and

docc-pages, the two Node.js tools use node-ci, and all 18 use links, hygiene and

readme-i18n-sync. The rest of the account is added as each repository is rebuilt.

What is in the repository

Two kinds of files.

Community files. GitHub uses CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md,

SUPPORT.md, the issue forms and the pull request template from a repository named .github for

every repository of the same owner that has no copy of its own. Nothing has to be wired up for this;

a repository only needs its own file when it wants something different.

Reusable workflows. Seven files under .github/workflows/, each triggered by workflow_call:

Workflow
What it does
swift-ci
swift build --build-tests, swift test, optional xcodebuild per platform
docc-pages
DocC with xcodebuild docbuild, several modules merged into one site, deployed to GitHub Pages
flutter-ci
dart format, flutter analyze, tests with coverage, optional dart pub publish --dry-run
node-ci
npm or pnpm install from the lockfile, then lint, typecheck, test, build if they exist
links
lychee link and anchor check with a one-day cache
readme-i18n-sync
each README.<lang>.md names the English version it translates
hygiene
rejects tool names, unverifiable superlatives and retired slogans in files and commit messages

A Swift package calls two of them like this (from the repository README):

yaml
1name: CI
2 
3on:
4 push:
5 branches: [main]
6 pull_request:
7 
8permissions: {}
9 
10jobs:
11 swift:
12 permissions:
13 contents: read
14 uses: muhittincamdali/.github/.github/workflows/swift-ci.yml@<sha> # v1
15 with:
16 platforms: '["iOS", "macOS", "tvOS", "watchOS", "visionOS"]'
17 
18 hygiene:
19 permissions:
20 contents: read
21 uses: muhittincamdali/.github/.github/workflows/hygiene.yml@<sha> # v1

Design decisions and the alternatives

Reusable workflows rather than composite actions. A composite action runs as steps inside the

caller's job, so the caller still decides the runner, the matrix and the permissions. A reusable

workflow owns whole jobs. That matters here: swift-ci has a SwiftPM job and a separate

xcodebuild job with a fail-fast: false matrix over platforms, and docc-pages has a build job

with contents: read and a deploy job with pages: write and id-token: write. Those shapes cannot

be expressed by a step list.

Typed inputs with defaults that fit the common case. Most callers pass one input or none.

swift-ci takes platforms as a JSON list and turns it into the matrix with fromJSON; an empty

list ("[]", the default) skips the platform job entirely. When no scheme is given it picks the

package's -Package scheme from xcodebuild -list -json, or the only scheme there is.

node-ci runs only the package scripts that exist, so a tool without a typecheck script does not

need a different configuration.

Inputs reach shell code only through environment variables. Every input that ends up in a

command is mapped into env: first and quoted in the script:

yaml
1- name: Select Xcode
2 if: inputs.xcode != ''
3 env:
4 XCODE_VERSION: ${{ inputs.xcode }}
5 run: sudo xcode-select --switch "/Applications/Xcode_${XCODE_VERSION}.app"

An ${{ }} expression written straight into run: is pasted into the script text before the shell

sees it; an environment variable is only ever data.

Least privilege by default. Each workflow declares permissions: {} at the top and grants

permissions per job. Every checkout uses persist-credentials: false, so the token is not left in

the local git configuration for later steps. The caller has to grant the same permissions on its

calling job, which the README table lists per workflow.

Everything pinned to a commit. Every uses: inside the workflows names a full commit SHA with

the version as a comment, for example

actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. A tag can be moved; a commit

cannot. The repository's dependabot.yml groups all action updates into one weekly pull request

with the ci prefix. Callers follow the same rule one level up: they pin

swift-ci.yml@<sha> # v1, and the README notes that Dependabot then proposes updates.

Concurrency that knows it is reused. Each workflow has its own group, such as

reusable-swift-ci-${{ github.repository }}-${{ github.ref }}. The reusable-<name> prefix keeps

it from coinciding with a group the calling workflow defines itself. Pull request runs cancel an

older run of the same branch; pushes to main are never cancelled. docc-pages never cancels, so a

deployment is not cut off halfway.

Scripts inside the workflow, not in a second repository. hygiene and readme-i18n-sync are

Python programs embedded with shell: python3 -I {0}. Isolated mode means Python does not import

anything from the checked-out repository's directory. hygiene writes each word it looks for with a

character class, such as w[o]rd, so the workflow file never matches its own rules. links writes

a default .lychee.toml when the calling repository has none, so a repository gets reasonable

exclusions and the one-day cache without copying a configuration file.

A real problem: a documentation site too large for GitHub Pages

The first tag, v1.0.0 (with v1 on the same commit), was cut on 14 October 2026. Later that

day docc-pages failed for SwiftUI-Components. The commit that fixed it, 5998767, records

what happened: DocC documents every member a type inherits from a protocol extension, which for a

SwiftUI view is about 850 View modifiers. With 220 public views the archive came to 2.5 GB in

380,000 files. GitHub Pages did not finish processing the 101 MB artifact within the 10-minute

deploy timeout, and the site was over the 1 GB Pages limit anyway.

The fix is one build setting. docc-pages gained an input:

yaml
1skip-synthesized-members:
2 description: >-
3 Leave out members a type only inherits from protocol extensions, such as the hundreds of
4 SwiftUI View modifiers every view gets. A package with many views otherwise produces a
5 site too large for GitHub Pages.
6 type: boolean
7 default: false

and passes DOCC_SKIP_SYNTHESIZED_MEMBERS="$SKIP_SYNTHESIZED" to xcodebuild docbuild. With the

setting the same archive has 3,158 files (18 MB), and the types' own initializers and properties

are unchanged.

The default is false, so every existing caller builds the same site it built before. That is

what made this a minor release, v1.1.0, rather than a change to v1. SwiftUI-Components now pins

docc-pages.yml@85ec2fa… # v1.1.0 and sets the input with a comment that explains why; the other

callers stay on v1 until they need it.

The lesson for a shared workflow: a new behaviour arrives behind an input whose default keeps the

old behaviour, and the version number says whether a caller has to look.

Using it

  1. Add a workflow file to the repository, for example .github/workflows/ci.yml, with the jobs

from the example above. Grant the permissions from the README table on each calling job.

  1. Replace <sha> with the commit of the tag you want and keep the tag as a comment.
  2. For documentation, set the repository's Pages source to "GitHub Actions" and call docc-pages

with targets and deploy: ${{ github.event_name != 'pull_request' }}, so pull requests only

build.

  1. Enable Dependabot for github-actions in the calling repository so new tags arrive as pull

requests.

The repository checks itself with ci.yml: actionlint 1.7.12 (downloaded and verified against a

SHA-256), plus its own hygiene and links workflows called by relative path.

Limits

  • flutter-ci has no caller yet. It is written and passes actionlint, but it has not run for a

real Flutter repository from this account.

  • The tags are git tags without GitHub Releases or a changelog file; what changed is in the commit

messages.

  • v1 and v1.0.0 point to the same commit and v1 was not moved to v1.1.0. A caller that

wants the new input has to pin the new commit.

  • The workflows assume GitHub-hosted runners: macos-26 for Apple work, ubuntu-24.04 otherwise.
  • hygiene matches regular expressions. A legitimate hit, such as a quoted error message, needs an

entry in the repository's .github/hygiene-allow.txt.

  • readme-i18n-sync reports a translation that is behind the English README as a warning, not an

error, unless the caller sets strict: true.

Source code: github.com/muhittincamdali/.github

Tags

#github-actions#ci#reusable-workflows#swift#docc#devops
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share