Skip to content

Repository files navigation

Simplify9 Reusable CI/CD Library

Organization-wide shared GitHub Actions workflows, composite actions, and starter templates for Simplify9 projects. Callers reference this repo with @main — no versioned tags exist.

GitHub ActionsReact NativeiOSAndroidDockerKubernetesHelmCloudflareNext.jsVite.NETNode.js


Table of Contents


Which Workflow Should I Use?

StackDeployment targetUse this workflow
Next.js (SSR, OpenNext adapter)Cloudflare Workersnext-cloudflare-worker.yaml
Vite single-page appCloudflare Workers (static assets)vite-cloudflare-worker.yml
Containerized service (any stack, .NET-friendly)Docker + Helm (GHCR OCI / ChartMuseum), optional K8s deployreusable-service-cicd.yml
Service deployed over ingress-nginxDocker + s9genericchart → Kubernetesgeneric-chart-helm.yml
Service deployed behind the Cilium Gateway APIDocker + s9genericchart-v2 → Kubernetesgeneric-gateway-helm-template.yml
Deploy an already-published chart from a values fileKubernetes via Helmhelm-deploy-values.yml
Cilium Gateway API-aware Helm chart (chart dev)ChartMuseumgateway-chart-cicd.yml
iOS app (React Native or native)TestFlightios-build.yml
Android app (React Native)Google Playandroid-build.yml
iOS app (Flutter)TestFlightflutter-ios-build.yml
Android app (Flutter)Google Playflutter-android-build.yml

All workflows are reusable (on: workflow_call:). Call them from your repo:

uses: simplify9/.github/.github/workflows/<name>.yml@main

Starter Templates

This repo also ships org workflow templates (workflow-templates/) that appear in GitHub's Actions → New workflow picker for any simplify9 repo. Each template is a thin caller wired to a reusable workflow with example inputs pre-filled — start from one and edit.

Template (in "New workflow")WrapsDefault triggers
Service CI/CD Pipelinereusable-service-cicd.ymlpushmain, workflow_dispatch
Generic Chart Helm CI/CDgeneric-chart-helm.ymlpushstaging/main, workflow_dispatch
Next.js + Cloudflare Workersnext-cloudflare-worker.yamlpushstaging/main, workflow_dispatch
Vite + Cloudflare Workersvite-cloudflare-worker.ymlpushstaging/main, workflow_dispatch
Android App CI/CDandroid-build.ymlworkflow_dispatch
iOS App CI/CDios-build.ymlworkflow_dispatch
Flutter Android App CI/CDflutter-android-build.ymlworkflow_dispatch
Flutter iOS App CI/CDflutter-ios-build.ymlworkflow_dispatch
Critical Vulnerability Checkcritical-vuln-gate.ymlpull_requestmain, develop
Dependabot Auto-Mergecritical-vuln-gate.ymlpull_requestmain, develop

Prerequisites — Secrets

Set these as Organization or repository secrets. Names below are the secret keys the workflows expect under secrets: (some workflows accept secrets: inherit).

Frontend (Cloudflare Workers)

cloudflare_api_token # API token with Workers + DNS permissions
cloudflare_account_id # Cloudflare account ID
dependabot-alerts-token # PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate (pass secrets.DEPENDABOT_ALERTS_TOKEN — GITHUB_TOKEN cannot access this API)

Service / Backend (Kubernetes + Container Registry + Helm)

registry-username # Container registry username (GHCR: github.actor)
registry-password # Container registry password/token (GHCR: GITHUB_TOKEN)
kubeconfig # Base64-encoded (or raw YAML) kubeconfig — ingress-nginx deploys
kubeconfig-gateway # Base64 kubeconfig for the gateway-api routing mode (reusable-service-cicd)
chartmuseum-username # ChartMuseum username (when publishing/pulling via ChartMuseum)
chartmuseum-password # ChartMuseum password/token
helm-set-secret-values # Sensitive Helm values, applied with --set-string
github-token # Tags the origin after deploy (falls back to built-in GITHUB_TOKEN) — not used by `helm-deploy-values.yml`
dependabot-alerts-token # PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate (pass secrets.DEPENDABOT_ALERTS_TOKEN — GITHUB_TOKEN cannot access this API)
nuget-api-key # NuGet API key (only if publishing packages)

Mobile — iOS

ios-p12-base64 # Base64-encoded .p12 signing certificate
ios-p12-password # Password for the .p12
ios-mobileprovision-base64 # Base64-encoded .mobileprovision
ios-team-id # (optional) explicit Apple Team ID
appstore-api-key-id # App Store Connect API Key ID
appstore-issuer-id # App Store Connect Issuer ID
appstore-api-private-key-base64 # Base64-encoded App Store Connect .p8 private key

Mobile — Android

android-keystore-base64 # Base64-encoded .jks / .keystore
android-keystore-password # Keystore password
android-key-alias # Key alias
android-key-password # Key password
google-play-service-account-json # Google Play service account JSON

Repository Structure

.github/ ← workspace root (README.md, AGENTS.md, CLAUDE.md)
├── .github/
│ ├── workflows/ ← reusable workflows (workflow_call)
│ │ ├── next-cloudflare-worker.yaml
│ │ ├── vite-cloudflare-worker.yml
│ │ ├── reusable-service-cicd.yml
│ │ ├── generic-chart-helm.yml
│ │ ├── generic-gateway-helm-template.yml
│ │ ├── helm-deploy-values.yml
│ │ ├── gateway-chart-cicd.yml
│ │ ├── ios-build.yml
│ │ ├── android-build.yml
│ │ ├── flutter-ios-build.yml
│ │ ├── flutter-android-build.yml
│ │ └── critical-vuln-gate.yml
│ ├── actions/ ← composite actions
│ │ ├── determine-semver/
│ │ ├── tag-github-origin/
│ │ ├── docker-build-push/
│ │ ├── helm-deploy/
│ │ ├── helm-deploy-s9generic/
│ │ ├── helm-generic/
│ │ ├── helm-package-push/
│ │ ├── gateway-routing/ (render.sh)
│ │ ├── gateway-onboard/ (onboard.sh)
│ │ ├── dotnet-build/
│ │ ├── dotnet-pack-push/
│ │ ├── generate-wrangler-config/
│ │ ├── setup-cloudflare-domain/
│ │ ├── ios-install-cert/
│ │ ├── ios-install-profile/
│ │ ├── xcode-build/
│ │ ├── xcode-export/
│ │ ├── write-job-summary/
│ │ └── check-critical-vulns/
│ └── dependabot.yml ← this repo's own Dependabot config (github-actions only)
├── workflow-templates/ ← org starter templates (*.yml + *.properties.json)
└── dependabot-templates/ ← ready-made per-category `dependabot.yml` configs for consumer repos to copy

Workflow Reference

Big workflows (the gateway/service pipelines) expose dozens of inputs. The tables below cover the commonly used ones; the workflow file itself is the complete, authoritative input list.


Frontend · Cloudflare Workers


next-cloudflare-worker.yaml

Builds a Next.js app with the OpenNext.js Cloudflare adapter and deploys it to Cloudflare Workers.

InputRequiredDefaultDescription
project_nameBase Worker project name (without env suffix)
environmentWrangler environment (staging, production)
route''Route / custom domain (falls back to repo var CLOUDFLARE_ROUTE then ROUTE)
package_manageryarnnpm, yarn, or pnpm
node_version24Node.js version
opennextjs_version1.20.1@opennextjs/cloudflare adapter version
compatibility_date2026-05-01Cloudflare compatibility date
assets_dir.open-next/assetsStatic assets directory
build_scriptbuildBuild npm script
run_linttrueRun lint step

Required secrets:cloudflare_api_token, cloudflare_account_id, dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate — pass secrets.DEPENDABOT_ALERTS_TOKEN; GITHUB_TOKEN cannot access this API regardless of granted permissions)

jobs:
deploy:
uses: simplify9/.github/.github/workflows/next-cloudflare-worker.yaml@mainwith:
project_name: my-nextjs-appenvironment: productionroute: myapp.comsecrets:
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

vite-cloudflare-worker.yml

Builds a Vite single-page app and deploys it to Cloudflare Workers static assets — SPA routing is handled natively by Cloudflare (not_found_handling = "single-page-application"); no custom Worker script is generated.

InputRequiredDefaultDescription
project_nameBase Worker project name
environmentWrangler environment
routeRoute / custom domain
assets_dirdistVite build output directory
package_manageryarnnpm, yarn, or pnpm
node_version24Node.js version
compatibility_date2026-05-01Cloudflare compatibility date
pre_build_commands''Optional shell commands run before the build
run_linttrueRun lint step

Required secrets:cloudflare_api_token, cloudflare_account_id, dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate — pass secrets.DEPENDABOT_ALERTS_TOKEN; GITHUB_TOKEN cannot access this API regardless of granted permissions)

Unlike the Next.js workflow, route is required here.


Service & Backend · Kubernetes


reusable-service-cicd.yml

The consolidated service pipeline: compute semver → optionally publish NuGet → build & push a Docker image → package and publish the Helm chart (GHCR OCI, ChartMuseum, or both) → optionally deploy to Kubernetes (ingress-nginx or gateway-api) → tag the git origin.

Publishing is always on; deploying is opt-in (deploy: false by default).

InputRequiredDefaultDescription
chart-nameHelm chart name (must match Chart.yamlname:)
chart-publish-methodbothgithub-oci, chartmuseum, or both (empty/unknown hard-fails)
chart-repo-urlChartMuseum base URL (required for chartmuseum/both)
chart-path./chartHelm chart directory
container-registryghcr.ioContainer registry
image-name(repo name)Docker image name
dotnet-version8.0.x.NET SDK (for NuGet/tests)
nuget-projects''NuGet project glob(s); one or more, space- or newline-separated (YAML | block). Empty = skip NuGet
deployfalseDeploy the published chart after publishing
routing-modeingress-nginxingress-nginx or gateway-api
deploy-namespaceplaygroundKubernetes namespace
deploy-environmentDevelopmentGitHub Environment for the deploy job
helm-set-values''Non-secret --set values
gateway-hostnames''Hostnames for the HTTPRoute (gateway-api)
major-version / minor-version1 / 0Semver components

