This repository is the organization-wide shared CI/CD library for Simplify9. It contains reusable GitHub Actions workflows, composite actions, and org workflow-templates. No application code lives here.
.github/ ← workspace root (README.md, AGENTS.md, CLAUDE.md live here)
├── .github/
│ ├── workflows/ ← reusable workflows (workflow_call triggers)
│ └── actions/ ← composite actions (uses: in steps)
├── workflow-templates/ ← org starter templates surfaced in GitHub's "New workflow" UI
└── profile/README.md ← org profile page
Every file in .github/workflows/ is a reusable workflow — it has on: workflow_call: (and occasionally on: workflow_dispatch:) and is never run standalone. Every directory in .github/actions/ is a composite action with its own action.yml. Every file in workflow-templates/ is a thin starter caller (paired with a .properties.json metadata sidecar) that GitHub offers when a user clicks "New workflow" in an org repo.
From any repo in the simplify9 org:
# Reusable workflow
uses: simplify9/.github/.github/workflows/<name>.yml@main
# Composite action (inside a step)
uses: simplify9/.github/.github/actions/<name>@mainAlways use @main. Never use a SHA pin or version tag — this repo has no release tags. Note the doubled .github/.github/ path segment (the repo is named .github, and the workflows live in its .github/ directory).
Caller workflow
└── reusable workflow (workflows/*.yml)
├── composite action (actions/determine-semver)
├── composite action (actions/docker-build-push)
├── composite action (actions/helm-generic | helm-deploy)
└── composite action (actions/write-job-summary)
Composite actions are the smallest units of work. Reusable workflows orchestrate jobs and call composite actions. Callers only call reusable workflows (never composite actions directly, except for simple utility actions like determine-semver or tag-github-origin). Every reusable workflow references the composite actions through the external simplify9/.github/.github/actions/<name>@main path — not local ./.github/actions/... paths — so a workflow run always uses the actions as published on main.
Do not change these without a full compatibility audit. These are the versions currently used by uses: references across this repo:
| Action | Version | Notes |
|---|---|---|
actions/checkout |
@v7 |
|
actions/setup-node |
@v6 |
|
actions/setup-dotnet |
@v5 |
|
actions/setup-java |
@v5 |
|
actions/upload-artifact |
@v7 |
|
actions/download-artifact |
@v8 |
Always download by name:, never by artifact-ids: |
actions/cache |
@v5 (and @v4 in some CF workflows) |
|
azure/setup-helm |
@v5 |
Installs latest stable Helm unless a version is pinned |
azure/setup-kubectl |
@v5 |
|
docker/setup-buildx-action |
@v4 |
|
docker/setup-qemu-action |
@v4 |
Multi-platform builds |
docker/login-action |
@v4 |
|
docker/metadata-action |
@v6 |
|
docker/build-push-action |
@v7 |
|
cloudflare/wrangler-action |
@v4 |
Used for all Cloudflare Workers deploys (command: deploy). Deployment URL is the deployment-url output |
gradle/actions/setup-gradle |
@v5 |
Pinned to v5. Do NOT use gradle/gradle-build-action (archived). v6.x is not adopted — stay on v5 |
ruby/setup-ruby |
@v1 |
iOS: Ruby/Bundler-managed CocoaPods (bundler-cache) |
subosito/flutter-action |
@v2 |
Flutter SDK setup (Flutter iOS/Android workflows). Version selector via flutter-version input, default 3.x |
maxim-lobanov/setup-xcode |
@v1 |
iOS Xcode version selection |
apple-actions/upload-testflight-build |
@v5 |
iOS TestFlight upload (App Store Connect API) — runs on ubuntu-latest |
dependabot/fetch-metadata |
@v3 |
Reads Dependabot PR metadata (update-type, package-ecosystem) for the auto-merge template. v3.1.0 also fixed update-type incorrectly returning null for some Composer/Terraform/pip PRs with non-standard commit prefixes |
r0adkll/upload-google-play |
@v1 |
Android Google Play upload |
Helm / kubectl CLI versions: the composite actions (helm-deploy, helm-deploy-s9generic, helm-package-push) default their helm-version / kubectl-version inputs to latest. Some reusable workflows pin a specific CLI: helm-deploy-values.yml defaults Helm v4.2.0 / kubectl v1.33.0; gateway-chart-cicd.yml defaults Helm v4.2.2. There is no single global CLI pin — check the specific workflow/action input.
actions/checkout@v7gotcha — stray.claude/worktreesgitlink (consumer-repo issue, not ours). If a caller reports checkout failing at the "Removing auth" / submodule-cleanup step withfatal: No url found for submodule path '.claude/worktrees/agent-...' in .gitmodules(exit code 128), do not touch any workflow or action here — the reusable pipeline is fine. The consumer repo accidentally committed a Claude Code worktree under.claude/worktrees/; since that dir has its own.git, git stored it as a160000gitlink with no.gitmodulesentry, and checkout'sgit submodule foreach --recursiveteardown chokes on it. It surfaces on whichever job checks out first (usually versioning). Fix is in the caller repo:git rm --cached -r .claude/worktrees, add.claude/to.gitignore, commit, push (repeat per affected branch); verify withgit ls-files -s | grep 160000returning nothing. See the README Troubleshooting section for the full caller-facing writeup.
- File:
.github/actions/<name>/action.yml runs.using: "composite"always — all 19 actions in this repo are composite; none are Docker- or JavaScript-based- Every
run:step must haveshell: bash - Every input must have
description:and a sensibledefault:(orrequired: true) - Outputs must have a
value:expression pointing to a step output - Keep heavy logic in a sibling script only when it is genuinely large or shared — currently only
gateway-onboard/onboard.sh(cluster-mutating) andgateway-routing/render.sh(pure value rendering) do this; every other action keeps its logic inline inaction.yml
- File:
.github/workflows/<name>.yml - Must have
on: workflow_call:as the primary trigger - All inputs must have
description:,type:, andrequired:set explicitly - Secrets are declared under
on.workflow_call.secrets:— never passed as inputs - Branch-to-environment mapping is not done with
if:checks ongithub.refinside these workflows. Instead it is delegated todetermine-semverviarelease-branch: ${{ github.event.repository.default_branch }}: a build on the default branch produces a clean release version + git tag; any other branch produces a qualified prerelease tag (x.y.z-<branch>.<run>) and is not treated as a release. Caller workflows / templates do the per-branch gating withif: github.ref_name == '...'and choose the GitHub Environment. - Deploy jobs bind to a GitHub Environment via a
deploy-environment/release-environment/gh-environmentinput (use environment protection rules for approvals), and where a deploy is optional it is gated by a boolean (deploy: falseinreusable-service-cicd.yml) or by leaving the environment input empty
This is a critical pattern. Never mix Helm config and secrets in a single parameter:
| Parameter | Helm flag | Used for |
|---|---|---|
helm-set-values (workflow input) |
--set |
Non-sensitive config: replicas, ingress, environment label |
helm-set-secret-values (workflow secret) |
--set-string |
Secrets: DB connection strings, API keys, anything with special characters |
Use --set-string for secrets because it bypasses Helm type coercion / shell parsing and prevents SSL:, =, or // characters from causing failures. At the composite-action layer the secret input is secret_set_values (helm-generic, snake_case) or secret-set-string-values (helm-deploy / helm-deploy-s9generic, kebab-case).
When adding upload/download artifact pairs (e.g. mobile build → release jobs):
- Always use a matching
name:input — neverartifact-ids: - Upload with
actions/upload-artifact@v7, download withactions/download-artifact@v8 - Set
retention-days: 1for build artifacts not needed beyond the pipeline run
All Docker/Helm/NuGet versioning flows through actions/determine-semver:
- Inputs
major+minor; reads git tags matchingmajor.minor.*and auto-increments the patch from the highest match - Output
versionis always a cleanx.y.z(novprefix); outputgit-tagis clean on the release branch and qualified (x.y.z-<branch>.<run>) elsewhere; outputis-releaseis'true'/'false' - After a successful deploy/publish,
tag-github-origincreates the tag via the GitHub REST API (no checkout needed), so the next run increments from it
Every composite action must follow the 4-pillar log output framework. This applies to all new actions and any modification to existing ones.
4 Pillars:
- Meaningful and context-aware — emit a
::notice::announcement on the first step with key input values - Checkpoint-driven — wrap each critical operation in
::group::/::endgroup::, track status in namespaced<PREFIX>_CP{N}_STATUSenv vars (e.g.DOCKER_CP1_STATUS,HELM_DEPLOY_CP2_STATUS). Never use bareCHECKPOINT_N_STATUSin a composite action — see the namespacing rule below - Systematically consistent — use the canonical emoji/tag vocabulary below
- Summarised — write a structured section to
$GITHUB_STEP_SUMMARY. Reusable workflows do this through the sharedwrite-job-summarycomposite action (inputs:title,status=${{ job.status }}, optionalicon,details); composite actions append their own summary table in anif: always()final step
Canonical emoji/tag vocabulary:
| Domain | Tag | Emoji |
|---|---|---|
| Docker | [DOCKER] |
🐳 |
| Helm / Kubernetes | [HELM] |
☸️ |
| Gateway API | [GATEWAY] |
🚪 |
| .NET / NuGet | [DOTNET] |
🔷 |
| Cloudflare Workers | [CF-WORKERS] |
⚡ |
| iOS | [IOS] |
🍎 |
| Android | [ANDROID] |
🤖 |
| Versioning / Tagging | [VERSION] |
🏷️ |
| Code Signing | [SIGN] |
🔏 |
Step template for a composite action:
steps:
# 1. Announce (first step — always runs)
- name: Announce <action>
shell: bash
run: |
echo "::notice title=🏷️ [DOMAIN] Action Name::key: ${{ inputs.key }}"
# <PREFIX> = a short, action-specific prefix (e.g. DOCKER, HELM_DEPLOY, CF_DOMAIN)
echo "<PREFIX>_CP1_STATUS=⏳ Pending" >> "$GITHUB_ENV"
echo "<PREFIX>_CP2_STATUS=⏳ Pending" >> "$GITHUB_ENV"
# 2. Existing work step — wrap with group, set status at end
- name: Do the work
shell: bash
run: |
echo "::group::🏷️ [CHECKPOINT 1/2] Step Name"
# ... existing commands ...
echo "<PREFIX>_CP1_STATUS=✅ PASSED" >> "$GITHUB_ENV"
echo "::endgroup::"
# 3. For uses: steps — add a confirm step immediately after
- uses: some/action@v1
- name: Confirm step complete
shell: bash
run: |
echo "<PREFIX>_CP2_STATUS=✅ PASSED" >> "$GITHUB_ENV"
# 4. Failure report (before summary)
- name: Report failure
if: failure()
shell: bash
run: |
echo "::error title=❌ [DOMAIN] Action failed::context. Checkpoints — 1) Name: ${<PREFIX>_CP1_STATUS:-⏭️ Not reached} | 2) Name: ${<PREFIX>_CP2_STATUS:-⏭️ Not reached}."
# 5. Summary (always last, always runs)
- name: Write action summary
if: always()
shell: bash
run: |
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
cat >> "$GITHUB_STEP_SUMMARY" << "$EOF"
## 🏷️ Action Title
| Field | Value |
|-------|-------|
| Key input | ${{ inputs.key }} |
| Triggered by | ${{ github.actor }} |
## 📋 Checkpoint Summary
| # | Checkpoint | Status |
|---|------------|--------|
| 1 | Step One | ${<PREFIX>_CP1_STATUS:-⏭️ Not reached} |
| 2 | Step Two | ${<PREFIX>_CP2_STATUS:-⏭️ Not reached} |
$EOFRules:
- Namespace checkpoint env vars per action — use
<PREFIX>_CP{N}_STATUS(e.g.DOCKER_CP1_STATUS), never bareCHECKPOINT_N_STATUS. A composite action's>> "$GITHUB_ENV"writes leak into the caller's job environment, so a bareCHECKPOINT_1_STATUSsilently overwrites the calling workflow's (and sibling actions') same-named checkpoints, corrupting their failure reports and summaries. The prefix must be unique per action (e.g.IOS_CERTvsIOS_PROFILE,HELM_DEPLOYvsHELM_PKG) so two actions in one job can't collide either - Use
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)for the heredoc delimiter — never a fixed string likeEOFwhich can collide with step output <PREFIX>_CP{N}_STATUSdefaults to⏭️ Skipped(not⏳ Pending) when the step is always skipped (e.g. optionalif:steps that never run in most call sites)- Do not add checkpoints for trivial one-liner steps (masking,
mkdir,chmod) — only for operations that can meaningfully fail independently
- Kubernetes: DigitalOcean managed cluster
- Ingress/Gateway: Cilium Gateway API and classic ingress-nginx coexist;
reusable-service-cicd.ymlandgeneric-gateway-helm-template.ymlselect between them withrouting-mode - Helm chart registry: ChartMuseum at
https://charts.sf9.ioand GHCR as an OCI registry (oci://ghcr.io/...).reusable-service-cicd.ymlpublishes togithub-oci,chartmuseum, orboth - Default generic charts:
s9genericchart(ingress-nginx pipeline,generic-chart-helm.yml) ands9genericchart-v2(gateway pipeline,generic-gateway-helm-template.yml) fromhttps://charts.sf9.io - Container registry:
ghcr.io(default for most workflows),docker.io, orregistry.digitalocean.com/<namespace> - kubeconfig: passed as a base64-encoded (or raw-YAML) secret —
kubeconfigfor ingress,kubeconfig-gatewayfor the gateway routing-mode inreusable-service-cicd.yml
All twelve workflows live in .github/workflows/. (When in doubt, ls .github/workflows is the source of truth — this table is maintained, not generated.)
| Workflow | Purpose | Key inputs |
|---|---|---|
next-cloudflare-worker.yaml |
Next.js (OpenNext adapter) → Cloudflare Workers | project_name, environment, route, package_manager, opennextjs_version |
vite-cloudflare-worker.yml |
Vite SPA → Cloudflare Workers static assets (native SPA routing, no Worker script) | project_name, environment, route (required), assets_dir |
Both call generate-wrangler-config to produce wrangler.toml dynamically, and write-job-summary.
| Workflow | Purpose | Key inputs |
|---|---|---|
reusable-service-cicd.yml |
Consolidated pipeline: semver → optional NuGet → Docker → publish chart (github-oci / chartmuseum / both) → optional deploy (ingress-nginx or gateway-api) → tag |
chart-name (required), chart-publish-method, deploy, routing-mode |
generic-chart-helm.yml |
Full CI/CD deploying s9genericchart over ingress-nginx, with optional EF Core migration init Job; tags after a successful deploy |
app-name, namespace, ingress-hosts, init-job-image |
generic-gateway-helm-template.yml |
Gateway-first CI/CD deploying s9genericchart-v2 behind the Cilium Gateway API (auto-onboards listeners + cert-manager Certificates); supports gateway/ingress/dual |
app-name, gateway-hostnames, routing-mode, gateway-section-names |
helm-deploy-values.yml |
Deploy-only: deploys an already-published chart from a ChartMuseum-style repo using a caller values file (no build/package/tag) | release-name, chart-name, chart-repo, namespace, values-file |
| Workflow | Purpose |
|---|---|
gateway-chart-cicd.yml |
CI/CD for a Cilium Gateway API-aware Helm chart: compute SemVer → helm lint --strict + routing/ConfigMap render assertions (via yq) → package → push to ChartMuseum → tag origin |
| Workflow | Purpose | Key inputs |
|---|---|---|
ios-build.yml |
React Native / native iOS → TestFlight. Builds + archives + exports on a macOS runner; uploads from ubuntu-latest via App Store Connect API |
workspace, scheme, release-environment, disable-release |
android-build.yml |
React Native Android AAB → Google Play | app-id, gradle-task, version-code-offset, release-environment, disable-release |
flutter-ios-build.yml |
Flutter iOS → TestFlight. flutter build ipa on a macOS runner; uploads from ubuntu-latest via App Store Connect API. Major/minor from marketing-prefix; patch/build counters from pubspec.yaml + run_number |
macos-runner, xcode-version, marketing-prefix, app-slug, release-environment, disable-release |
flutter-android-build.yml |
Flutter Android AAB → Google Play. flutter build appbundle with key.properties signing |
app-id, app-slug, version-code-offset, release-environment, disable-release |
All four mobile workflows have a build job that needs: critical-vuln-gate (a critical alert blocks the build itself, not just the release, so CI stops wasting a runner on a release that can't ship — critical-vuln-gate's own skipped result, e.g. build-only runs, is explicitly allowed through) and a release_with_environment job gated by if: release-environment != '' && !disable-release && needs.build.result == 'success' && (needs.critical-vuln-gate.result == 'success' || == 'skipped'), bound to the named GitHub Environment. (Earlier revisions of release_with_environment's if checked only the input flags — since any custom job if replaces GitHub's implicit success()-over-needs check, that meant a failed build or a failed gate would NOT actually block the TestFlight/Play Store upload; fixed 2026-07-12.) They use marketplace release actions (apple-actions/upload-testflight-build@v5, r0adkll/upload-google-play@v1) — there is no Docker-based upload action. The Flutter and RN iOS workflows both reuse ios-install-cert / ios-install-profile for signing; Flutter sets up the SDK with subosito/flutter-action@v2. Per-branch environment selection (e.g. android-staging vs android-production) is done by the caller template's workflow_dispatch jobs, gated on github.ref_name.
| Workflow | Purpose | Key inputs |
|---|---|---|
critical-vuln-gate.yml |
Thin workflow_call wrapper around check-critical-vulns — fails if the calling repo has an open critical-severity Dependabot alert. Called from the critical-vuln-check and dependabot-auto-merge workflow-templates, and embedded as an early job in every deploy/build reusable workflow. |
none (secret: dependabot-alerts-token, required — see note below) |
dependabot-alerts-token— GITHUB_TOKEN does not work here. Confirmed by live testing (2026-07-12): the Dependabot Alerts REST API rejects the ephemeral ActionsGITHUB_TOKENoutright ("Resource not accessible by integration"), regardless of whatpermissions:are granted anywhere in the call chain. This gate requires a real Personal Access Token or GitHub App installation token with "Dependabot alerts: read", stored as the org secretDEPENDABOT_ALERTS_TOKEN. Every caller — including every one of the ten reusable workflows this gate is embedded in — must explicitly forward it (dependabot-alerts-token: ${{ secrets.dependabot-alerts-token }}at each nesting level, ultimately sourced fromsecrets.DEPENDABOT_ALERTS_TOKEN), since custom secrets are never automatically available inside a called reusable workflow.
workflow-templates/ holds the ten starter workflows GitHub surfaces in the "New workflow" picker for org repos. Each is a <name>.yml + <name>.properties.json pair (the .properties.json supplies name, description, iconName, categories). Each template's job uses: a reusable workflow at @main.
| Template | Calls reusable workflow | Trigger |
|---|---|---|
service-cicd |
reusable-service-cicd.yml |
push on main + workflow_dispatch |
generic-chart-cicd |
generic-chart-helm.yml |
push on staging, main + workflow_dispatch |
next-cloudflare |
next-cloudflare-worker.yaml |
push on staging, main + workflow_dispatch |
vite-cloudflare |
vite-cloudflare-worker.yml |
push on staging, main + workflow_dispatch |
android-app |
android-build.yml |
workflow_dispatch only |
ios-app |
ios-build.yml |
workflow_dispatch only |
flutter-android-app |
flutter-android-build.yml |
workflow_dispatch only |
flutter-ios-app |
flutter-ios-build.yml |
workflow_dispatch only |
critical-vuln-check |
critical-vuln-gate.yml |
pull_request on main, develop |
dependabot-auto-merge |
critical-vuln-gate.yml (+ inline auto-merge job) |
pull_request on main, develop |
When you add, rename, or change the interface of a reusable workflow that has a template, update the matching workflow-templates/<x>.yml AND its .properties.json in the same change. The two Cloudflare and the two Helm-service templates gate per-branch with if: github.ref/github.ref_name; the four mobile templates are workflow_dispatch-only and gate the dev/prod jobs on github.ref_name; the two Security templates trigger on pull_request only (never push) and call critical-vuln-gate.yml rather than gating on branch/environment.
All 19 actions are composite. Call them in job steps with uses: simplify9/.github/.github/actions/<name>@main.
determine-semver— Computes the nextmajor.minor.patchfrom git tags. Inputs:major,minor,release-branch,current-ref,build-id. Outputs:version,git-tag,is-release.tag-github-origin— Creates a lightweight git tag via the GitHub REST API (no checkout). Inputs:github-token,repository,tag,sha. Outputs:created,ref.
docker-build-push— Build + push (optionally multi-platform via Buildx/QEMU) up to three tags (version, branch name,latest). Inputs:image-name,version,username,password,registry,platforms,build-args,build-secrets. Outputs:image-tags,image-digest.
Maintenance smell — three overlapping deploy actions.
helm-deploy,helm-deploy-s9generic, andhelm-genericall wraphelm upgrade --installand re-implement the same concerns (kubeconfig decode, Helm/kubectl install, atomic rollback,--set/--set-stringhandling, release verification). They diverge in incidental details — input naming (kebab-casevssnake_case), chart source (OCI / ChartMuseum / Helm repo / local path), Helm version strategy, and whether a pre-deploy migration Job is supported. Every hardening fix has to be applied in three places. Recommended direction: consolidate into one parameterized deploy action covering every chart source with optional migration Job, standardizing on one input-naming convention and one secret path (--set-string); keep the old names as thin shims so existing callers aren't broken.
helm-generic—helm upgrade --installofs9genericchart(default) fromhttps://charts.sf9.io, with an optional pre-deploy DB migration Job (init_job_image+ relatedinit_job_*inputs). Requires Helm 4, always uses--rollback-on-failure. snake_case inputs (app_name,namespace,kubeconfig_data,extra_set_values,secret_set_values). Used bygeneric-chart-helm.ymlandgeneric-gateway-helm-template.yml. Output:chart-ref.helm-deploy— Deploy from an OCI registry or ChartMuseum (chart-source-type). Detects Helm 3 vs 4. kebab-case inputs. Used byreusable-service-cicd.ymlandhelm-deploy-values.yml. Output:chart-url.helm-deploy-s9generic— Deploy from an OCI registry or a local chart directory (chart-pathmode), with on-failure cluster diagnostics. Output:chart-url,deployed-image.helm-package-push— Package a chart and publish to OCI (helm push) or ChartMuseum (HTTP upload); optionally rewrites Chart.yaml version/appVersion and values.yaml image fields. Outputs:chart-package,chart-url.
gateway-routing— Pure rendering (no cluster access; logic inrender.sh): produces the gateway/ingress/configmap Helm values file and the host/section lists. Outputs:values-file,gateway-host-list,gateway-section-names-list.gateway-onboard— Cluster-mutating (logic inonboard.sh): ensures the parent Gateway has the HTTP/HTTPS listeners and cert-manager Certificates for the requested hostnames before deploy. No outputs. Consumes the host/section lists fromgateway-routing.
dotnet-build— Resolves a build target (existing*.slnor an ephemeral generated one), thenrestore→build→ optionaltest. Output:build-target.dotnet-pack-push—dotnet pack --no-build→dotnet nuget push --skip-duplicate; emptyprojectsis a graceful skip. Outputs:packages-pushed,package-paths.
Project-list inputs (projects / test-projects on dotnet-build, projects on dotnet-pack-push, and the nuget-projects workflow input) accept one or more glob patterns as a space- OR newline-separated list. A YAML | block scalar (one project per line) is honoured in full. These are split with read -rd '' -a — plain read -ra stops at the first newline and silently drops every entry after the first, so never revert to it.
generate-wrangler-config— Generateswrangler.tomldynamically (plain Workers, OpenNext viabuild-for-opennext, static assets, SPAnot-found-handling, route lists). Output:config-path.setup-cloudflare-domain— Adds a custom domain to a Cloudflare Pages project via the CF REST API (idempotent). Inputfail-on-error(defaultfalse) makes failure non-blocking. Output:domain-status.
ios-install-cert— Imports a base64.p12into a temporary keychain. Inputs:p12Base64,p12Password,keychainPath. ExportsKEYCHAIN_PATHto$GITHUB_ENV.ios-install-profile— Installs a base64.mobileprovisionand extracts its UUID/Name. Input:profileBase64. ExportsIOS_PROFILE_UUID/IOS_PROFILE_NAMEto$GITHUB_ENV, plus best-effortIOS_PROFILE_TEAM_ID/IOS_PROFILE_BUNDLE_ID(empty, never fatal, if the profile omits them) so callers building anExportOptions.plistdon't re-decode the profile secret.xcode-build—xcodebuild archive(manual signing by default). Inputs:workspace,scheme,configuration,archivePath,signingStyle,developmentTeam,provisioningProfileUuid,keychainPath.xcode-export—xcodebuild -exportArchive→.ipa. Inputs:archivePath,exportOptionsPlist,exportPath.
(There is no xcode-setup action — CocoaPods, Ruby/Bundler, and Xcode selection are handled inline by ios-build.yml.)
write-job-summary— Appends a standardized, status-aware section to$GITHUB_STEP_SUMMARY. Inputs:title,status(${{ job.status }}→ ✅ SUCCESS / ❌ FAILED),icon,details. Used by every reusable workflow.check-critical-vulns— Fails if the repository has any open critical-severity Dependabot alert. UsesLink-header cursor pagination againstGET /repos/{owner}/{repo}/dependabot/alerts?state=open&severity=critical— this endpoint does not support page-number pagination (page=Nis rejected with HTTP 400). Inputs:dependabot-alerts-token(required — a PAT/App token, notGITHUB_TOKEN, which cannot access this API regardless of granted permissions; see thecritical-vuln-gate.ymlnote above),repository(defaults to the calling repo). Output:critical-count. Fails closed on every error path (network failure, missing/rejected token, bad response, or a real critical alert). Reused at three call sites: thecritical-vuln-gatereusable workflow (PR-time check + auto-merge gate) and the build-time gate embedded in every deploy/build reusable workflow.
- Always use manual signing in CI (
signingStyle: manualinxcode-build). Automatic signing requires an interactive Xcode session. - Certificate and profile installation must use
ios-install-certandios-install-profilebefore the archive step. - The build job runs on a macOS runner (
macos-runner, defaultmacos-latest); the release job runs onubuntu-latestand uploads to TestFlight viaapple-actions/upload-testflight-build@v5(App Store Connect API) — no macOS tooling needed for the upload. xcode-versionaccepts a major (26) or major.minor (16.4) selector (resolved bymaxim-lobanov/setup-xcode@v1).- Flutter iOS
marketing-prefixaccepts major.minor only (X.Y, default1.0). Its patch remains thepubspec.yamlpatch plusgithub.run_number; the build number remains pubspec+Nplusgithub.run_number. - CocoaPods caching: two independent
actions/cache@v5steps key onPodfile.lock—~/.cocoapods/repos(global spec repo, always restored) andios/Pods(project Pods dir, skipped whenclean-reinstall-pods: true). - ccache (
enable-ccache, defaulttrue): caches ObjC/C++ pod compilation under~/Library/Caches/ccache. No benefit for Swift targets. The workflow patches the Podfile to set:ccache_enabled => truewhen enabled. - Ruby/Bundler: set
ruby-version+use-bundler: trueto manage CocoaPods via Bundler (ruby/setup-ruby@v1withbundler-cache).
VERSION_CODE=github.run_number + version-code-offset. Setversion-code-offsethigh (default80000) when migrating from another CI system to avoid versionCode collisions on the Play Console.VERSION_NAMEderives fromversion-prefixas a SemVer patch counter —major.minorfixed,patch = base patch + run_number(no carry/rollover, no upper bound) — unlessversion-name-overrideis set.- Release uses
r0adkll/upload-google-play@v1(a marketplace action) — not a Docker-based action. Track viaplay-track(defaultinternal); setchanges-not-sent-for-review: truefor internal tracks. - Gradle uses
gradle/actions/setup-gradle@v5(Gradle home caching). Do not usegradle/gradle-build-action(archived). Do not addcache: gradletoactions/setup-java— it invokesgradle-build-actioninternally and conflicts withsetup-gradle. Do not add a manualactions/cachestep for~/.gradle—setup-gradleowns Gradle home caching. - The workflow itself sets
org.gradle.caching=truein the project'sgradle.properties(the caller no longer needs to add it manually). - NDK is pinned to
27.1.12297006(r27b LTS) for RN 0.85 and installed viasdkmanager, notactions/cache—/usr/local/lib/android/sdk/is root-owned on GitHub-hosted runners, sotarextraction fails withCannot utime/Cannot change mode.sdkmanagerhas the correct elevated permissions. - Node.js 24 opt-in: both jobs set
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: truesoactions/cache,actions/setup-java@v5, andgradle/actions/setup-gradle@v5use Node 24 ahead of GitHub's Node 20 retirement. use-jetifier(defaulttrue) runsnpx jetifyfor AndroidX migration; disable for projects that don't need it.
- Create
.github/actions/<kebab-name>/action.yml - Set
name:,description:,author: 'Simplify9' runs.using: "composite"— everyrun:step needsshell: bash- Declare all inputs with
description:and eitherdefault:orrequired: true - If it installs a CLI tool (Helm, kubectl), use an input with a
latest(or workflow-pinned) default so callers can override - Do not hardcode registry URLs or credentials — accept them as inputs
- Follow the 4-pillar log framework with a namespaced
<PREFIX>_CP{N}_STATUS
- Create
.github/workflows/<kebab-name>.yml - Primary trigger:
on: workflow_call:with fullinputs:andsecrets:blocks - Compute the version with
determine-semver(release-branch: github.event.repository.default_branch) rather thanif:-gating ongithub.ref - Bind deploy/release jobs to a GitHub Environment input and gate optional deploys with a boolean (
deploy:) or an empty-environment check - Upload artifacts with
retention-days: 1unless the artifact has a cross-pipeline use case - Call
write-job-summary(withstatus: ${{ job.status }}) at the end of each job - If the workflow should be offered as a starter, add a paired
workflow-templates/<name>.yml+.properties.json
- Do not hardcode CLI versions (Helm, kubectl, Node) inside action
run:scripts — use action inputs with defaults so callers can override - Do not add
on: push:oron: pull_request:triggers to files in.github/workflows/— all triggers come from caller repos (templates inworkflow-templates/are the place forpush/workflow_dispatch) - Do not use
gradle/gradle-build-action— it is archived; usegradle/actions/setup-gradle@v5 - Do not add
cache: gradletoactions/setup-java— it invokesgradle-build-actioninternally and silently disables thesetup-gradlecache restore - Do not add a manual
actions/cachestep for~/.gradle—setup-gradlealready owns Gradle home caching - Do not use
actions/cachefor/usr/local/lib/android/sdk/paths — root-owned on GitHub-hosted runners; usesdkmanagerto install NDK/CMake directly - Do not pass secrets as regular inputs — declare them under
on.workflow_call.secrets: - Do not mix Helm config and secret values in a single
--setcall — use--set-string(helm-set-secret-values/secret_set_values/secret-set-string-values) for anything sensitive or containing special characters - Do not call composite actions via local
./.github/actions/...paths from inside the reusable workflows — use the externalsimplify9/.github/.github/actions/<name>@mainform so runs use the published actions - Do not reintroduce a Docker-based mobile upload action — TestFlight and Play uploads use the pinned marketplace actions
After every change to this repository, update README.md (caller-facing) and this AGENTS.md (conventions) to reflect the current state. CLAUDE.md points at this file as the authoritative contract, so keep it accurate.
Specifically, update the docs whenever you:
- Add, remove, or rename a workflow, composite action, or workflow-template
- Add, change, or remove an input or secret on any workflow or action
- Change a pinned action version or a CLI tool version default
- Change the default value of any documented input
- Change deployment infrastructure (registry, cluster, chart repo URL, chart name)
An outdated README is a source of broken pipelines and wasted debugging time — treat it as part of the same change, not an afterthought.