Table of Contents
- The problem
- What is in the repository
- Design decisions and the alternatives
- A real problem: a documentation site too large for GitHub Pages
- Using it
- Limits
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):
1name: CI2 3on:4 push:5 branches: [main]6 pull_request:7 8permissions: {}9 10jobs:11 swift:12 permissions:13 contents: read14 uses: muhittincamdali/.github/.github/workflows/swift-ci.yml@<sha> # v115 with:16 platforms: '["iOS", "macOS", "tvOS", "watchOS", "visionOS"]'17 18 hygiene:19 permissions:20 contents: read21 uses: muhittincamdali/.github/.github/workflows/hygiene.yml@<sha> # v1Design 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:
1- name: Select Xcode2 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:
1skip-synthesized-members:2 description: >-3 Leave out members a type only inherits from protocol extensions, such as the hundreds of4 SwiftUI View modifiers every view gets. A package with many views otherwise produces a5 site too large for GitHub Pages.6 type: boolean7 default: falseand 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
- 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.
- Replace
<sha>with the commit of the tag you want and keep the tag as a comment. - 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.
- Enable Dependabot for
github-actionsin 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-cihas 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.
v1andv1.0.0point to the same commit andv1was not moved tov1.1.0. A caller that
wants the new input has to pin the new commit.
- The workflows assume GitHub-hosted runners:
macos-26for Apple work,ubuntu-24.04otherwise. hygienematches regular expressions. A legitimate hit, such as a quoted error message, needs an
entry in the repository's .github/hygiene-allow.txt.
readme-i18n-syncreports 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
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