Secrets (conditionally required): registry-username, registry-password, chartmuseum-username + chartmuseum-password (for chartmuseum/both), kubeconfig (deploy + ingress-nginx) or kubeconfig-gateway (deploy + gateway-api), helm-set-secret-values, nuget-api-key, github-token (tags the origin after deploy), dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate — GITHUB_TOKEN cannot access this API).

Required caller permissions:contents: write, packages: write (GHCR image/chart push), security-events: read (build-time critical-vuln gate) — must be declared in the caller's own top-level permissions: block. A job's own permission request inside this reusable workflow can only narrow what the caller grants, never widen it, so a caller missing packages: write gets a silent 403 on the GHCR push regardless of what this workflow's own jobs request.

Outputs:version, docker-image, helm-chart.

jobs:
publish:
uses: simplify9/.github/.github/workflows/reusable-service-cicd.yml@mainwith:
chart-name: my-servicechart-publish-method: bothchart-repo-url: https://charts.sf9.iomajor-version: '2'minor-version: '1'secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}chartmuseum-username: ${{ secrets.CM_USER }}chartmuseum-password: ${{ secrets.CM_PASSWORD }}nuget-api-key: ${{ secrets.NUGET_API_KEY }} # omit to skip NuGet

Set chart-publish-method: github-oci to publish only to GHCR OCI (no ChartMuseum secrets needed — the OCI push uses the built-in GITHUB_TOKEN).


generic-chart-helm.yml

Full CI/CD that builds a Docker image and deploys the shared s9genericchart over ingress-nginx, with optional pre-deploy EF Core migration Job, then tags the version after a successful deploy.

InputRequiredDefaultDescription
app-nameApplication name (app.name Helm value)
namespacedevelopmentKubernetes namespace
deploy-environmentdevelopmentGitHub Environment for the deploy job
container-registryghcr.ioContainer registry
ingress-hostsComma-separated ingress hosts
ingress-pathsComma-separated ingress paths
ingress-tls-secretsTLS secrets matching hosts by index
service-target-portService targetPort
environmentenvironment Helm value (e.g. Development)
helm-set-valuesExtra non-secret --set values
package-nugetfalseBuild & publish NuGet packages
init-job-image''If set, runs a K8s migration Job before deploy
init-job-secret-name''Secret holding the migration connection string
major-version / minor-version1 / 0Semver components

Secrets:registry-username, registry-password, kubeconfig, github-token (tags the origin after deploy), dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate — GITHUB_TOKEN cannot access this API), helm-set-secret-values, nuget-api-key, nuget-source, NUGET_PACKAGE_PAT.

Required caller permissions:contents: write, packages: write (GHCR image push), security-events: read (build-time critical-vuln gate) — must be declared in the caller's own top-level permissions: block. A job's own permission request inside this reusable workflow can only narrow what the caller grants, never widen it, so a caller missing packages: write gets a silent 403 on the GHCR push regardless of what this workflow's own jobs request.

Outputs:version, docker-image, nuget-version.


generic-gateway-helm-template.yml

Gateway-first CI/CD: semver → Docker build/push → Gateway listener + TLS certificate auto-onboarding → Helm deploy of s9genericchart-v2 → tag. The standard pipeline for any service that needs its own hostname on the cluster via the Cilium Gateway API.

Pipeline: version → (optional NuGet) → build → deploy (gateway-routing renders values, gateway-onboard provisions listeners + cert, helm-generic deploys) → tag.

InputRequiredDefaultDescription
app-nameHelm release / image name
namespacedevelopmentKubernetes namespace
routing-modegatewaygateway, ingress, or dual
gateway-hostnames''Comma/newline hostnames for the HTTPRoute
gateway-section-name''Single shared-listener name (all hosts attach to it)
gateway-section-names''Per-hostname section names, one line per hostname (blank line = dedicated mode)
gateway-paths''Route paths
gateway-parent-namepublic-gatewayGateway resource name
gateway-parent-namespaces9-dev-edgeGateway resource namespace
gateway-auto-onboardingtrueProvision listeners + cert for dedicated hosts
gateway-cert-issuer-nameletsencrypt-production-gatewaycert-manager issuer
gateway-cert-waittrueWait for the certificate to become Ready
chart-names9genericchart-v2Helm chart name
chart-repohttps://charts.sf9.ioHelm repo URL
init-job-image''Optional pre-deploy migration Job
helm-set-valuesExtra non-secret --set values
major-version / minor-version1 / 0Semver components

Secrets:registry-username, registry-password, kubeconfig, github-token (tags the origin after deploy), dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate — GITHUB_TOKEN cannot access this API), helm-set-secret-values, nuget-api-key, nuget-source, NUGET_PACKAGE_PAT.

Required caller permissions:contents: write, packages: write (GHCR image/chart push), security-events: read (build-time critical-vuln gate) — must be declared in the caller's own top-level permissions: block. A job's own permission request inside this reusable workflow can only narrow what the caller grants, never widen it, so a caller missing packages: write gets a silent 403 on the GHCR push regardless of what this workflow's own jobs request.

Outputs:version, docker-image, helm-chart, nuget-version.

Listener modes

ModeWhen to useWhat onboarding does
Dedicated (default)Custom domains (api-stg.zeenah.io)Creates HTTP + HTTPS listeners, issues a TLS cert via cert-manager HTTP-01
Shared / wildcardInternal subdomains (myapp.sf9.io)Validates the named shared listener exists; skips cert + listener creation
jobs:
deploy:
uses: simplify9/.github/.github/workflows/generic-gateway-helm-template.yml@mainwith:
app-name: my-apinamespace: my-api-devgateway-hostnames: api-stg.myapp.iosecrets:
kubeconfig: ${{ secrets.KUBECONFIG }}registry-username: ${{ secrets.REGISTRY_USERNAME }}registry-password: ${{ secrets.REGISTRY_PASSWORD }}helm-set-secret-values: ${{ secrets.DEV_HELM_SECRET_VALUES }}

Shared wildcard (e.g. *.sf9.io):

with:
app-name: my-apinamespace: my-api-devgateway-hostnames: my-api.sf9.iogateway-section-name: https-wildcard-sf9-io
Mixed listener mode

One release, two hostnames, different modes. A blank line in gateway-section-names means dedicated; a non-empty line names a shared listener. Keep every non-empty line at the same indentation (YAML sets block-scalar indent from the first non-empty line).

with:
gateway-hostnames: | api-stg.zeenah.io zeenah-api.sf9.iogateway-section-names: | https-wildcard-sf9-io

The first (blank) line → dedicated for api-stg.zeenah.io (gets its own listeners + cert); the second → shared listener for zeenah-api.sf9.io (validated only). When two hosts share one section name the pipeline emits a single parentRefs entry (the Gateway API forbids duplicate (name, namespace, sectionName) tuples).

DNS / Cloudflare proxy: HTTP-01 needs the hostname to resolve directly to the gateway IP. With Cloudflare, set the record to DNS-only (grey cloud) while the cert is issued; re-enable the orange cloud once it is Ready (the pipeline skips the DNS pre-flight when a valid cert already exists).


helm-deploy-values.yml

Deploy-only: deploys an already-published chart from a ChartMuseum-style repo using a caller-supplied values file plus friendly ingress/service inputs. Does not build, package, or tag.

InputRequiredDefaultDescription
release-nameHelm release name
chart-nameChart name
chart-repoClassic (ChartMuseum-style) Helm repo URL
namespaceKubernetes namespace
values-filevalues.yamlValues file in the caller repo (applied if present)
chart-version''Empty = latest published; set to pin
environment''Helm environment= value (not the GitHub Environment)
gh-environment''Optional GitHub Environment for protection/secrets
ingress-hosts / ingress-paths / ingress-tls-secrets''Ingress config (hosts zipped with TLS secrets by index)
image-tag''Image tag
helm-versionv4.2.0Helm CLI version
kubectl-versionv1.33.0kubectl CLI version

Secrets:kubeconfig (or kubeconfig64 alias), helm-set-secret-values, dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the build-time critical-vuln gate; pass secrets.DEPENDABOT_ALERTS_TOKENGITHUB_TOKEN cannot access this API regardless of granted permissions).


Helm Chart CI/CD


gateway-chart-cicd.yml

CI/CD for a Cilium Gateway API-aware Helm chart: compute SemVer (from git tags) → helm lint --strict + routing/ConfigMap render assertions (parsed with yq) → package → push to ChartMuseum → tag origin. The version comes from determine-semver, not the run number.

InputRequiredDefaultDescription
chart-pathchartChart directory
chartmuseum-urlhttps://charts.sf9.io/api/chartsChartMuseum upload endpoint
helm-versionv4.2.2Helm CLI version
major-version / minor-version1 / 0SemVer components
update-dependenciestruehelm package --dependency-update
validate-routingtrueValidate default/ingress/gateway/dual rendering
validate-configmaptrueValidate ConfigMap gating + key routing (config.data → ConfigMap, environmentVariables → Secret)

Secrets:registry-username, registry-password (required); github-token (optional — used by the tag job, falls back to GITHUB_TOKEN).

Outputs:version, chart-name, chart-package, chart-repo-url.

TODO — migrate to OCI. ChartMuseum HTTP upload is the legacy distribution path. Publishing via an OCI registry (helm push chart.tgz oci://...), as reusable-service-cicd.yml already supports, gives immutable, digest-pinned, signable charts and removes the standalone ChartMuseum dependency.


Mobile · iOS & Android

There are two flavors: React Native (ios-build.yml / android-build.yml) and Flutter (flutter-ios-build.yml / flutter-android-build.yml). All four share the same shape — a build job that itself needs the critical-vuln gate (a critical alert blocks the build, not just the release, so CI never spends a runner building something that can't ship) and a release job (release_with_environment) gated on the input flags AND on both the build and the gate having actually succeeded (or the gate having been skipped), bound to a named GitHub Environment for approvals. Per-branch dev/prod selection is done by the workflow_dispatch caller (see the matching starter templates). The Flutter and RN iOS workflows reuse the same ios-install-cert / ios-install-profile composite actions for signing.


ios-build.yml

Builds, signs, and archives a React Native / native iOS app on a macOS runner, exports an IPA, and uploads it to TestFlight from ubuntu-latest via the App Store Connect API (apple-actions/upload-testflight-build@v5).

InputRequiredDefaultDescription
workspacePath to .xcworkspace
schemeXcode scheme to archive
configurationReleaseBuild configuration
xcode-version''Xcode major or major.minor (e.g. 16.4)
macos-runnermacos-latestmacOS runner label
node-version24Node.js version (React Native)
package-manageryarnyarn or npm
ios-diriosiOS directory for pod operations
clean-reinstall-podsfalsepod deintegrate + pod install --repo-update
enable-ccachetrueccache for ObjC/C++ pod compilation
ruby-version''Ruby version (empty disables Ruby setup)
use-bundlerfalseInstall gems via Bundler (needs ruby-version)
marketing-prefix1.0Marketing version start (X.Y or X.Y.Z)
release-environmentios-stagingGitHub Environment for the release job
disable-releasefalseBuild only; skip TestFlight upload

Required secrets:ios-p12-base64, ios-p12-password, ios-mobileprovision-base64, appstore-api-key-id, appstore-issuer-id, appstore-api-private-key-base64 (and optional ios-team-id).

Recommended secret:dependabot-alerts-token (a PAT/App token with "Dependabot alerts: read" — sourced from the org secret DEPENDABOT_ALERTS_TOKEN, notGITHUB_TOKEN, which cannot access the Dependabot Alerts API). Without it, the critical-vuln gate fails closed on every run, which now blocks the build job itself (not just the TestFlight upload) — no signing/archiving happens until the gate resolves.

Outputs:version, build-number, ipa-file.

jobs:
build-and-release:
uses: simplify9/.github/.github/workflows/ios-build.yml@mainwith:
workspace: ios/App.xcworkspacescheme: Appxcode-version: "26"release-environment: ios-productionsecrets:
ios-p12-base64: ${{ secrets.IOS_P12_BASE64 }}ios-p12-password: ${{ secrets.IOS_P12_PASSWORD }}ios-mobileprovision-base64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}appstore-api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}appstore-issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}appstore-api-private-key-base64: ${{ secrets.APPSTORE_API_KEY_BASE64 }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

Notes: manual signing only (automatic signing needs an interactive Xcode session); CocoaPods spec repo + Pods dir are cached on Podfile.lock; ccache speeds ObjC/C++ rebuilds (no Swift benefit). Use the iOS App CI/CD starter template for a workflow_dispatch entry point.


android-build.yml

Builds and signs a React Native Android App Bundle (AAB) via Gradle and publishes it to Google Play (r0adkll/upload-google-play@v1).

InputRequiredDefaultDescription
app-idAndroid applicationId (package name)
app-slugappOutput AAB filename slug
gradle-taskbundleReleaseGradle task
version-prefix1.0.0Base version (X.Y or X.Y.Z)
version-code-offset80000Added to github.run_number for versionCode
java-version17Java version (temurin)
node-version24Node.js version (React Native)
package-manageryarnyarn or npm
build-root-directoryandroidGradle project root
use-jetifiertrueRun npx jetify (AndroidX migration)
play-trackinternalinternal, alpha, beta, production
changes-not-sent-for-reviewfalseUse changesNotSentForReview (internal tracks)
release-environmentandroid-stagingGitHub Environment for the release job
disable-releasefalseBuild only; skip Play upload

Required secrets:android-keystore-base64, android-keystore-password, android-key-alias, android-key-password, google-play-service-account-json.

Recommended secret:dependabot-alerts-token (a PAT/App token with "Dependabot alerts: read" — sourced from the org secret DEPENDABOT_ALERTS_TOKEN, notGITHUB_TOKEN, which cannot access the Dependabot Alerts API). Without it, the critical-vuln gate fails closed on every run, which now blocks the build job itself (not just the Play upload) — no signing/building happens until the gate resolves.

Outputs:version-name, version-code, aab-file.

jobs:
build-and-release:
uses: simplify9/.github/.github/workflows/android-build.yml@mainwith:
app-id: com.mycompany.myappgradle-task: bundleReleaseversion-prefix: "2.0.0"version-code-offset: "80000"release-environment: android-productionplay-track: productionsecrets:
android-keystore-base64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}android-keystore-password: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}android-key-alias: ${{ secrets.ANDROID_KEY_ALIAS }}android-key-password: ${{ secrets.ANDROID_KEY_PASSWORD }}google-play-service-account-json: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

Notes: Gradle uses gradle/actions/setup-gradle@v5 (do not use the archived gradle/gradle-build-action, and do not add cache: gradle to setup-java — it conflicts). The workflow sets org.gradle.caching=true itself, so callers no longer need to. NDK 27.1.12297006 (r27b LTS) is pinned and installed via sdkmanager (not actions/cache — the Android SDK dir is root-owned). Use the Android App CI/CD starter template for a workflow_dispatch entry point.


flutter-ios-build.yml

Builds and signs a Flutter iOS app on a macOS runner, exports an IPA via flutter build ipa, and uploads it to TestFlight from ubuntu-latest via the App Store Connect API (apple-actions/upload-testflight-build@v5). Set the marketing version's major/minor through marketing-prefix (X.Y); the patch remains the pubspec.yaml patch plus github.run_number, and the build number remains the pubspec +BUILD plus github.run_number.

InputRequiredDefaultDescription
macos-runnermacos-latestmacOS runner label (e.g. macos-26)
xcode-version""Xcode selector via maxim-lobanov/setup-xcode (empty = runner default)
flutter-version3.xsubosito/flutter-action version selector (pin exact for reproducible builds)
flutter-channelstableFlutter channel
project-directory.Flutter project root relative to repo root (set to e.g. mobile for a monorepo); ios-dir/pbxproj-path resolve under it
ios-diriosiOS project directory (Podfile + Runner project)
pbxproj-pathios/Runner.xcodeproj/project.pbxprojproject.pbxproj that gets the manual-signing rewrite
app-slugappOutput IPA filename slug
marketing-prefix1.0Marketing version major/minor (X.Y only); patch stays automatic from pubspec + run number
export-methodapp-store-connectExportOptions.plist distribution method
run-analyzefalseRun flutter analyze before building
ipa-name-pattern{app_slug}-{version}-{build_number}.ipaOutput IPA name tokens
dart-define""Space-separated KEY=VALUE pairs passed as --dart-define to flutter build ipa
wait-for-processingfalsePoll App Store Connect until processing finishes (fire-and-forget by default)
release-environmentios-stagingGitHub Environment for the release job
disable-releasefalseBuild only; skip TestFlight upload

Required secrets:ios-p12-base64, ios-p12-password, ios-mobileprovision-base64 (plus appstore-api-key-id, appstore-issuer-id, appstore-api-private-key-base64 for the upload, and optional ios-team-id).

Recommended secret:dependabot-alerts-token (a PAT/App token with "Dependabot alerts: read" — sourced from the org secret DEPENDABOT_ALERTS_TOKEN, notGITHUB_TOKEN, which cannot access the Dependabot Alerts API). Without it, the critical-vuln gate fails closed on every run, which blocks the build job itself (not just the TestFlight upload) — no signing/archiving happens until the gate resolves.

Outputs:version, build-number, ipa-file.

jobs:
build-and-release:
uses: simplify9/.github/.github/workflows/flutter-ios-build.yml@mainwith:
macos-runner: macos-26xcode-version: "26.3"app-slug: myappmarketing-prefix: "1.0"release-environment: ios-productionsecrets:
ios-p12-base64: ${{ secrets.IOS_P12_BASE64 }}ios-p12-password: ${{ secrets.IOS_P12_PASSWORD }}ios-mobileprovision-base64: ${{ secrets.IOS_MOBILEPROVISION_BASE64 }}ios-team-id: ${{ secrets.IOS_TEAM_ID }}appstore-api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}appstore-issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}appstore-api-private-key-base64: ${{ secrets.APPSTORE_API_PRIVATE_KEY_BASE64 }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

Notes: manual signing only — keychain/cert/profile install is delegated to the shared ios-install-cert / ios-install-profile composite actions, then the pbxproj is rewritten to manual signing (Apple Distribution) with fail-loud grep asserts and a generated ExportOptions.plist. The pub package cache is handled by subosito/flutter-action's cache: true (no separate actions/cache step), and ios/Pods is cached on Podfile.lock; pod install deliberately avoids deintegrate/--repo-update to preserve the cache. Use the Flutter iOS App CI/CD starter template for a workflow_dispatch entry point.


flutter-android-build.yml

Builds and signs a Flutter Android App Bundle (AAB) via flutter build appbundle and publishes it to Google Play (r0adkll/upload-google-play@v1). Signing uses Flutter's key.properties convention (decoded keystore + generated android/key.properties).

InputRequiredDefaultDescription
app-idAndroid applicationId / Play package name
app-slugappOutput AAB filename slug
project-directory.Flutter project root relative to repo root (set to e.g. mobile for a monorepo)
flutter-version3.xsubosito/flutter-action version selector (pin exact for reproducible builds)
flutter-channelstableFlutter channel
java-version17Java version (temurin)
version-prefix1.0.0Base version (X.Y or X.Y.Z)
version-code-offset80000Added to github.run_number for versionCode
run-analyzetrueRun flutter analyze before building
analyze-fatal-levelnoneflutter analyze fatal severity (none/warning/info)
keystore-output-pathandroid/app/release.keystoreWhere the decoded keystore is written
aab-name-pattern{app_slug}-release-{version_name}.aabOutput AAB name tokens
dart-define""Space-separated KEY=VALUE pairs passed as --dart-define to flutter build appbundle
play-trackinternalinternal, alpha, beta, production
release-statusdraftPlay release status (draft/completed/...)
changes-not-sent-for-reviewfalseUse changesNotSentForReview (internal tracks)
release-environmentandroid-stagingGitHub Environment for the release job
disable-releasefalseBuild only; skip Play upload

Required secrets:android-keystore-base64, android-keystore-password, android-key-alias, android-key-password, google-play-service-account-json.

Recommended secret:dependabot-alerts-token (a PAT/App token with "Dependabot alerts: read" — sourced from the org secret DEPENDABOT_ALERTS_TOKEN, notGITHUB_TOKEN, which cannot access the Dependabot Alerts API). Without it, the critical-vuln gate fails closed on every run, which blocks the build job itself (not just the Play upload) — no signing/building happens until the gate resolves.

Outputs:version-name, version-code, aab-file.

jobs:
build-and-release:
uses: simplify9/.github/.github/workflows/flutter-android-build.yml@mainwith:
app-id: com.mycompany.myappapp-slug: myappversion-prefix: "2.0.0"version-code-offset: "80000"release-environment: android-productionplay-track: productionsecrets:
android-keystore-base64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}android-keystore-password: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}android-key-alias: ${{ secrets.ANDROID_KEY_ALIAS }}android-key-password: ${{ secrets.ANDROID_KEY_PASSWORD }}google-play-service-account-json: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

Notes: versionName is a SemVer patch counter — major.minor are fixed by version-prefix and only the patch increments (patch = base patch + run_number, e.g. 1.1.69 → 1.1.70, no carry/rollover, no upper bound); versionCode is run_number + version-code-offset (strictly monotonic — set the offset above your last shipped versionCode, and trigger a new run rather than re-running a failed one, since re-runs reuse the run number). No NDK plumbing — Flutter owns the actual build — but the Gradle User Home is cached via gradle/actions/setup-gradle@v5 (which applies to the gradlew Flutter invokes), and both jobs opt JS-based actions onto Node 24 via FORCE_JAVASCRIPT_ACTIONS_TO_NODE24. The keystore and generated android/key.properties are removed by an always() cleanup step. Use the Flutter Android App CI/CD starter template for a workflow_dispatch entry point.


Security


critical-vuln-gate.yml

Thin workflow_call wrapper around the check-critical-vulns composite action: fails if the calling repository has any open critical-severity Dependabot alert. Has no inputs: — only a required secret. Called by the critical-vuln-check and dependabot-auto-merge starter templates, and the same underlying check is embedded as an early gating job inside 10 of the 11 other reusable workflows in this repo (both Cloudflare workflows, all four Service & Backend workflows, and all four mobile workflows) — everything except the chart-lint-only gateway-chart-cicd.yml.

Required secrets:dependabot-alerts-token (a PAT/App token with "Dependabot alerts: read" on the calling repo — pass secrets.DEPENDABOT_ALERTS_TOKEN; GITHUB_TOKEN cannot access the Dependabot Alerts API regardless of granted permissions, confirmed by live testing).

Outputs: none directly from the workflow; the underlying check-critical-vulns action's critical-count output is available to the job that calls it.

jobs:
vuln-gate:
uses: simplify9/.github/.github/workflows/critical-vuln-gate.yml@mainsecrets:
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

Notes: this is the same check embedded as a build-time gate in 10 of the other reusable workflows in this repo (see above) — see the Critical Vulnerability Check and Dependabot Auto-Merge starter templates below for the PR-time uses. Requires Dependabot alerts enabled on the repo (a free feature — no GitHub Advanced Security license needed). Also forwards github-token: secrets.GITHUB_TOKEN to check-critical-vulns so it can verify npm alerts against the PR's own branch — see PR-branch verification below.


Composite Action Reference

Call composite actions directly in job steps:

uses: simplify9/.github/.github/actions/<name>@main

All 19 actions are composite (runs.using: composite). Only gateway-onboard (onboard.sh), gateway-routing (render.sh), and check-critical-vulns (parse_yarn_lock.py, for its PR-branch npm verification — see below) keep logic in a sibling script; the rest is inline bash.

Versioning & Tagging

ActionPurposeKey inputsKey outputs
determine-semverCompute next major.minor.patch from git tagsmajor, minor, release-branch, current-ref, build-idversion, git-tag, is-release
tag-github-originCreate a git tag via the GitHub REST API (no checkout)github-token, repository, tag, shacreated, ref

Docker

ActionPurposeKey inputsKey outputs
docker-build-pushBuild + push (multi-platform via Buildx/QEMU) up to three tagsimage-name, version, username, password, registry, platformsimage-tags, image-digest

Helm

ActionPurposeKey inputs
helm-generichelm upgrade --install of s9genericchart (default) + optional pre-deploy migration Job. Helm 4. snake_case inputsapp_name, namespace, kubeconfig_data, extra_set_values, secret_set_values, init_job_image
helm-deployDeploy from OCI or ChartMuseum (chart-source-type)chart-name, repository, kubeconfig, chart-source-type, chart-repo-url
helm-deploy-s9genericDeploy from OCI or a local chart dir (chart-path) with failure diagnosticschart-name, chart-path, kubeconfig
helm-package-pushPackage + publish to OCI or ChartMuseumchart-path, chart-name, version, publish-method

⚠️Three overlapping deploy actions.helm-deploy, helm-deploy-s9generic, and helm-generic all wrap helm upgrade --install and re-implement the same concerns (kubeconfig handling, atomic rollback, --set/--set-string, verification), differing only in input naming, chart source, and migration-Job support. Every hardening fix must be applied in three places. The intended direction is to consolidate them into a single parameterized deploy action and keep the old names as thin shims. See AGENTS.md.

Gateway API (Cilium)

ActionPurposeKey outputs
gateway-routingRender gateway/ingress/configmap Helm values + host/section lists (pure, no cluster access)values-file, gateway-host-list, gateway-section-names-list
gateway-onboardEnsure parent Gateway listeners + cert-manager Certificates exist before deploy (cluster-mutating)

.NET

ActionPurposeKey outputs
dotnet-buildResolve .sln/glob, then restore → build → optional testbuild-target
dotnet-pack-pushdotnet pack --no-buildnuget push --skip-duplicate (empty = skip)packages-pushed, package-paths

Cloudflare

ActionPurposeKey outputs
generate-wrangler-configGenerate wrangler.toml (plain Workers, OpenNext, static assets, SPA)config-path
setup-cloudflare-domainAdd a custom domain to a Pages project (fail-on-error: false = non-blocking)domain-status

iOS

ActionPurpose
ios-install-certImport a .p12 into a temporary keychain (p12Base64, p12Password)
ios-install-profileInstall a .mobileprovision, extract UUID/Name + best-effort Team ID/Bundle ID (profileBase64)
xcode-buildxcodebuild archive with manual signing (workspace, scheme, archivePath, developmentTeam, provisioningProfileUuid, keychainPath)
xcode-exportxcodebuild -exportArchive.ipa (archivePath, exportOptionsPlist, exportPath)

Shared

ActionPurpose
write-job-summaryAppend a standardized, status-aware section to $GITHUB_STEP_SUMMARY (title, status, icon, details)
check-critical-vulnsFail if the repository has any open critical-severity Dependabot alert (dependabot-alerts-token — a PAT/App token with "Dependabot alerts: read"; GITHUB_TOKEN cannot access this API regardless of granted permissions — repository; optional github-token; output critical-count). Uses Link-header cursor pagination (this endpoint rejects page=N). When run under pull_request_target with a github-token forwarded, also verifies open npm alerts against the PR's own HEAD branch lockfile (parse_yarn_lock.py sibling script for yarn.lock; package-lock.json handled inline via jq) — see PR-branch verification. Used by critical-vuln-gate.yml and embedded as a build-time gate in 10 of the other reusable workflows (all but gateway-chart-cicd.yml)

Dependabot

This is the org-wide reference for how Dependabot is implemented across every simplify9 repository. It covers two genuinely different systems that are easy to conflate — read the first section before anything else.

Two separate systems

1. The Dependabot service. Not a GitHub Actions workflow — a backend GitHub runs on your behalf. It reads .github/dependabot.yml from a repo's default branch only. This is fixed and non-configurable: the file's own target-branch: field controls where update PRs get opened, never where the config file itself must live. On the schedule you set, the service:

  • Parses manifest files (package.json, *.csproj, Dockerfile, pubspec.yaml, Gemfile, action.yml, …)
  • Diffs current versions against the registry's latest and against the GitHub Advisory Database
  • Opens one PR per update (or per group) directly as dependabot[bot], based on/against whatever target-branch: says

Two independent triggers feed it, both toggled per-repo in Settings → Code security:

  • Version updates — scheduled (the schedule: block below), routine bumps regardless of vulnerability status.
  • Security updates — event-driven, fires immediately when a new advisory affecting something in the dependency graph is published, independent of schedule.

2. GitHub Actions. The workflows that react to what the service produces — this is where the gate/auto-merge logic and the one non-obvious secrets restriction (below) live.

The three-file pattern

Every onboarded repo gets the same three files:

FileRole
.github/dependabot.ymlService config — which ecosystems, schedule, limits, grouping. Category-specific, rendered from a template below.
.github/workflows/critical-vuln-check.ymlPR-time gate — see Starter Templates / critical-vuln-gate.yml.
.github/workflows/dependabot-auto-merge.ymlAuto-merge for safe bumps — see below.

Both workflow files are thin callers of this repo's critical-vuln-gate.yml reusable workflow, which wraps the check-critical-vulns composite action — one source of truth reused at three call sites: the PR-time gate, the build-time gate embedded in 10 of the 11 other reusable workflows in this repo, and as a dependency of auto-merge.

dependabot.yml templates (dependabot-templates/)

Ready-made per-category configs live in dependabot-templates/ for copying into a consumer repo, with {{TARGET_BRANCH}} replaced by develop (if the repo has that branch) or the repo's actual default branch otherwise:

TemplateEcosystemsSchedule (all Asia/Amman)Primary limitDocker/Actions limit
nuget-api.ymlnuget, docker, github-actionsMonday 06:00105
npm-frontend.ymlnpm, docker, github-actionsTuesday 06:00105
react-native-mobile.ymlnpm, bundler, github-actionsWednesday 06:00105
flutter-mobile.ymlpub, github-actionsWednesday 06:00105
infra-actions-only.ymlgithub-actions, dockerThursday 06:0055
github-repo.ymlgithub-actions (/ + /.github/actions/*)Sunday 06:005

Schedule days are deliberately staggered by category (and land on the Asia/Amman Sunday–Thursday work week) so Dependabot PR volume doesn't land on every repo the same morning.

Cooldown — explicit, tuned per semver level

GitHub made a repo-wide cooldown default-on for every Dependabot user org-wide, starting 2026-07-14: a flat 3-day wait after a version is published before Dependabot opens a version-update PR for it (security updates are exempt and still fire immediately). Every template sets this explicitly rather than relying on the silent uniform default, so the wait scales with how much a bump should be trusted to have surfaced problems already:

cooldown:
default-days: 3semver-patch-days: 5semver-minor-days: 3semver-major-days: 7

Patch gets the longest wait of the three, which looks backwards until you notice what actually differs between them. The soak time is not scaled to how breaking a bump is — it's scaled to how little human scrutiny it receives. Patch is the only class that dependabot-auto-merge.yml merges automatically, so it is the only class where a freshly published version can reach develop with no human ever looking at it. Minor and major bumps both require a person to press merge, and that person is a far better filter than any number of days. Major still gets a full week on top of never being auto-merged, because the cost of reviewing one is high enough that it should not be re-reviewed twice.

This was originally set to semver-patch-days: 1 on the reasoning that patch bumps are low risk and worth landing quickly. That reasoning does not survive contact with two facts: a semver-patch release can break a runtime contract that no range expresses (see the React Native version contract section below, where a patch bump of react broke a production app), and the npm supply-chain attacks of 2025 turned freshly published patch versions into the primary delivery vector — most were detected within days, which is exactly the window a 1-day cooldown fails to cover.

Raising it costs nothing in security terms: cooldown never applies to Dependabot security updates, which still fire immediately regardless of these values. It delays only routine version updates. This applies to every updates: entry (primary ecosystem, docker, github-actions) in every template — cooldown is scoped per-entry, not repo-wide.

Every primary-ecosystem block also groups bumps by semver level, kept separate:

groups:
npm-patch: # (or nuget-/pub-/actions-patch, per ecosystem)update-types: ["patch"]npm-minor: # (or nuget-/pub-/actions-minor, per ecosystem)update-types: ["minor"]

Patch bumps bundle into one PR, minor bumps bundle into a separate PR — the two are deliberately never mixed into the same group, so a patch-only release isn't held up behind an unrelated minor bump (or vice versa) in review. Major bumps are never grouped at all — each major bump always opens as its own individual PR, since combining major (potentially breaking) bumps into a shared PR risks masking which specific dependency needs the closer review. A grouped PR still counts as one toward open-pull-requests-limit — this is what keeps PR volume manageable at org scale. docker and bundler entries have no groups: block at all, so their updates were already one-PR-per-dependency before this distinction mattered.

Labels — explicit labels: requires the label to already exist

Every template above sets labels: explicitly (dependencies plus one ecosystem-specific label — npm, docker, nuget, github-actions, pub, or fastlane). This is a real GitHub quirk, not a bug in the templates: if dependabot.yml omits labels: entirely, Dependabot auto-applies (and auto-creates) a generic dependencies label on its own — but the moment you specify labels: yourself, that auto-creation stops. Dependabot then requires every listed label to already exist in the repo; if one doesn't, it posts a comment ("The following labels could not be found: ... Please create them before Dependabot can add them to a pull request") and simply skips labeling that PR. This is cosmetic, not a gate failure — the PR itself still opens and is still gated/auto-merged normally.

Since a repo's label set isn't created by this rollout, every one of the 7 labels above needs to exist before its first Dependabot PR opens. Any newly onboarded repo needs the same one-time label creation if it doesn't already have them — creating a label a given repo doesn't actually reference is harmless, it just sits unused.

open-pull-requests-limit — how it actually behaves

  • Scoped per updates: block (i.e., per ecosystem + directory + branch), not per repo — a repo with 3 updates: blocks has 3 independent limits.
  • Only throttles version updates. Security updates always fire regardless of how many PRs are already open — the limit never blocks a real vulnerability fix.
  • It doesn't queue skipped updates — Dependabot just doesn't open a new PR for that slot until an existing one is closed or merged, then backfills on the next scheduled run (or immediately for security updates).
  • Closing a PR without merging does not reliably regenerate it on the next scheduled run — confirmed by live testing: a plain close deletes Dependabot's own branch and Dependabot states it won't notify again about that release until a newer version ships, with no guaranteed reopen path once the branch is gone. @dependabot ignore this major/minor version (or this dependency) — or the config's ignore: list — is the actual mechanism for permanently suppressing a version; that is not what happens by default on a plain close. To force Dependabot to re-evaluate an existing PR (e.g. under a newly fixed pipeline) with zero risk of losing it, comment @dependabot rebase: it force-pushes onto the same PR/branch, never closes or deletes anything, and re-triggers pull_request_target so the PR gets fully re-checked. Only actually close/delete a PR when Dependabot itself asks for it (see Orphaned PRs below) or you're deliberately accepting the loss of that specific version.

Multi-project .NET solutions — Dependabot doesn't sync duplicate PackageReferences across sibling .csproj files

If a solution has multiple .csproj files that each pin the same NuGet package independently (e.g. an integration-test project that duplicates the main API project's PackageReferences instead of relying solely on its <ProjectReference> for the transitive closure), Dependabot's NuGet updater can bump the package in one project's manifest and silently leave the other(s) at the old pinned version — even though directory: "/" already parses the .sln and discovers every referencing project up front. This isn't a scanning gap in our config: confirmed via the updater's own discovery logs (dependabot-core#9444), it finds every occurrence correctly. The bug is in the write-back step, and a maintainer has confirmed it directly: "If the projects have the same dependency but with a different version that's a scenario that the current updater can't handle" (dependabot-core#9444, also #9088) — open, unfixed, no ETA as of this writing.

Do not try to work around this by splitting a solution's NuGet entry into multiple directories: (one per project folder). The failure is in the write, not the directory scope, so splitting doesn't fix the sync — it makes it worse: each directories: entry runs as its own independent update job, and groups: applies per job, not across directories, so you'd get separate PRs per project each proposing the same package bump on its own schedule, actively reproducing the drift instead of preventing it.

The only fix that categorically removes the risk: NuGet Central Package Management (Directory.Packages.props at the repo root, ManagePackageVersionsCentrally=true, every .csproj drops its Version= attribute). This isn't a Dependabot config change — it's an app-repo code change, and Dependabot already understands it natively (bumps the one shared props file, so every project moves together by construction; there's no second writable location left for the updater to miss). Repos with more than one .csproj referencing overlapping packages should migrate to this rather than relying on manual re-sync after every drift-induced build failure. This is the same "hidden duplicate manifest" shape as a polyglot repo templated for one ecosystem (e.g. NuGet-only) that has a subdirectory using a different one (e.g. npm) with its own out-of-band manifest Dependabot was never configured to see — both cases are a manifest Dependabot can't (or wasn't told to) keep in lockstep with its sibling.

Org-wide exposure (scanned across all 3 orgs, 241 NuGet repos): 67 repos have 2+ .csproj files; 44 of those duplicate at least one PackageReference across sibling projects; of those, 21 are mismatched right now. But most of that risk is currently dormant, not active — whether a mismatch actually breaks a pipeline depends entirely on whether CI builds the sibling project at all. Only 5 of the 44 repos actually build/publish a test project as part of their CI/Docker pipeline (SW-CloudFiles, Bitween-api, SW-Mtm-api, mealivery-api, gig-insureapp-api); the other 39 have test projects that sit in the solution untouched by CI, so a version mismatch there won't fail a pipeline — it'll only surface if a developer runs a full solution build locally, or if CI is later changed to include tests. Cross-referencing "CI-active" against "mismatched now" narrows the truly urgent set to just SW-CloudFiles and Bitween-api — everything else mismatched today is a real but currently-invisible risk. Two of the 44 repos (gig-aiusecasespoc-api, wisewell-api) don't have a test project at all — the duplication there is between other sibling projects (SDK/adapters, not tests). And one repo's test project (avtr-api) isn't even listed in its .sln, so it's fully orphaned — not part of any build, mismatched or not. Don't assume "has a duplicate-pinned test project" ⇒ "CI is broken" — check whether that project is actually in the .sln and actually built/published by the pipeline before treating a repo as urgent.

Where the file must live vs. target-branch

Many repos have a develop branch that diverges from main. target-branch is set to develop for these, so Dependabot's own update PRs land where normal dev flow expects them. But dependabot.yml itself — the file the service reads to know target-branch exists at all — must be committed to the default branch, always, regardless of what target-branch says inside it. These are two independent facts that are easy to conflate: "which branch gets the file commit" (always default) vs. "what the file's target-branch field says" (develop, when it exists). Committing the config to develop instead of the default branch leaves it completely undiscovered by the service — no error, no PRs, just silence.

Orphaned PRs — when a dependabot.yml entry changes shape

Dependabot matches an open PR back to the specific dependabot.yml entry that created it — by package-ecosystem + directory/directories + target-branch, not by name or position in the file. If that entry's identity changes after the PR was opened (adding an explicit target-branch, restructuring directories, splitting or merging ecosystem blocks), Dependabot can no longer match the old PR to any current entry. It won't rebase or update that PR; instead it posts: "The dependabot.yml entry that created this PR has been deleted so this PR can't be rebased. Please close the PR so Dependabot can create a new one with the current dependabot.yml."

This is the one case where a plain close is safe and Dependabot-sanctioned — the opposite of the general close behavior above. Dependabot is explicitly asking for the close because it will recreate the PR fresh under the current config, not because the version is being suppressed. Live-tested at rollout scale: rolling out an explicit target-branch org-wide orphaned a real batch of pre-existing PRs across two sweeps, all closed with zero recreation issues.

Because this comment only appears after Dependabot has actually tried and failed to match an entry (e.g. in response to @dependabot rebase), a config change doesn't retroactively surface every orphaned PR by itself — something has to prompt Dependabot to re-check each PR first. Scan open Dependabot PRs' own comments for this exact message to find every orphaned PR org-wide, rather than assuming a config change orphaned nothing just because no comments have appeared yet.

PR-time gate & auto-merge

See critical-vuln-gate.yml and the Critical Vulnerability Check / Dependabot Auto-Merge rows in Starter Templates for the reusable-workflow/template pairing. Both caller templates trigger on pull_request_target (not pull_request — see Known pitfalls below), scoped to branches: [main, develop].

dependabot-auto-merge.yml merges a Dependabot PR only when all of:

  • Actor is dependabot[bot] (both jobs gate on this explicitly)
  • dependabot/fetch-metadata@v3 reports a patch-level semver bump (never minor/major)
  • Ecosystem is npm, NuGet, pub, Bundler, or GitHub Actions — never Docker (base-image bumps always need a human)
  • If, and only if, the PR targets main: no open critical Dependabot alert on the repo (re-checked here explicitly via its own vuln-gate job — reusable-workflow jobs can't needs: a job defined in a different workflow file, so this can't just piggyback on the check template's result)

"Auto-merge" arms GitHub's native auto-merge feature (gh pr merge --auto --squash) — it still waits for the repo's own required status checks (build/test) to pass before actually merging. It does not bypass CI.

Why the critical-alert check is scoped to main only: matches the two-layer policy below — develop's branch protection never requires critical-vuln-check.yml, so a human merging a patch bump into develop by hand is never blocked by an open alert. Auto-merge must not be stricter than a human would be: gating it on the same repo-wide alert for a develop-bound PR would refuse to arm auto-merge there for no enforced reason, and since alerts only clear once a fix lands on the default branch, an unrelated open alert could stall a develop-bound patch bump indefinitely. vuln-gate's own if: now checks github.event.pull_request.base.ref == 'main'; auto-merge's if: uses always() plus explicit branch-aware logic so it isn't skipped when vuln-gate itself was skipped for a develop-bound PR, while still requiring vuln-gate to have succeeded for a main-bound one.

Enforcement is two layers, deliberately redundant

  • PR-time (critical-vuln-check.yml + branch protection): on main, mark the check required in branch protection — merge is physically blocked while any critical alert is open. On develop, leave it present but not required — a visible red check, no block.
  • Build-time (embedded directly as an early job in 10 of the 11 other reusable workflows in this repo — everything except the chart-lint-only gateway-chart-cicd.yml — triggered on push, not pull_request): re-checks at actual deploy time. This is not subject to the Dependabot secrets restriction below (that restriction is specific to pull_request-family events; push never carries it), and it's the real safety net on any repo where branch protection can't enforce anything at all — e.g. private repos on a plan tier below GitHub Team/Enterprise, where classic branch protection is unavailable outright (403: Upgrade to GitHub Pro). The build-time gate still blocks an actual release even with zero branch protection configured.

PR-branch verification (npm only) — breaking the fix-your-own-block deadlock

A critical alert only clears once its fix lands on the default branch — GitHub never re-scans a PR's own branch. That's a real deadlock on main: a PR that itself contains the fix for the only open critical alert could never merge, because the alert was still "open" by definition until that exact merge happened.

check-critical-vulns breaks this for npm specifically. When it runs under pull_request_target (i.e. from critical-vuln-gate.yml, not the build-time embedded uses, which run on push and have no PR to compare against), it re-checks every open npm-ecosystem alert against the PR's own HEAD branch: it reads the flagged package's lockfile (package-lock.json or yarn.lock — not the manifest, which shows a requested range, not the resolved version) at the PR head ref, and if every resolved occurrence of that package falls outside the alert's vulnerable_version_range, that alert no longer counts against this PR. Checking every occurrence matters: the same package commonly resolves to different versions at different points in the dependency tree (confirmed live: form-data resolved to both a patched version nested under one dependency and a vulnerable version at the top level, in the same lockfile) — one unpatched occurrence still means the vulnerability is present.

Lockfile fallback: if the alert's recorded manifest_path genuinely 404s at the PR head (the PR deleted it — e.g. it migrated package-lock.jsonyarn.lock as part of the fix, confirmed live on gig-insureapp-survey-mobile#40), the action also tries the sibling package-lock.json/yarn.lock in the same directory before giving up, and parses whichever one actually returned content by its format, not the alert's originally-recorded one. A Lockfile migrated notice is logged whenever the fallback path fires, so a passing gate stays auditable. Same directory scope as the original manifest either way, so this can't cross into an unrelated sub-package's lockfile in a monorepo.

Only a confirmed 404 triggers the fallback — not merely "empty content." The Contents API also returns HTTP 200 with encoding: "none" and an empty .content for a file that still exists but exceeds its ~1MB inline-read cap (real lockfiles routinely do — confirmed live against a real 1.7MB yarn.lock), which would otherwise be indistinguishable from a deletion. Treating that the same as a 404 would let a stale/coexisting sibling lockfile of a different package manager silently stand in for a real, still-vulnerable, merely-too-large manifest — a false clear on a genuinely open critical alert. So the fetch loop checks the actual HTTP status first: only a real 404 advances to the next candidate. A 200-with-encoding:none, a 403 "too large" (files over the API's ~100MB hard limit), a 200 whose body isn't a single "file" (a directory listing comes back as a JSON array; a symlink/submodule has no .content either), or any other non-2xx logs a warning and stops the search immediately, leaving the alert blocking exactly as before this feature existed.

Version comparison uses the real semver npm package (npx semver <version> -r <range>), not string comparison — GitHub's vulnerable_version_range uses comma-separated AND clauses (e.g. ">= 1.0.0, < 2.3.4"), converted to node-semver's space-separated form before evaluation. Before trusting any per-alert result, the action first probes npx semver against a known in-range case (semver 1.2.3 -r '>=1.0.0' must print 1.2.3) — the CLI prints nothing and exits non-zero both when a version is genuinely out of range and when npx itself can't run at all (registry unreachable, no network), so an unchecked failure here would silently read as "not vulnerable" for every npm alert in the run. If the probe fails, PR-branch verification is skipped entirely for that run (all alerts stay blocking), the same fail-closed fallback as a missing github-token.

Scope, deliberately narrow: this only applies to the npm ecosystem for now (NuGet, Composer, pip, Maven, GitHub Actions, and Docker all still fail closed, exactly as before — a genuinely open, unrelated critical alert still blocks the PR, whatever ecosystem it's in). An org-wide audit (2026-07-14) found npm makes up 96% of open critical alerts here, so this covers the overwhelming majority of real cases; the rest still need the manual fix_started dismissal path (see Known pitfalls below) as a fallback.

Fails closed, always: non-npm ecosystem, no lockfile match at the PR head (even after the sibling-file fallback above), a lockfile that exists but is too large for the Contents API to return inline, an unsupported/unrecognized lockfile format (pnpm-lock.yaml has no parser yet), a parse failure, a broken/unreachable npx semver, or a missing github-token input — any of these leaves that alert counted as still-blocking, same as before this feature existed. Nothing here can silently let a genuinely-still-vulnerable PR merge.

Safety under pull_request_target: this is pure static text parsing of the lockfile via the Contents API — it never checks out or executes the PR's own code (no npm ci, no dotnet restore, no package-manager invocation against untrusted content), so it doesn't reintroduce the code-injection risk pull_request_target is normally dangerous for. github-token (forwarded as secrets.GITHUB_TOKEN from critical-vuln-gate.yml, which already grants contents: write) is used only to read the lockfile — composite actions can't read the secrets context directly, so it must be passed in explicitly even though it's just the workflow's own ambient token.

Secrets — why a real PAT, not GITHUB_TOKEN

The Dependabot Alerts REST API (GET /repos/{owner}/{repo}/dependabot/alerts) rejects the ephemeral Actions GITHUB_TOKEN outright ("Resource not accessible by integration") — confirmed by live testing, not a permissions: scoping issue. No amount of permissions: configuration anywhere in the call chain fixes it. DEPENDABOT_ALERTS_TOKEN — a PAT or GitHub App installation token with "Dependabot alerts: read" — must be forwarded explicitly through every caller in the chain (secrets: inherit does not apply to custom secrets crossing a workflow_call boundary the same way it does for GITHUB_TOKEN). Missing/empty token → the gate fails closed with an explicit Missing dependabot-alerts-token error rather than silently passing.

Known pitfalls (already fixed org-wide — do not reintroduce)

1. pull_request silently strips secrets from Dependabot's own PRs. GitHub treats any PR authored by dependabot[bot] like a fork PR for token/secrets purposes: a plain pull_request trigger gets zero repository secrets and a read-only GITHUB_TOKEN, no matter what permissions: requests — and since both gate workflows exist specifically to react to Dependabot's own PRs, this broke their entire reason for existing. It went unnoticed as long as testing only exercised human-authored PRs. Fix: both workflows use pull_request_target instead, which evaluates from the trusted base branch and gets full secrets/token access. This is safe here specifically because neither workflow ever checks out or executes the PR's own code — every step is a pure API call (Dependabot Alerts API, PR metadata via fetch-metadata, gh pr merge by URL) — which is exactly the case where pull_request_target doesn't carry its usual code-injection risk. Never revert either gate template back to a plain pull_request trigger.

2. A missing permissions: block causes total, invisible silence — not a loud failure.critical-vuln-gate.yml's own job requests contents: write + security-events: read. A reusable-workflow call can only narrow permissions from what its caller grants, never widen them — so a caller file missing its own top-level permissions: block doesn't fail at runtime, it fails to parse, and GitHub never triggers the workflow at all: no error, no failed check, nothing in the Actions tab, zero run history. The only symptom is a repo with zero check runs where there should be dozens. Every caller template copy must keep its permissions: block (critical-vuln-check.yml: contents: write, security-events: read; dependabot-auto-merge.yml: adds pull-requests: write).

3. Bulk @dependabot rebase (or any mass PR-comment action) hits GitHub's secondary rate limit far sooner than bulk file commits do. Comment/issue-creation endpoints are far more aggressively throttled than the Contents API (PUT .../contents/{path}, used by the packages: write and permissions-block rollouts above) — those never tripped this. Even a handful of concurrent workers posting comments can trip a rolling-window secondary limit that then blocks further comment creation regardless of how slowly you retry afterward. Keep bulk comment-posting single-threaded, paced at 2+ seconds between requests, and don't run other write-heavy scripts concurrently with it — they likely share the same abuse-detection budget.

4. A PR that fixes a critical npm alert by migrating lockfile format keeps failing the gate even though the vulnerability is actually gone. Confirmed live on gig-insureapp-survey-mobile#40: the dev deleted package-lock.json and moved to yarn.lock + resolutions as part of the fix. The alert's recorded manifest_path still said package-lock.jsonPR-branch verification tried to read that exact file at the PR head, got nothing (it no longer existed), and per its fail-closed design left all 5 alerts blocking, even though every flagged package was already resolved to a patched version in the new yarn.lock. Fixed org-wide (2026-07-23): check-critical-vulns now also tries the sibling package-lock.json/yarn.lock in the same directory before giving up, and parses whichever one actually has content — see Lockfile fallback above. Still fails closed for anything neither file can explain (e.g. a migration to pnpm-lock.yaml, which has no parser) — dismiss those alerts manually (reason fix_started) once you've confirmed the fix, same as any other unsupported-ecosystem alert.

Patch/minor group split — rolled out org-wide

Every grouped ecosystem (npm, nuget, pub, github-actions — see dependabot.yml templates above) originally grouped ["minor", "patch"] bumps into one shared PR. Rolled out org-wide as a plain config edit to each repo's default-branch dependabot.yml (no code change, so pushed with [skip ci] to avoid triggering all 242 repos' build/deploy pipelines for zero code reason): each {ecosystem}-minor-patch group split into two — {ecosystem}-patch (patch only) and {ecosystem}-minor (minor only). docker and bundler were never grouped and are unaffected — they were already one-PR-per-dependency.

Side effect on auto-merge coverage, not just PR hygiene:dependabot/fetch-metadata reports a grouped PR's update-type as the highest semver level present in the group. Under the old combined group, a batch that was mostly patch bumps plus a single minor bump would report as minor — never eligible for auto-merge — even though most of its contents were trivial. Since {ecosystem}-patch groups are now patch-only by construction, a genuinely all-patch batch reports correctly as patch and now auto-merges where it previously wouldn't have (still subject to the existing eligibility rules above — required checks, main-only critical-alert gate, never Docker).

Renaming the group is itself a dependabot.yml entry identity change — every PR still open under the old {ecosystem}-minor-patch group name became orphaned by this rollout (see Orphaned PRs above) and was closed for Dependabot to recreate fresh under the new groups. Any future change to a group's name/update-types will orphan its in-flight PRs the same way — expect and budget for that scan-and-close step as part of the change, not as a surprise afterward.

dependabot.yml must not exist on develop, only the default branch (reiterating Where the file must live vs. target-branch above) — a rollout mistake earlier in this project's history left a stray copy on develop in 131 repos. It was inert (Dependabot never reads it from there) but if develop were later merged into main through a normal merge/PR, that stale copy could have silently overwritten the current, correct config. Fixed by deleting the stray copies outright rather than keeping them in sync — there is never a reason for this file to exist outside the default branch.

Current scope

  • Deployed to every active repo in the simplify9 org.
  • Repos with a genuine open critical alert correctly gate main (or show a non-blocking warning on develop) until the alert is resolved or dismissed — this is expected behavior, not a bug.
  • Patch and minor bumps group separately per ecosystem (never combined); major bumps are always individual, ungrouped PRs.

React Native version contract — why "semver-patch" is not a safety property

react-native advertises a loose peer range while its own bundled renderer hard-codes an exact React version and throws at runtime:

// react-native/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js — module top levelvarisomorphicReactPackageVersion=React.version;if("19.2.3"!==isomorphicReactPackageVersion)throwError('Incompatible React versions: ...');

react-native@0.85.1 declares peerDependencies: { react: "^19.2.3" }, so bumping react to 19.2.8 is a legal semver-patch update. It installs cleanly, type-checks, lints, passes jest, and bundles successfully — then throws on the first real screen. Confirmed live: mealivery-customer-mobile PR #89 auto-merged exactly this and broke the app.

On the New Architecture this is a deferred crash, not a launch crash.renderElement() correctly routes to Fabric (which carries no version check), but RendererImplementation.js binds six APIs to the Paper renderer unconditionally, regardless of newArchEnabledfindNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent, findHostInstance_DEPRECATED, unmountComponentAtNodeAndRemoveContainer, isChildPublicInstance. The first call to any of them loads Paper and throws. That set is reached by ScrollView, FlatList/VirtualizedList, TextInput, and any Animated node using useNativeDriver — so it fires on the first content screen, after Sentry.init() has run but typically before its transport can flush.

What does and does not catch this (each verified by experiment, not assumed):

GateCatches it?Why
npm/yarn install^19.2.3 genuinely permits 19.2.8
tsc --noEmit, eslinttypes and lint are unaffected
jest + react-test-renderer renderrendering uses Fabric; Paper is never loaded
npx react-native bundleMetro builds a static graph and never evaluates module bodies
rn-contract / checkcompares resolved react against the renderer's own literal
Native build / E2E smokebut ~10× the cost on macOS runners

Crash reporting does not catch it either. Confirmed on mealivery-customer-mobile: with the mismatch in place the app reliably crashes on demand, and neither Sentry nor Crashlytics recorded anything — despite Sentry being correctly initialised (enabled: !__DEV__, and Sentry.init() runs during module evaluation, well before the throw). An uncaught JS error in a release build unwinds through ErrorUtils to RCTFatal and aborts the process before the transport can flush. Treat this as a monitoring blind spot for the whole class, not a misconfiguration: for this failure mode the PR gate is not a convenience, it is the only signal you get. Nothing downstream will tell you.

Hence workflow-templates/react-native-contract-check.yml.github/workflows/react-native-contract-gate.yml.github/actions/check-react-native-contract. It reads the repo's lockfile (not just package.json, since the lockfile decides what installs) and extracts the expected version from the shipped renderer — node_modules when present, otherwise ~200KB from the CDN, never the ~32MB tarball. Runs in seconds on ubuntu-latest.

Three deliberate design choices:

  • Plain pull_request, not pull_request_target. Unlike critical-vuln-check.yml, this gate needs the PR's ownpackage.json and lockfile, which pull_request_target cannot see. It is safe on pull_request because it needs no secrets (a read-only GITHUB_TOKEN is enough) and never installs or executes PR code. Never add a yarn install step to it — that would turn a safe trigger into an arbitrary-code-execution surface for freshly-published dependency code.
  • Fails closed when it cannot determine the contract. RN ≥ 0.86 deletes the Paper renderer entirely, so the assertion is gone; the checker falls back to Fabric's reconcilerVersion (still exact) and only then gives up. A gate that silently passes when it cannot check manufactures confidence, which is worse than no gate. Override per-repo with fail-on-unknown: false.
  • Also fails on duplicate react. Two copies in the tree means the renderer captures one instance's internals while components use another — a separate, harder-to-diagnose failure.

Grouping is not the fix.dependabot-templates/react-native-mobile.yml groups react / react-native / @react-native/* / @types/react into one react-core PR so the lockstep set stays reviewable together, but Dependabot will still open a react-only PR when no matching react-native release exists. Exact-pinning is not the fix either — Dependabot rewrites exact pins (it did so twice in this repo). The gate is the fix, and it only blocks anything once rn-contract / check is marked required on the branch Dependabot targets — GitHub's auto-merge waits on required checks only.


Core Architecture & Conventions

concurrency — cancel PR checks, never cancel releases

Every caller template sets a concurrency block. The group is always <prefix>-${{ github.workflow }}-<per-PR or per-ref key>; what varies, and what matters, is cancel-in-progress:

Triggercancel-in-progressWhy
pull_request / pull_request_target checkstrueA developer iterating pushes five times; only the newest result is meaningful. Cancelling the four superseded runs costs nothing and bills nothing. These checks produce no artifact and deploy nothing.
push release/publish workflowsfalseA superseded run may already be mid-upload to TestFlight, Play, a registry or a cluster. Cancelling there can leave a partial release. Queue instead.
concurrency:
group: rn-contract-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}cancel-in-progress: true

github.event.pull_request.number || github.ref rather than github.ref alone: on a pull_request event github.ref is refs/pull/<n>/merge, which is unique per PR and would work, but the explicit form keeps the group readable and stays correct if the workflow ever gains a non-PR trigger.

Measured effect on the mobile fleet: 302 PRs/month against 492 push events, so cancellation removes ~39% of billed check runs. The absolute saving on Linux is small (these tiers cost single -digit dollars per month), but it applies for free and keeps the number bounded as PR volume grows.

Note on the existing mobile release templates:android-app.yml, ios-app.yml and the flutter-* pair are push-triggered and set cancel-in-progress: true. That predates this convention. It is tolerable because a cancelled build simply produces no artifact, but a push landing during the upload/distribute stage could interrupt it — worth revisiting separately.

Two-Layer Pattern

Caller repository 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)

Callers only call reusable workflows. Composite actions are internal building blocks (except simple utilities like determine-semver / tag-github-origin). Workflows reference actions via the external simplify9/.github/.github/actions/<name>@main path.

Versioning — determine-semver

Every pipeline that produces a deployable artifact computes its version with determine-semver: it reads the highest existing major.minor.N git tag and outputs major.minor.(N+1). After a successful deploy/publish, tag-github-origin writes the tag back so the next run increments from it. Branch behavior is controlled by release-branch: github.event.repository.default_branch — the default branch yields a clean release version + tag; other branches yield a qualified prerelease (x.y.z-<branch>.<run>).

Branch-to-Environment Mapping

Per-branch gating lives in the caller (template), not inside the reusable workflows:

BranchTypical useHow it's wired
stagingStaging/devCaller job if: github.ref == 'refs/heads/staging' → staging GitHub Environment
main / masterProductionCaller job if: github.ref == 'refs/heads/main' → production GitHub Environment

Mobile workflows additionally gate the release job on release-environment != '' && !disable-release.

Helm Values vs Helm Secret Values

The most important security pattern in the repo. Never put secrets into helm-set-values.

ParameterPassed asHelm flagUse for
helm-set-valuesWorkflow input--setNon-sensitive config: replicas, ingress, environment label
helm-set-secret-valuesWorkflow secret--set-stringSensitive data: DB connection strings, API keys

--set-string bypasses Helm type coercion and prevents =, SSL:, // in connection strings from being misinterpreted.

# Correctwith:
helm-set-values: 'replicas=2,ingress.enabled=true,environment=production'secrets:
helm-set-secret-values: ${{ secrets.MY_DB_CONNECTION_STRING }}# Wrong — secret exposed as a plain workflow inputwith:
helm-set-values: 'db=${{ secrets.DATABASE_URL }}'

Pinned Tool Versions

ToolPinned version
actions/checkout@v7
actions/setup-node@v6
actions/setup-dotnet@v5
actions/setup-java@v5
actions/upload-artifact@v7
actions/download-artifact@v8
actions/cache@v5 (some CF workflows @v4)
azure/setup-helm@v5
azure/setup-kubectl@v5
docker/setup-buildx-action@v4
docker/setup-qemu-action@v4
docker/login-action@v4
docker/metadata-action@v6
docker/build-push-action@v7
cloudflare/wrangler-action@v4
gradle/actions/setup-gradle@v5
ruby/setup-ruby@v1
maxim-lobanov/setup-xcode@v1
apple-actions/upload-testflight-build@v5
r0adkll/upload-google-play@v1

Helm/kubectl CLI: the deploy/package actions default to latest; some workflows pin (helm-deploy-values.yml: Helm v4.2.0 / kubectl v1.33.0; gateway-chart-cicd.yml: Helm v4.2.2). gradle/actions/setup-gradle is pinned to @v5; do not switch to the archived gradle/gradle-build-action and do not add cache: gradle to setup-java (it conflicts with setup-gradle).


Repository Metadata Standard

Every active repo in the org must have a one-line description and 3–6 topics. They are the org's lightweight service catalog: the repo list becomes self-explanatory, and GitHub search / the API can filter by facet (e.g. org:simplify9 topic:api topic:dotnet, org:simplify9 topic:mobile).

Description

  • One sentence, sentence case, no trailing period, ≤ 120 characters.
  • Says what the repo is and for which product: Backend REST API for the <Product> platform — never <Product> repo.
  • No secrets, hostnames, or contract details — descriptions are visible to every org member.

Topics

3–6 lowercase topics, one per facet, in this order (minimum 2 only where a facet is genuinely unknowable — empty stubs, org-config repos):

#FacetValues
1Product / clientThe product prefix of the repo name (lowercase). Org-level repos (SW-* libraries, infrastructure-*, this repo) use simplify9
2Sub-productOnly when present in the name (e.g. iot, last-mile)
3Component (exactly one)api, web, website, mobile, cms, library, integration, infrastructure, docs, desktop, monorepo, data
4Stack (1–2)dotnet, react, nextjs, react-native, expo, flutter, nodejs, nestjs, strapi, medusa, php, laravel, python, java, kotlin, swift, kubernetes, helm, terraform, ansible, rabbitmq, mqtt, elasticsearch, …
5Lifecycle (when true)legacy, poc, nuget, open-source

Example (public library repo): SW-CloudFiles → description .NET abstraction over cloud file storage providers (S3, Azure, GCS, OCI) using streams and ASP.NET Core DI, topics simplify9 library dotnet nuget open-source.

Rules:

  • Controlled vocabulary only — new topics require updating the standard first. A half-consistent taxonomy is worse than none.
  • Public repos may add up to 3 extra ecosystem topics (e.g. actions, reusable-workflows) for external discoverability.
  • New repos must be created with description + topics. Drift is corrected with scripts/backfill-repo-metadata.py (dry-run by default, --apply to write), driven by an internal reviewed CSV — the mapping CSV and the full client vocabulary are internal-only and are not committed to this public repo.

Troubleshooting

Checkout fails: No url found for submodule path '.claude/worktrees/...' in .gitmodules

Symptom — every job that runs actions/checkout dies at the end (the "Removing auth" / submodule cleanup step) with exit code 128, even though the checkout itself succeeded:

Error: fatal: No url found for submodule path '.claude/worktrees/agent-xxxxxxxx' in .gitmodules
Error: The process '/usr/bin/git' failed with exit code 128

This is not a problem with the reusable workflow — it's a corrupted state in your (the consumer) repo. A Claude Code git worktree under .claude/worktrees/ was accidentally committed. Because that directory contains its own .git, git recorded it as a gitlink (a 160000 tree entry, the same way submodules are stored) but there's no matching entry in .gitmodules. actions/checkout's teardown runs git submodule foreach --recursive, hits the URL-less gitlink, and fails. It surfaces on whichever job checks out first (often the versioning job).

Fix — in your repo, remove the stray gitlink and stop it recurring:

git rm --cached -r .claude/worktrees # drop the gitlink from the index (keeps working tree)echo".claude/">> .gitignore
git commit -m "Remove stray .claude worktree gitlink breaking CI checkout"
git push

If the recursive form complains, target the exact path (git rm --cached .claude/worktrees/agent-xxxxxxxx). Verify none remain before pushing — this should print nothing:

git ls-files -s | grep 160000 # any output = a stray gitlink still tracked

If the bad commit is on more than one branch, repeat on each affected branch.

Deploy job shows as "skipped"

  • For reusable-service-cicd.yml, set deploy: true (publishing happens regardless; deploying is opt-in).
  • For mobile workflows, the release job needs a non-empty release-environment and disable-release: false.
  • Confirm the relevant kubeconfig secret is set and base64-encoded (kubeconfig for ingress-nginx, kubeconfig-gateway for gateway-api).

Build directory not found (Vite / Next.js)

  • Vite (vite-cloudflare-worker.yml): set assets_dir: dist.
  • Next.js (next-cloudflare-worker.yaml): default assets_dir is .open-next/assets — change only if your build differs.

Helm parse error: "SSL: command not found" or malformed --set value

A secret value contains special characters being parsed by the shell. Move it from helm-set-values to helm-set-secret-values (a workflow secret, applied with --set-string).

iOS: "No matching provisioning profile"

Confirm the provisioning profile matches the bundle ID and team ID. Manual signing is mandatory in CI; the certificate is imported into a temporary keychain before the archive step.

Android versionCode conflicts on Play Console

Increase version-code-offset (default 80000) above your previous CI system's last published versionCode.

Certificate issuance times out / stays pending (Gateway API)

The pipeline runs a DNS pre-flight and purges failed ACME Orders. The two most common causes:

  • DNS not pointing to the gateway — create the A record before running.
  • Cloudflare orange-cloud proxy — cert-manager's HTTP-01 self-check can't traverse the proxy; set the record to DNS-only (grey cloud) for issuance, then re-enable once Ready.

kubeconfig not working

Ensure it is base64-encoded before storing as a secret:

base64 -w 0 ~/.kube/config # Linux
base64 -b 0 ~/.kube/config # macOS

(Workflows also accept raw-YAML kubeconfig; both are auto-detected.)


Contributing

  1. Create a feature branch.
  2. Test changes by calling the workflow/action from a separate repo on a branch (point a caller at @<branch>) and watching the Actions run — there is no local test runner.
  3. Follow all conventions in AGENTS.md — pinned action versions, the 4-pillar log framework, composite-action shell requirements, and secrets-vs-inputs.
  4. If you change a workflow that has a starter template, update the paired workflow-templates/<name>.yml and .properties.json.
  5. Update both README.md and AGENTS.md in the same change.

Key rules:

  • Every run: step in a composite action must have shell: bash.
  • All new inputs need description: and a sensible default: or required: true.
  • Do not add on: push: / on: pull_request: triggers to files in .github/workflows/.
  • Secrets go under on.workflow_call.secrets:, never as inputs.
  • Optional deploys are gated (deploy: false) or bound to a GitHub Environment.

About

Organization-wide shared GitHub Actions workflows, composite actions, and starter templates for Simplify9 projects

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors