Organization-wide shared GitHub Actions workflows, composite actions, and starter templates for Simplify9 projects. Callers reference this repo with
@main— no versioned tags exist.
- Which Workflow Should I Use?
- Starter Templates
- Prerequisites — Secrets
- Where Jobs Run
- Repository Structure
- Workflow Reference
- Composite Action Reference
- Dependabot
- Dependency automation (Renovate)
- Core Architecture & Conventions
- Repository Metadata Standard
- Troubleshooting
- Contributing
| Stack | Deployment target | Use this workflow |
|---|---|---|
| Next.js (SSR, OpenNext adapter) | Cloudflare Workers | next-cloudflare-worker.yaml |
| Vite single-page app | Cloudflare Workers (static assets) | vite-cloudflare-worker.yml |
| Containerized service (any stack, .NET-friendly) | Docker + Helm (GHCR OCI / ChartMuseum), optional K8s deploy | reusable-service-cicd.yml |
| .NET library (NuGet packages only, no Docker/Helm) | NuGet feed (nuget.org by default) | reusable-nuget-publish.yml |
| Service deployed over ingress-nginx | Docker + s9genericchart -> Kubernetes |
generic-chart-helm.yml |
| Service deployed behind the Cilium Gateway API | Docker + s9genericchart-v2 -> Kubernetes |
generic-gateway-helm-template.yml |
| Deploy an already-published chart from a values file | Kubernetes via Helm | helm-deploy-values.yml |
| Cilium Gateway API-aware Helm chart (chart dev) | ChartMuseum | gateway-chart-cicd.yml |
| iOS app (React Native or native) | TestFlight | ios-build.yml |
| Android app (React Native) | Google Play | android-build.yml |
| iOS app (Flutter) | TestFlight | flutter-ios-build.yml |
| Android app (Flutter) | Google Play | flutter-android-build.yml |
All workflows are reusable (on: workflow_call:). Call them from your repo:
uses: simplify9/.github/.github/workflows/<name>.yml@mainThis 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") | Wraps | Default triggers |
|---|---|---|
| Service CI/CD Pipeline | reusable-service-cicd.yml |
push -> main, workflow_dispatch |
| NuGet Library Publish | reusable-nuget-publish.yml |
push -> main, workflow_dispatch |
| Generic Chart Helm CI/CD | generic-chart-helm.yml |
push -> develop/main, workflow_dispatch |
| Next.js + Cloudflare Workers | next-cloudflare-worker.yaml |
push -> develop/main, workflow_dispatch |
| Vite + Cloudflare Workers | vite-cloudflare-worker.yml |
push -> develop/main, workflow_dispatch |
| Android App CI/CD | android-build.yml |
push -> develop/main, workflow_dispatch |
| iOS App CI/CD | ios-build.yml |
push -> develop/main, workflow_dispatch |
| Flutter Android App CI/CD | flutter-android-build.yml |
push -> develop/main, workflow_dispatch |
| Flutter iOS App CI/CD | flutter-ios-build.yml |
push -> develop/main, workflow_dispatch |
| Critical Vulnerability Check | critical-vuln-gate.yml |
pull_request_target -> main, develop |
| Dependabot Auto-Merge | critical-vuln-gate.yml |
pull_request_target -> main, develop |
| React Native Contract Check | react-native-contract-gate.yml |
pull_request -> main, develop |
| Verify | verify.yml |
pull_request -> main, develop, development, staging, gigstaging; workflow_dispatch |
Set these as Organization or repository secrets. Names below are the secret keys the workflows expect under secrets: (some workflows accept secrets: inherit).
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)
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)
nuget-api-key # NuGet API key (required; org key: secrets.SWNUGETKEY)
nuget-source # NuGet feed URL (optional, defaults to nuget.org v3)
nuget-restore-token # Private-feed restore token (optional), exposed as NUGET_AUTH_TOKEN for a nuget.config credential %NUGET_AUTH_TOKEN%
dependabot-alerts-token # PAT/App token with "Dependabot alerts: read", for the release-time critical-vuln gate (pass secrets.DEPENDABOT_ALERTS_TOKEN)
github-token # Tags the origin (optional, falls back to built-in GITHUB_TOKEN)
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
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
Every Linux job in these workflows uses
runs-on: ${{ vars.S9_RUNNER || vars.S9_HOSTED_RUNNER || 'ubuntu-24.04' }}
(Android build jobs: S9_RUNNER_ANDROID). If your repository can see
S9_RUNNER, its jobs run on Simplify9's self-hosted runners; otherwise on the
GitHub-hosted label in the organisation variable S9_HOSTED_RUNNER
(ubuntu-24.04). Callers change nothing. Public repositories are never given
S9_RUNNER. macOS/iOS jobs always run on GitHub-hosted macOS. The Android
workflows' ubuntu-runner input, if set to anything but ubuntu-latest,
still wins.
On the self-hosted runners, the Node workflows (vite-cloudflare-worker,
next-cloudflare-worker) do not use setup-node's GitHub cache: downloading
it from GitHub there is slower than a fresh install (measured 46-105 s against
21-26 s for a ~700 MB yarn cache). Image builds likewise use the runners'
shared builder instead of the GitHub cache. On GitHub-hosted runners both
behave as before.
Why not ubuntu-latest: GitHub moves it to Ubuntu 26.04 between 2026-10-19
and 2026-11-19 (actions/runner-images #14748). Keeping the label in one
organisation variable means the move to 26.04 happens once, deliberately,
for every repository.
.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
│ │ ├── reusable-nuget-publish.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
│ │ └── verify.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/
│ │ ├── verify-plan/ verify-contract/ verify-web-install/
│ │ ├── verify-node-deps/ verify-web-smoke/ strapi-rehearsal/
│ │ ├── verify-web-e2e/ verify-netblock/ verify-secret-scan/ verify-dotnet-tests/ verify-mocks/
│ │ ├── verify-emulator/ verify-maestro/
│ │ ├── write-job-summary/
│ │ └── check-critical-vulns/
│ └── dependabot.yml <- this repo's own Dependabot config (github-actions only)
├── starters/ <- copyable test starters per stack (web-playwright: web e2e journeys; dotnet-integration: WebApplicationFactory + Testcontainers; react-native-maestro: Maestro flows + mocks)
├── workflow-templates/ <- org starter templates (*.yml + *.properties.json)
└── dependabot-templates/ <- ready-made per-category `dependabot.yml` configs for consumer repos to copy
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.
Builds a Next.js app with the OpenNext.js Cloudflare adapter and deploys it to Cloudflare Workers.
| Input | Required | Default | Description |
|---|---|---|---|
project_name |
Yes | — | Base Worker project name (without env suffix) |
environment |
Yes | — | Wrangler environment (staging, production) |
route |
'' |
Route / custom domain (falls back to repo var CLOUDFLARE_ROUTE then ROUTE) |
|
package_manager |
yarn |
npm, yarn, or pnpm |
|
node_version |
24 |
Node.js version | |
opennextjs_version |
1.20.1 |
@opennextjs/cloudflare adapter version |
|
compatibility_date |
2026-05-01 |
Cloudflare compatibility date | |
assets_dir |
.open-next/assets |
Static assets directory | |
build_script |
build |
Build npm script | |
run_lint |
true |
Run 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@main
with:
project_name: my-nextjs-app
environment: production
route: myapp.com
secrets:
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}Required caller permissions: contents: write + security-events: read (critical-vuln gate) and packages: read (deploy job), exactly as for vite-cloudflare-worker.yml. Private @simplify9/* npm packages from GitHub Packages work the same way too (built-in GITHUB_TOKEN, scope-only .npmrc, one-time Manage Actions access grant): see that section.
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.
| Input | Required | Default | Description |
|---|---|---|---|
project_name |
Yes | — | Base Worker project name |
environment |
Yes | — | Wrangler environment |
route |
Yes | — | Route / custom domain |
assets_dir |
dist |
Vite build output directory | |
package_manager |
yarn |
npm, yarn, or pnpm |
|
node_version |
24 |
Node.js version | |
compatibility_date |
2026-05-01 |
Cloudflare compatibility date | |
pre_build_commands |
'' |
Optional shell commands run before the build | |
run_lint |
true |
Run 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,
routeis required here.
Required caller permissions: contents: write + security-events: read (critical-vuln gate) and packages: read (deploy job). A called workflow can't elevate the token, so a caller whose permissions: block omits any of these fails before a job starts ("The nested job 'deploy' is requesting 'packages: read', but is only allowed 'packages: none'").
permissions:
contents: write
security-events: read
packages: readPrivate @simplify9/* npm packages (GitHub Packages): the deploy job exports the built-in GITHUB_TOKEN (no PAT or extra secret) and configures auth for npm.pkg.github.com itself, so the repo's .npmrc only needs the scope mapping:
@simplify9:registry=https://npm.pkg.github.comA committed //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} line also works in CI, but it makes Yarn 1 refuse to run on any machine where GITHUB_TOKEN isn't exported, so prefer the scope-only form and set the token per machine (npm config set //npm.pkg.github.com/:_authToken <token with read:packages>). One-time per consuming repo: open each package's settings -> Manage Actions access -> add the repo with Read, or installs return 401/403.
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).
| Input | Required | Default | Description |
|---|---|---|---|
chart-name |
Yes | — | Helm chart name (must match Chart.yaml name:, unless rename-chart) |
rename-chart |
false |
Boolean — publish the chart as chart-name, setting Chart.yaml name: before packaging, so one chart directory can also ship under a second name (e.g. <name>-develop from a develop branch) |
|
chart-publish-method |
both |
github-oci, chartmuseum, or both (empty/unknown hard-fails) |
|
chart-repo-url |
— | ChartMuseum base URL (required for chartmuseum/both) |
|
chart-path |
./chart |
Helm chart directory | |
container-registry |
ghcr.io |
Container registry | |
image-name |
(repo name) | Docker image name | |
dotnet-version |
10.0.x |
.NET SDK (for NuGet/tests) — see .NET SDK selection | |
nuget-projects |
'' |
NuGet project glob(s); one or more, space- or newline-separated (YAML | block). Empty = skip NuGet |
|
run-tests / test-projects |
'false' / **/*Tests/*.csproj |
Run the test projects after the build. They run in the nuget job, which runs for them even when nuget-projects is empty (build and test, no pack/push) |
|
deploy |
false |
Deploy the published chart after publishing | |
routing-mode |
ingress-nginx |
ingress-nginx or gateway-api |
|
deploy-namespace |
playground |
Kubernetes namespace | |
deploy-environment |
Development |
GitHub Environment for the deploy job | |
helm-set-values |
'' |
Non-secret --set values |
|
gateway-hostnames |
'' |
Hostnames for the HTTPRoute (gateway-api) | |
gateway-section-name |
'' |
Shared listener every host attaches to (e.g. https-wildcard-bitween-io): no listener or Certificate is created, and the deploy fails before Helm runs if the Gateway lacks it. Empty: *.sf9.io hosts attach to https-wildcard-sf9-io, other hosts get a dedicated listener + Certificate (gateway-auto-onboarding) |
|
major-version / minor-version |
1 / 0 |
Semver 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@main
with:
chart-name: my-service
chart-publish-method: both
chart-repo-url: https://charts.sf9.io
major-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 NuGetSet chart-publish-method: github-oci to publish only to GHCR OCI (no ChartMuseum secrets needed — the OCI push uses the built-in GITHUB_TOKEN).
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.
| Input | Required | Default | Description |
|---|---|---|---|
app-name |
— | Application name (app.name Helm value) |
|
namespace |
development |
Kubernetes namespace | |
deploy-environment |
development |
GitHub Environment for the deploy job | |
container-registry |
ghcr.io |
Container registry | |
ingress-hosts |
— | Comma-separated ingress hosts | |
ingress-paths |
— | Comma-separated ingress paths | |
ingress-tls-secrets |
— | TLS secrets matching hosts by index | |
service-target-port |
— | Service targetPort |
|
environment |
— | environment Helm value (e.g. Development) |
|
helm-set-values |
— | Extra non-secret --set values |
|
image-pull-secret |
sf9cr |
image.pullSecret for the chart; empty renders no imagePullSecrets (clusters that pull through an attached registry; needs s9genericchart 1.0.8+) |
|
secret-values-prefix |
'' |
Prepended to every line of the helm-set-secret-values secret, e.g. environmentVariables. for a secret holding bare KEY=VALUE lines |
|
package-nuget |
false |
Build & publish NuGet packages | |
test-before-build |
false |
Run test-projects in their own job before the image build; if they fail, nothing is built or deployed |
|
test-projects |
**/*Tests/*.csproj |
Test projects for test-before-build and for NuGet packing (glob) |
|
dotnet-version |
10.0.x |
.NET SDK for test-before-build and NuGet packing — see .NET SDK selection |
|
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-version |
1 / 0 |
Semver 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.
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.
| Input | Required | Default | Description |
|---|---|---|---|
app-name |
— | Helm release / image name | |
namespace |
development |
Kubernetes namespace | |
routing-mode |
gateway |
gateway, 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-name |
public-gateway |
Gateway resource name | |
gateway-parent-namespace |
s9-dev-edge |
Gateway resource namespace | |
gateway-auto-onboarding |
true |
Provision listeners + cert for dedicated hosts | |
gateway-cert-issuer-name |
letsencrypt-production-gateway |
cert-manager issuer | |
gateway-cert-wait |
true |
Wait for the certificate to become Ready |
|
chart-name |
s9genericchart-v2 |
Helm chart name | |
chart-repo |
https://charts.sf9.io |
Helm repo URL | |
init-job-image |
'' |
Optional pre-deploy migration Job | |
helm-set-values |
— | Extra non-secret --set values |
|
major-version / minor-version |
1 / 0 |
Semver 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
| Mode | When to use | What onboarding does |
|---|---|---|
| Dedicated (default) | Custom domains (api-stg.zeenah.io) |
Creates HTTP + HTTPS listeners, issues a TLS cert via cert-manager HTTP-01 |
| Shared / wildcard | Internal 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@main
with:
app-name: my-api
namespace: my-api-dev
gateway-hostnames: api-stg.myapp.io
secrets:
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-api
namespace: my-api-dev
gateway-hostnames: my-api.sf9.io
gateway-section-name: https-wildcard-sf9-ioOne 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.io
gateway-section-names: |
https-wildcard-sf9-ioThe 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).
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.
| Input | Required | Default | Description |
|---|---|---|---|
release-name |
Yes | — | Helm release name |
chart-name |
Yes | — | Chart name |
chart-repo |
Yes | — | Classic (ChartMuseum-style) Helm repo URL |
namespace |
Yes | — | Kubernetes namespace |
values-file |
values.yaml |
Values file in the caller repo (applied if present) | |
chart-version |
'' |
Empty = latest published; an exact version pins it; a SemVer range (e.g. 10.x) takes the newest release in it. The job summary shows the version Helm resolved |
|
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). Emits the s9generic keys ingress.hosts[i].host / .tlsSecret and ingress.paths[i]; a chart whose ingress.hosts is a plain list sets ingress.hosts={a,b} in helm-set-values instead |
|
helm-set-values |
'' |
Non-secret --set values, one per line. A line is never split on commas: each is its own --set, so {a,b} lists and \, escapes work as on the Helm command line |
|
image-tag |
'' |
Image tag | |
helm-version |
v4.2.0 |
Helm CLI version | |
kubectl-version |
v1.33.0 |
kubectl 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_TOKEN — GITHUB_TOKEN cannot access this API regardless of granted permissions).
For .NET library repos that ship NuGet packages only (no Dockerfile, no Helm chart): compute semver -> critical-vuln gate (default branch only) -> restore, build, optionally test -> pack and push -> tag the git origin. The tag is created only after the push succeeds, so the next run always increments from a published version.
Use reusable-service-cicd.yml instead when the repo also ships a service; its nuget-projects input covers an SDK package. Don't point a library at reusable-service-cicd.yml with nuget-projects alone: its Docker/Helm ci job fails without a Dockerfile and chart, the tag job is then skipped, and every later run recomputes the same version, which --skip-duplicate silently no-ops.
| Input | Required | Default | Description |
|---|---|---|---|
nuget-projects |
Yes | — | .csproj path(s)/glob(s) to pack and push; space- or newline-separated (YAML | / >- block). Every project is still built |
major-version / minor-version |
1 / 0 |
Semver components | |
dotnet-version |
10.0.x |
.NET SDK (also builds older target frameworks such as net8.0) |
|
run-tests |
false |
Boolean — run test-projects after the build |
|
test-projects |
**/*Tests/*.csproj |
Test project glob |
Versioning: the package is pushed with determine-semver's git-tag output, so package and tag always match. On the default branch that is a clean release (8.1.19); from any other branch (e.g. workflow_dispatch) it is a SemVer prerelease (8.1.19-my-branch.42), so a branch build never ships as a stable version.
Secrets: nuget-api-key (required), nuget-source (defaults to nuget.org v3), nuget-restore-token (optional; restores from a private feed such as GitHub Packages — exposed to the restore/build step as NUGET_AUTH_TOKEN, which the repo's nuget.config must reference as %NUGET_AUTH_TOKEN%), dependabot-alerts-token (PAT/App token with "Dependabot alerts: read", for the release-time critical-vuln gate — GITHUB_TOKEN cannot access this API), github-token (tags the origin; falls back to GITHUB_TOKEN — pass a PAT only if tags must trigger other workflows).
Required caller permissions: contents: write (tag), packages: write (GitHub Packages feeds), security-events: read (critical-vuln gate).
Outputs: version (the pushed package version).
permissions:
contents: write
packages: write
security-events: read
jobs:
Publish:
uses: simplify9/.github/.github/workflows/reusable-nuget-publish.yml@main
with:
nuget-projects: >-
src/MyLib/MyLib.csproj
src/MyLib.Abstractions/MyLib.Abstractions.csproj
major-version: '2'
minor-version: '1'
secrets:
nuget-api-key: ${{ secrets.SWNUGETKEY }}
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}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.
| Input | Required | Default | Description |
|---|---|---|---|
chart-path |
chart |
Chart directory | |
chartmuseum-url |
https://charts.sf9.io/api/charts |
ChartMuseum upload endpoint | |
helm-version |
v4.2.2 |
Helm CLI version | |
major-version / minor-version |
1 / 0 |
SemVer components | |
update-dependencies |
true |
helm package --dependency-update |
|
validate-routing |
true |
Validate default/ingress/gateway/dual rendering | |
validate-configmap |
true |
Validate 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://...), asreusable-service-cicd.ymlalready supports, gives immutable, digest-pinned, signable charts and removes the standalone ChartMuseum dependency.
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.
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).
| Input | Required | Default | Description |
|---|---|---|---|
workspace |
Yes | — | Path to .xcworkspace |
scheme |
Yes | — | Xcode scheme to archive |
configuration |
Release |
Build configuration | |
xcode-version |
'' |
Xcode major or major.minor (e.g. 16.4) |
|
macos-runner |
macos-latest |
macOS runner label | |
node-version |
24 |
Node.js version (React Native) | |
package-manager |
yarn |
yarn or npm |
|
ios-dir |
ios |
iOS directory for pod operations | |
clean-reinstall-pods |
false |
pod deintegrate + pod install --repo-update |
|
enable-ccache |
true |
ccache for ObjC/C++ pod compilation | |
ruby-version |
'' |
Ruby version (empty disables Ruby setup) | |
use-bundler |
false |
Install gems via Bundler (needs ruby-version) |
|
marketing-prefix |
1.0 |
Marketing version start (X.Y or X.Y.Z) | |
release-environment |
ios-staging |
GitHub Environment for the release job | |
disable-release |
false |
Build 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, not GITHUB_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@main
with:
workspace: ios/App.xcworkspace
scheme: App
xcode-version: "26"
release-environment: ios-production
secrets:
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.
Builds and signs a React Native Android App Bundle (AAB) via Gradle and publishes it to Google Play (r0adkll/upload-google-play@v1).
| Input | Required | Default | Description |
|---|---|---|---|
app-id |
Yes | — | Android applicationId (package name) |
app-slug |
app |
Output AAB filename slug | |
gradle-task |
bundleRelease |
Gradle task | |
version-prefix |
1.0.0 |
Base version (X.Y or X.Y.Z) | |
version-code-offset |
80000 |
Added to github.run_number for versionCode |
|
java-version |
17 |
Java version (temurin) | |
node-version |
24 |
Node.js version (React Native) | |
package-manager |
yarn |
yarn or npm |
|
build-root-directory |
android |
Gradle project root | |
use-jetifier |
true |
Run npx jetify (AndroidX migration) |
|
free-disk-space |
true |
Reclaim preinstalled toolchains the build never uses (.NET, GHC, Swift, PowerShell, CodeQL, Docker images) before installing anything | |
android-ndk-version |
27.1.12297006 |
NDK to guarantee is installed. Set to your android/build.gradle ndkVersion to avoid a second, unused NDK; empty string skips NDK setup |
|
play-track |
internal |
internal, alpha, beta, production |
|
changes-not-sent-for-review |
false |
Use changesNotSentForReview (internal tracks) |
|
release-environment |
android-staging |
GitHub Environment for the release job | |
disable-release |
false |
Build 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, not GITHUB_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@main
with:
app-id: com.mycompany.myapp
gradle-task: bundleRelease
version-prefix: "2.0.0"
version-code-offset: "80000"
release-environment: android-production
play-track: production
secrets:
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 defaults to 27.1.12297006 (r27b LTS) and is installed via sdkmanager (not actions/cache — the Android SDK dir is root-owned), and is skipped when the runner image already ships that version. Use the Android App CI/CD starter template for a workflow_dispatch entry point.
Runner disk is the failure mode to know about. A release RN build (node_modules, jetifier rewrite, restored Gradle home, NDK, per-ABI native output, R8, bundletool) does not fit next to everything
ubuntu-24.04preinstalls. When the filesystem fills, the JVM's writes get truncated instead of refused, so Gradle blames whichever task happened to be writing — and the reported error is never the real one:
Reported error What actually happened :app:compileReleaseArtProfile—Class rules don't support flags, but 'HSP' were specifiedThe merged baseline-prof.txtwas cut off mid-token:app:signReleaseBundle/:app:packageReleaseBundle—Location not within channel boundariesThe AAB's zip central directory points past a truncated EOF java.io.IOException: No space left on deviceThe honest one — same cause Because it depends on when the disk fills, the same commit can fail and then pass on a re-run.
free-disk-space: true(the default) reclaims ~20 GB up front, and the build now prints free space before and after Gradle so this is visible in the log rather than inferred. If a build still fails on disk, the failure report says so explicitly. Apps that package several ABIs withdebugSymbolLevel 'FULL'are the ones that hit this first.
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.
| Input | Required | Default | Description |
|---|---|---|---|
macos-runner |
macos-latest |
macOS runner label (e.g. macos-26) |
|
xcode-version |
"" |
Xcode selector via maxim-lobanov/setup-xcode (empty = runner default) |
|
flutter-version |
3.x |
subosito/flutter-action version selector (pin exact for reproducible builds) |
|
flutter-channel |
stable |
Flutter 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-dir |
ios |
iOS project directory (Podfile + Runner project) | |
pbxproj-path |
ios/Runner.xcodeproj/project.pbxproj |
project.pbxproj that gets the manual-signing rewrite | |
app-slug |
app |
Output IPA filename slug | |
marketing-prefix |
1.0 |
Marketing version major/minor (X.Y only); patch stays automatic from pubspec + run number |
|
export-method |
app-store-connect |
ExportOptions.plist distribution method | |
run-analyze |
false |
Run flutter analyze before building |
|
ipa-name-pattern |
{app_slug}-{version}-{build_number}.ipa |
Output IPA name tokens | |
dart-define |
"" |
Space-separated KEY=VALUE pairs passed as --dart-define to flutter build ipa |
|
wait-for-processing |
false |
Poll App Store Connect until processing finishes (fire-and-forget by default) | |
release-environment |
ios-staging |
GitHub Environment for the release job | |
disable-release |
false |
Build 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, not GITHUB_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@main
with:
macos-runner: macos-26
xcode-version: "26.3"
app-slug: myapp
marketing-prefix: "1.0"
release-environment: ios-production
secrets:
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.
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).
| Input | Required | Default | Description |
|---|---|---|---|
app-id |
Yes | — | Android applicationId / Play package name |
app-slug |
app |
Output AAB filename slug | |
project-directory |
. |
Flutter project root relative to repo root (set to e.g. mobile for a monorepo) |
|
flutter-version |
3.x |
subosito/flutter-action version selector (pin exact for reproducible builds) |
|
flutter-channel |
stable |
Flutter channel | |
java-version |
17 |
Java version (temurin) | |
version-prefix |
1.0.0 |
Base version (X.Y or X.Y.Z) | |
version-code-offset |
80000 |
Added to github.run_number for versionCode |
|
free-disk-space |
true |
Reclaim preinstalled toolchains the build never uses (.NET, GHC, Swift, PowerShell, CodeQL, Docker images) before installing anything | |
run-analyze |
true |
Run flutter analyze before building |
|
analyze-fatal-level |
none |
flutter analyze fatal severity (none/warning/info) |
|
keystore-output-path |
android/app/release.keystore |
Where the decoded keystore is written | |
aab-name-pattern |
{app_slug}-release-{version_name}.aab |
Output AAB name tokens | |
dart-define |
"" |
Space-separated KEY=VALUE pairs passed as --dart-define to flutter build appbundle |
|
play-track |
internal |
internal, alpha, beta, production |
|
release-status |
draft |
Play release status (draft/completed/...) |
|
changes-not-sent-for-review |
false |
Use changesNotSentForReview (internal tracks) |
|
release-environment |
android-staging |
GitHub Environment for the release job | |
disable-release |
false |
Build 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, not GITHUB_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@main
with:
app-id: com.mycompany.myapp
app-slug: myapp
version-prefix: "2.0.0"
version-code-offset: "80000"
release-environment: android-production
play-track: production
secrets:
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.
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 11 of the 13 other reusable workflows in this repo (both Cloudflare workflows, all four Service & Backend workflows, the NuGet library workflow, and all four mobile workflows) — everything except the chart-lint-only gateway-chart-cicd.yml and the PR-time react-native-contract-gate.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. When the gate fails on real alerts it also uploads critical-vulns-<repo>-<run_id>.csv as a run artifact (unzipped, kept 5 days) — see Blocking-alerts CSV report.
jobs:
vuln-gate:
uses: simplify9/.github/.github/workflows/critical-vuln-gate.yml@main
secrets:
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}Notes: this is the same check embedded as a build-time gate in 11 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.
One reusable workflow that runs the per-stack verification ladder for a PR and reports a single result. The required status check is verify / result (the caller job is named verify): mark exactly that check as required in the org ruleset. result always reports, so a skipped ladder never leaves the check pending.
Scope. The full ladder runs on PRs into the repo's default branch, on Renovate PRs (author s9-deps-bot[bot], input bot-login) whatever their base, and on manual workflow_dispatch. A human PR into develop, development or staging skips every job except result, which passes immediately (about one billed minute). "Default branch" is read from the repository, not hard-coded to main.
Security. verify installs dependencies and runs the PR's own code (tests, builds, the app under a smoke test). Every job has a read-only token (contents: read, packages: read) and no deploy secrets; the caller template passes only dependabot-alerts-token and nuget-restore-token.
| Input | Required | Default | Description |
|---|---|---|---|
stack |
Yes | - | dotnet-service, dotnet-library, web, react-native, flutter, strapi or infra (infra runs no ladder jobs) |
working-directory |
. |
Project directory | |
known-failing |
'' |
Space-separated step ids tolerated until contract-deadline (see below) |
|
contract-deadline |
'' |
YYYY-MM-DD. Empty or invalid = warn mode and known-failing never expires |
|
bot-login |
s9-deps-bot[bot] |
Login whose PRs always get the full ladder | |
node-version |
24 |
Node.js (web, react-native, strapi) | |
dotnet-version |
10.0.x |
.NET SDK (dotnet stacks) | |
java-version |
17 |
Java (Android builds, Flutter) | |
flutter-version |
3.x |
Flutter (flutter stack) | |
smoke-path |
'' |
HTTP path probed by docker-smoke. Empty: /health for dotnet-service (on by default; skipped without a Dockerfile), off for dotnet-library. none turns it off |
|
docker-build-github-token |
false |
Boolean, opt-in. Mounts the job's GITHUB_TOKEN into the docker build as the BuildKit secret GITHUB_TOKEN (RUN --mount=type=secret,id=GITHUB_TOKEN) for GitHub Packages installs; never in the image. Applies to the docker rung and docker-smoke |
|
postgres-version |
(empty) | PostgreSQL major for the strapi job. Empty: the version in ci/fixture.sql.gz's header, else 17. A newer version restores dumps from older servers, never the reverse. |
Secrets (all optional): dependabot-alerts-token (informational vuln-report), nuget-restore-token (private NuGet feed).
Ladder per stack (ids are what known-failing accepts and what verify-report lists):
| Stack | Steps |
|---|---|
web |
install, consistency (singleton react/react-dom/next check; skipped on Yarn PnP), lint, typecheck, test, build, worker-dry-run (Workers projects), then the repo's test:e2e under the network block (e2e, see below) or, until the deadline, a built-in Chromium load that fails on page or console errors (also reported as e2e, not blocked); network when the journeys passed only with network access. Runs on the GitHub-hosted runner |
strapi |
node job: install, consistency; strapi job: install, build, rehearsal |
dotnet-service, dotnet-library |
build (tests off), consistency (dotnet restore --force-evaluate with NU1605/NU1608/NU1107/NU1603 as errors, catches a csproj bumped alone), test (unit test projects, see below), docker (no-push build when a Dockerfile exists); integration (hosted job: Testcontainers test projects); smoke (docker-smoke: on by default for dotnet-service, opt-in for libraries) |
react-native |
install, consistency (React/RN contract and align-deps), lint, typecheck, test, bundle; android (debug build); maestro (every .maestro/ flow on an emulator, network-blocked, see below); network when flows passed only with network access |
flutter |
install, analyze, test, build (debug APK; on GitHub-hosted runners the job first frees ~20 GB of unused toolchains, as the android job does) |
infra |
none: result passes when plan and contract succeed |
Web end-to-end (e2e). When package.json has a test:e2e script (Playwright; starter in starters/web-playwright/):
- Committed
e2e/**/*.har, the files next to each.har(response bodies of anattachrecording; source code and media excepted) ande2e/mocks.jsonare scanned withverify-secret-scan; personal data, real tokens, credential headers or cookies (pretty-printed or not) and passwords fail the step. Zipped recordings (e2e/**/*.zip) are refused, because they cannot be scanned. - Chromium is installed (the repo's Playwright version), then outbound network is blocked for everything
test:e2estarts, the app server included. Allowed:- loopback (the app, Mockoon)
- the hosts listed in
e2e/allowed-hosts.txt: third-party SDKs only, one host name or IP per line,#comments; no scheme, port, path or wildcard. Each is resolved once and pinned in/etc/hosts.
- With
e2e/mocks.json(a Mockoon environment file), Mockoon serves it on127.0.0.1:3901with proxying off, andE2E_API_URL=http://127.0.0.1:3901is exported for the app's server-side calls (for example, undernext start). test:e2eruns withCI=true,VERIFY_E2E=1andNEXT_TELEMETRY_DISABLED=1.- If it fails while the block rejected traffic, it re-runs once with network access.
- Passing then means the app needs a host that is neither mocked nor allowed:
e2epasses and thenetworkstep fails, naming the blocked destinations. Untilnetwork's own deadline (see "New steps start tolerated") that failure is a warning. - Failing again is a real
e2efailure. - Once
networkis enforced for the repo (its own deadline: the later ofcontract-deadlineand 2026-12-29), there is no re-run with network access: it would drive the app against real hosts. A failure while traffic was blocked failse2eand names the blocked destinations.
- The job summary names failed and flaky (passed on retry) tests, read from the
listreporter's output. On failure,playwright-report/andtest-results/are uploaded as the artifacte2e-report(5 days).
The smoke fallback (no test:e2e) is not blocked: it loads the real app with its real API calls until the contract deadline. The web job runs on the GitHub-hosted runner because the block needs root network control.
.NET test projects are sorted by what they reference (verify-dotnet-tests/classify.py). A test project is one that references Microsoft.NET.Test.Sdk, sets <IsTestProject>true or uses MSTest.Sdk:
- Testcontainers (
TestcontainersorTestcontainers.*): the hostedintegrationjob builds the solution and runs only these, with Docker available. TRX results are uploaded asintegration-results(5 days) on failure. These projects used to run insidetest, sointegrationis tolerated exactly whentestis (or whenintegrationitself is listed inknown-failing). It is not a tolerated new step. Without such a project the job skips its build. - Refused: a config file in the project (
*.json,*.config,*.runsettings,*.env,*.yml,*.yaml) holds a connection string to a non-local host with a password. The project never runs; theteststep names it and the file:line, never the value. It does not count for the contract until it is converted to Testcontainers or local values. - Everything else runs in the blocking
teststep as before, from a copy of the solution without the other two kinds; with nothing to drop, from the solution itself.
Docker smoke (smoke). For dotnet-service, docker-smoke builds the image, runs it and probes /health by default (smoke-path overrides the path, none turns it off). The image must start without its database: /health is liveness only. When on by default it is a new step, tolerated until 2026-12-29 or the repo's later deadline; a repo that set smoke-path itself keeps its blocking behaviour.
maestro runs only when the Android job produced an APK, when dependencies changed or the PR targets the default branch, and when the repo has .maestro/smoke.yaml; without that file it is skipped (and the contract reports maestro-smoke missing).
React Native journeys (maestro). Starter in starters/react-native-maestro/.
- Committed recordings and
e2e/mocks.jsonare scanned, as for web. - An API 34 x86_64 emulator boots with outbound network blocked (the emulator, Metro and Mockoon run under the block; Maestro, the host-side driver, does not; allowed: loopback and
e2e/allowed-hosts.txt). Android's own background traffic is stopped after boot (inside the emulator, system apps' traffic is rejected; multicast and NTP are dropped silently on the runner), and a 30-second idle window records what the emulator still sends: those destinations are never blamed on the journeys. If the emulator doesn't boot in 2 attempts, or its image, Maestro or the APK can't be installed, the step fails as "infrastructure, not the app" (exit 2), not as a journey failure. The step times out after 35 minutes, as a failed step whose evidence is still uploaded. - With
e2e/mocks.json, Mockoon serves it on port 3901, forwarded into the emulator withadb reverse. Metro starts withVERIFY_E2E=1andE2E_API_URL=http://localhost:3901(and the same asEXPO_PUBLIC_VERIFY_E2E/EXPO_PUBLIC_E2E_API_URL). They exist only in Metro's environment at bundle time: the app must read them there (a Babel inline-env transform, or Expo'sEXPO_PUBLIC_variables), not from a.envfile or a Gradle build field. - Every flow file directly in
.maestro/runs,smoke.yamlfirst (config.yamland subfolders are not flows).- A failed flow re-runs once; passing then is reported as flaky.
- A flow that still fails while the block rejected traffic re-runs once with network access. Passing then means the app needs an unmocked host:
maestropasses and thenetworkstep fails, a warning until its deadline. - Once
networkis enforced for the repo, that unblocked re-run no longer happens (no real hosts): the flow fails, naming the blocked destinations.
- The summary names passed, flaky, network-only and failed flows. On failure, JUnit files, screenshots, Maestro logs and the Metro and emulator logs are uploaded as
maestro-report(5 days).
known-failing and contract-deadline. Before the deadline, a failing step whose id is in known-failing is reported as a warning (and listed under warned in verify-report) instead of failing the job. On and after the deadline the list is ignored and those steps fail. The same date flips the contract job from warn to enforce. Set the deadline to the enrollment date plus 56 days; leave both empty to stay in warn mode forever.
New steps start tolerated. A step id added to the ladder after a repo enrolled is treated as if it were in known-failing until the later of contract-deadline and the day it shipped + 56 days (forever without a deadline), with no change to the caller. Each one applies to the stacks it was added for only. Today that is network (web, shipped 2026-11-03: tolerated until 2026-12-29 or the repo's later deadline) and the default-on smoke (dotnet-service only, shipped 2026-11-03: until 2026-12-29).
Verification Contract. The contract job (verify-contract) reports which required items a repo lacks: a warning annotation per item before its deadline, an error after it (see per-item deadlines below). Item ids per stack:
| Item id | Stacks | Requires |
|---|---|---|
lockfile |
all but infra |
a lockfile (yarn.lock, package-lock.json, pnpm-lock.yaml, pubspec.lock, packages.lock.json or, for .NET, Directory.Packages.props) |
toolchain-pin |
all but infra |
node stacks: .nvmrc, engines.node or packageManager; .NET: global.json; flutter: .fvmrc or pubspec.lock |
cpm |
dotnet-service (more than one csproj), dotnet-library |
Directory.Packages.props (central package management) |
health-endpoint |
dotnet-service |
MapHealthChecks( or a "/health" route in a .cs file |
test-project |
dotnet-service, dotnet-library |
a *.csproj with Test in its path |
dockerfile |
dotnet-service |
a Dockerfile |
script:<name> |
web: lint, typecheck, test, build, test:e2e; react-native: lint, typecheck, test; strapi: build |
that script in package.json |
align-deps |
react-native |
@rnx-kit/align-deps in devDependencies |
maestro-smoke |
react-native |
.maestro/smoke.yaml |
maestro-journeys |
react-native |
at least 3 flow files directly in .maestro/ (smoke.yaml counts; config.yaml and subfolders don't) (since 2026-11-03) |
e2e-mocks |
react-native |
e2e/mocks.json, a Mockoon environment file the app's API calls are served from in CI (since 2026-11-03) |
widget-test |
flutter |
a test/**/*_test.dart file |
playwright |
web |
@playwright/test in devDependencies and a playwright.config.{ts,js,mjs,cjs} in the project directory (since 2026-11-03) |
integration-tests |
dotnet-service; dotnet-library that uses an outside system |
service: a test project referencing Microsoft.AspNetCore.Mvc.Testing; library with a non-test project referencing an outside-system client (StackExchange.Redis, Npgsql*, Microsoft.Data.SqlClient, System.Data.SqlClient, MongoDB.Driver, RabbitMQ.Client, MassTransit*, Confluent.Kafka, Azure.Storage.*): a test project referencing Testcontainers. A project whose config holds a real connection string never counts (since 2026-11-03) |
stack: infra has no contract items.
Per-item deadlines. Items added after the first contract version carry the day they shipped (above). Each one turns from a warning into an error at the later of the repo's contract-deadline and its ship date + 56 days, so a new requirement never fails a repo sooner than 8 weeks after it appears. Every other item uses contract-deadline itself. Without a valid contract-deadline, all items stay warnings.
Strapi data-upgrade rehearsal. The strapi job always builds and boots HEAD against an empty Postgres and waits for /_health to answer 204. It works out each repo's set-up on every run:
- Postgres version: the caller's
postgres-versionif set, else the version inci/fixture.sql.gz's header, else 17. The job starts it itself. - Schema:
DATABASE_SCHEMA=publicis set, so a config likeenv('DATABASE_SCHEMA', 'cms')usespublic.- A schema fixed in
config/database.*(schema: 'cms') is created, the data copy is moved into it, and every check reads it. - Base and head must fix the same schema.
- A schema fixed in
- Data:
ci/fixture.sql.gzwhen the repo has one.- Otherwise generated data: after the base boots on the empty database,
seed.pyinserts the CI admin and one media file row, then creates published entries from the repo's schemas. That is 3 per collection type and 1 per single type, every field filled to its type and rules, components and dynamic zones included. - Relations, passwords and custom fields are left out.
- An entry Strapi rejects is reported with its message; no entry at all fails the step.
- Generated data needs Strapi 5; a Strapi 4 repo needs a data copy.
- The summary and the notice name the mode. When the PR changes any dependency file (
package.jsonor a lock file: an@strapi/*bump, but also a lock file refresh or a driver update moves Strapi's own dependencies), a database migration (database/migrations/), a content-type schema (src/api/*/content-types/*/schema.json) or a component (src/components/**.json), it also does the following:
- Otherwise generated data: after the base boots on the empty database,
- Scans
ci/fixture.sql.gzwithverify-secret-scan --strapi-dumpand refuses a fixture that holds personal data or secrets. - Restores the fixture into Postgres (or, without one, generates data after step 3).
- Boots the base commit from a git worktree, so it migrates the fixture.
- Boots HEAD on the same database.
- Compares, for content tables (Strapi bookkeeping tables
strapi_*,admin_*,up_*andi18n_localeare excluded):- row counts
- columns: none dropped, no type changed
- values: a per-column fingerprint, md5 over every row in
idorder - content API: the base logs in as the fixture's CI admin and creates a read-only API token. Each commit is read with its own content types from
src/api/*/content-types/*/schema.json(REST list, first page of 100,populate=*).- Every key, value and entry the base returned must still be there at HEAD.
- Additions pass: a new type, a new field, new
meta. - The check fails if no type answered 200 at the base, since nothing was compared.
- HEAD's
/adminmust answer 200.
The rehearsal step fails when any of these happens:
- either boot is unhealthy
- the fixture is empty or fails the scan, or generated data created no entry
- a table or column was dropped, or a column's type changed
- any content-table row count or column value changed
- a content-API response lost or changed something (additions are fine), or a type disappeared
/admindid not answer 200
Intended changes go in ci/rehearsal-expected-changes.txt, one per line: table, table.column or api:<pluralName> (# comments allowed), added in the same PR as the change. Declared differences are reported with (declared) instead of failing.
- Only the lines the PR adds (head vs base) count. A file merged once cannot loosen the check on later PRs, and bot PRs, which never change it, fail on any data change.
- Human PRs into
developpass instantly, so a developer's lines are first honoured on the promotion PR into the default branch, whose diff contains them. - "Base" is the first parent of the merge commit CI checks out (what the PR actually merges onto), falling back to the event's base SHA.
- Before any boot,
config/database.*must read its connection settings from theDATABASE_*environment. A fixedhost,user,passwordordatabase(other thanlocalhost) fails the step and names the line, never the value: a fixed host could reach a real database from CI. A fixedschemais handled (above).
On failure the diffs are uploaded as the artifact strapi-rehearsal-diff (5 days).
Making ci/fixture.sql.gz. Never hand-edit or hand-dump it: run scripts/strapi-fixture/strapi_fixture.py on a machine with Docker (DevOps, not CI):
SRC_DB='postgres://readonly_user:...@host:5432/db' \
python3 scripts/strapi-fixture/strapi_fixture.py --source-env SRC_DB --repo ../some-cms [--truncate form_submissions]A database only reachable from its cluster (most DigitalOcean databases restrict trusted sources) is streamed out by a temporary pod that logs in with the CMS's own Kubernetes secret, read-only:
scripts/strapi-fixture/cluster-dump.sh <context> <namespace> <cms-secret> \
| python3 scripts/strapi-fixture/strapi_fixture.py --source-dump - --repo ../some-cms- The secret must hold
DATABASE_HOST,_PORT,_NAME,_USERNAMEand_PASSWORD, and may holdDATABASE_SCHEMA. - The pod runs the server's own Postgres major (probed;
PG_IMAGEoverrides) and is deleted on exit. - The raw dump goes straight into the throwaway container, never to disk. A dump that ends early stops the tool.
Either way:
- Schema: the tool finds the CMS's schema itself: the one schema in the dump that holds tables.
- It moves into
public, where verify boots Strapi. - Every other schema is dropped, such as an API that shares the database.
- If tables sit in several schemas, it stops and asks for
--schema(the CMS'sDATABASE_SCHEMA).
- It moves into
- The source URL is read from the named environment variable, never from the command line. The source is only read (
pg_dump); use staging. A production source needs approval and a read-only role. - In a throwaway local Postgres it:
- anonymizes admin users; the lowest-id Super Admin becomes the CI admin
ci-admin@example.invalid, passwordVerifyCiOnly-1, created if missing - anonymizes app users (
up_users) - empties API/transfer tokens, sessions, webhooks and content history
- blanks secret-looking keys in the core store (OAuth provider secrets, email-provider keys)
- replaces personal-data columns (names containing email, phone, mobile, msisdn, national id or ip, plus the repo's
ci/fixture-pii.txt)
- anonymizes admin users; the lowest-id Super Admin becomes the CI admin
- It then scans the dump with
verify-secret-scanand writes the fixture only if the scan is clean and the file is under 50 MB gzipped. - Postgres version: the throwaway runs the source server's major version (
--postgres-image auto), read from the dump's header or asked of the server. The fixture's header then tells verify which Postgres to start. An explicit--postgres-imagemust be at least the source's version. - Repo files, all one entry per line with
#comments:ci/fixture-pii.txt:table.columnto scrub. An entry matching no column stops the tool, so typos can't pass silently.ci/fixture-allow.txt: public business addresses that are real content, as an address or@domain. Never allow-list a personal mailbox domain (gmail.com and similar); scrub that column instead, and the scan says so.
- The source is passed to
pg_dumpthroughPG*environment variables, so the password is in no process's arguments. - The CI admin's password is public. Never restore a fixture into a real environment.
Informational vuln report. On PRs into the default branch a vuln-report job runs check-critical-vulns and surfaces open critical Dependabot alerts in its summary. It is continue-on-error and is not part of result's needs, so it can never fail verify / result.
Outputs / artifacts: result uploads verify-report.json (artifact verify-report, 5 days) with scope, failed, warned and missing.
Required caller permissions: contents: read, packages: read, pull-requests: read (the plan job lists the PR's files).
name: Verify
on:
pull_request:
branches: [main, develop, development, staging, gigstaging]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
contents: read
packages: read
pull-requests: read
concurrency:
group: verify-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
verify:
uses: simplify9/.github/.github/workflows/verify.yml@main
with:
stack: web
known-failing: 'typecheck e2e'
contract-deadline: '2026-12-20'
secrets:
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
nuget-restore-token: ${{ secrets.NUGET_PACKAGE_PAT }}Call composite actions directly in job steps:
uses: simplify9/.github/.github/actions/<name>@mainAll 29 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, and alerts_to_csv.jq for its blocking-alerts CSV report — see below) keep logic in a sibling script; the rest is inline bash.
| Action | Purpose | Key inputs | Key outputs |
|---|---|---|---|
determine-semver |
Compute next major.minor.patch from git tags |
major, minor, release-branch, current-ref, build-id |
version, git-tag, is-release |
tag-github-origin |
Create a git tag via the GitHub REST API (no checkout) | github-token, repository, tag, sha |
created, ref |
| Action | Purpose | Key inputs | Key outputs |
|---|---|---|---|
docker-build-push |
Build + push (multi-platform via Buildx/QEMU) up to three tags. On a self-hosted runner that provides a shared BuildKit (S9_BUILDKIT_ENDPOINT), builds there instead — no GitHub cache, linux/amd64 only |
image-name, version, username, password (both only needed when push is true; push: 'false' builds without logging in), registry, platforms |
image-tags, image-digest |
| Action | Purpose | Key inputs |
|---|---|---|
helm-generic |
helm upgrade --install of s9genericchart (default) + optional pre-deploy migration Job. Helm 4. snake_case inputs |
app_name, namespace, kubeconfig_data, extra_set_values, secret_set_values, init_job_image |
helm-deploy |
Deploy from OCI or ChartMuseum (chart-source-type) |
chart-name, repository, kubeconfig, chart-source-type, chart-repo-url |
helm-deploy-s9generic |
Deploy from OCI or a local chart dir (chart-path) with failure diagnostics |
chart-name, chart-path, kubeconfig |
helm-package-push |
Package + publish to OCI or ChartMuseum | chart-path, chart-name, version, publish-method |
Three overlapping deploy actions.
helm-deploy,helm-deploy-s9generic, andhelm-genericall wraphelm upgrade --installand 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.
| Action | Purpose | Key outputs |
|---|---|---|
gateway-routing |
Render gateway/ingress/configmap Helm values + host/section lists (pure, no cluster access) | values-file, gateway-host-list, gateway-section-names-list |
gateway-onboard |
Ensure parent Gateway listeners + cert-manager Certificates exist before deploy (cluster-mutating) | — |
| Action | Purpose | Key outputs |
|---|---|---|
dotnet-build |
Resolve .sln/glob, then restore -> build -> optional test |
build-target |
dotnet-pack-push |
dotnet pack --no-build -> nuget push --skip-duplicate (empty = skip); versions already on the feed are counted as skipped, not pushed |
packages-pushed, packages-skipped, package-paths |
.NET SDK selection — dotnet-version is a floor, not a selector. GitHub-hosted Ubuntu images (24.04 and 26.04) preinstall SDKs 8, 9 and 10 in /usr/share/dotnet, and actions/setup-dotnet installs into the same directory, so the newest installed SDK builds regardless of dotnet-version (measured: 8.0.x, 10.0.x and both all select 10.0.401). The org runners (vars.S9_RUNNER) carry SDKs 10.0.401 and 8.0.425, so there too the newest installed SDK builds (8.0.x → 10), and setup-dotnet downloads nothing it already has. To pin an SDK anywhere, commit a global.json. The SDK never changes a project's target framework or the runtime in its Docker image — those come from the .csproj and Dockerfile. net8.0 projects still build and test with the 10.0.x default: runtime 8 is present on both runner types.
| Action | Purpose | Key outputs |
|---|---|---|
generate-wrangler-config |
Generate wrangler.toml (plain Workers, OpenNext, static assets, SPA) |
config-path |
setup-cloudflare-domain |
Add a custom domain to a Pages project (fail-on-error: false = non-blocking) |
domain-status |
| Action | Purpose |
|---|---|
ios-install-cert |
Import a .p12 into a temporary keychain (p12Base64, p12Password) |
ios-install-profile |
Install a .mobileprovision, extract UUID/Name + best-effort Team ID/Bundle ID (profileBase64) |
xcode-build |
xcodebuild archive with manual signing (workspace, scheme, archivePath, developmentTeam, provisioningProfileUuid, keychainPath) |
xcode-export |
xcodebuild -exportArchive -> .ipa (archivePath, exportOptionsPlist, exportPath) |
| Action | Purpose | Key inputs | Key outputs |
|---|---|---|---|
verify-plan |
Scope helper (scope.sh, tested by test_scope.sh): effective known-failing list (the caller's list before its deadline plus the stack's tolerated new steps, NEW_STEPS entries <stack>:<id>:<deadline>, * = every stack), whether dependency files changed, contract mode |
event-name, repository, pr-number, known-failing, deadline, stack, github-token |
known_failing, repo_known_failing, deps_changed, contract_mode, strapi_changed |
verify-contract |
Report which Verification Contract items a repo lacks (contract.py); warn before the deadline, error after |
stack (required), working-directory, deadline |
missing, mode |
verify-web-install |
Detect the package manager from the lockfile (detect_pm.sh), set up Node, install from the lockfile without modifying it |
working-directory, node-version |
package-manager (npm/yarn/yarn-berry/pnpm), runner |
verify-node-deps |
Fail on duplicate singleton packages in node_modules and, for npm, on invalid peer dependencies; reports "skipped (PnP)" when there is no node_modules |
package-manager (required), working-directory, names (default react,react-dom,react-native,next) |
- |
verify-web-smoke |
Serve the built web app and load it in Chromium, failing on page or console errors | working-directory |
- |
verify-dotnet-tests |
Run the unit or integration (Testcontainers) test projects of a built .NET solution from a filtered copy of it; never run a test project whose config holds a real connection string (classify.py, run_tests.sh). With no build-target, only report has |
kind (required), build-target, working-directory, results-directory |
ran, has |
verify-web-e2e |
The web e2e step: scan e2e/**/*.har and e2e/mocks.json, serve the mocks with Mockoon (E2E_API_URL), run test:e2e with outbound network blocked (one re-run with network access when blocked traffic may explain a failure), name failed and flaky tests in the summary. Needs a GitHub-hosted runner |
working-directory, runner (required) |
network (needed when the tests passed only with network access) |
verify-maestro |
The React Native maestro step: every .maestro/ flow on an API 34 emulator with outbound network blocked, e2e/mocks.json served as E2E_API_URL=http://localhost:3901, flaky flows re-run once, a network re-check, evidence in $RUNNER_TEMP/maestro-report. Needs a GitHub-hosted runner |
working-directory, apk (required) |
network |
verify-emulator |
Script, no action.yml: emulator.sh (sourced) enables KVM, installs an API 34 x86_64 emulator image and boots it, optionally through netblock_run so the emulator's traffic (the app's) is blocked; one retry, and a separate "did not boot (infrastructure)" error. Used by the mobile e2e steps |
- | - |
verify-mocks |
Script, no action.yml: mockoon.sh (sourced) installs the pinned @mockoon/cli (9.9.0) and serves a Mockoon environment file on 127.0.0.1:3901 with proxying off, optionally through a prefix command such as netblock_run. Used by the web and mobile e2e steps |
- | - |
verify-netblock |
Script, no action.yml: netblock.sh (sourced) blocks outbound traffic for the commands it runs (group verify-offline, iptables owner match) except loopback and the hosts of an allowed-hosts file, resolved once and pinned in /etc/hosts. Needs sudo and iptables |
- | - |
verify-secret-scan |
Script, no action.yml yet: scan.py [--allow FILE] [--strapi-dump] PATH... refuses files committed for CI (Strapi fixtures, Playwright HAR recordings, Mockoon mock files) that hold personal data or secrets: real emails (allowed: example.com/.org/.net, strapi.io, .invalid/.example/.test/.localhost, plus ci/fixture-allow.txt lines), password hashes (except the public CI admin's), signed JWTs (an unsigned mock token, {"alg":"none"} with an empty signature, passes), private keys, common tokens, connection strings with a password and a non-local host (key=value in any key order, split on ; or ,, including Redis host:port,password=…; or scheme://user:pass@host; localhost, 127.0.0.1, host.docker.internal and dotless names such as a compose service db count as local), credential headers with a value (Authorization, Cookie, Set-Cookie, X-Api-Key, Api-Key, X-Auth-Token, as HAR name/value or Mockoon key/value) and non-empty password fields (JSON, escaped JSON in a HAR postData, form-encoded); --strapi-dump also requires Strapi's token, session and webhook tables to be empty. Reads .gz; exit 0 clean, 1 findings, 2 usage/IO error; masks every value (***@domain, first 4 characters). Stdlib only, tested by test_scan.py |
- | - |
strapi-rehearsal |
Boot Strapi against Postgres; when a dependency file, a database migration, a content-type schema or a component changed, scan and restore ci/fixture.sql.gz, boot the base commit then HEAD on it and require unchanged content-table row counts, columns, column values and content-API responses plus /admin 200, except lines the PR adds to ci/rehearsal-expected-changes.txt (rehearse.sh, api_check.py); diffs in artifact strapi-rehearsal-diff |
base-sha, strapi-changed, working-directory, package-runner |
- |
| Action | Purpose |
|---|---|
write-job-summary |
Append a standardized, status-aware section to $GITHUB_STEP_SUMMARY (title, status, icon, details) |
check-critical-vulns |
Fail 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; sla-mode (default warn; the org-wide switch to enforce is that default, see SLA deadlines), high-max-age-days, medium-max-age-days; outputs critical-count, report-name, report-path, sla-overdue-count). Also runs an independent SLA step for open high/medium alerts, see SLA deadlines. Uses Link-header cursor pagination (this endpoint rejects page=N). When it fails on real alerts, uploads every still-blocking alert as an unzipped critical-vulns-<repo>-<run_id>.csv run artifact (alerts_to_csv.jq sibling filter) — see Blocking-alerts CSV report. 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) |
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.
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 whatevertarget-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.
Every onboarded repo gets the same three files:
| File | Role |
|---|---|
.github/dependabot.yml |
Service config — which ecosystems, schedule, limits, grouping. Category-specific, rendered from a template below. |
.github/workflows/critical-vuln-check.yml |
PR-time gate — see Starter Templates / critical-vuln-gate.yml. |
.github/workflows/dependabot-auto-merge.yml |
Auto-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 11 of the 13 other reusable workflows in this repo, and as a
dependency of auto-merge.
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:
| Template | Ecosystems | Schedule (all Asia/Amman) |
Primary limit | Docker/Actions limit |
|---|---|---|---|---|
nuget-api.yml |
nuget, docker, github-actions | Monday 06:00 | 10 | 5 |
npm-frontend.yml |
npm, docker, github-actions | Tuesday 06:00 | 10 | 5 |
react-native-mobile.yml |
npm, bundler, github-actions | Wednesday 06:00 | 10 | 5 |
flutter-mobile.yml |
pub, github-actions | Wednesday 06:00 | 10 | 5 |
infra-actions-only.yml |
github-actions, docker | Thursday 06:00 | 5 | 5 |
github-repo.yml |
github-actions (/ + /.github/actions/*) |
Sunday 06:00 | 5 | — |
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.
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: 3
semver-patch-days: 5
semver-minor-days: 3
semver-major-days: 7Patch 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.
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.
- Scoped per
updates:block (i.e., per ecosystem + directory + branch), not per repo — a repo with 3updates: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'signore: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-triggerspull_request_targetso 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.
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.
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.
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@v3reports 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 ownvuln-gatejob — reusable-workflow jobs can'tneeds: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.
- PR-time (
critical-vuln-check.yml+ branch protection): onmain, mark the check required in branch protection — merge is physically blocked while any critical alert is open. Ondevelop, leave it present but not required — a visible red check, no block. - Build-time (embedded directly as an early job in 11 of the 13 other reusable workflows in this repo — everything except the chart-lint-only
gateway-chart-cicd.ymland the PR-timereact-native-contract-gate.yml— triggered onpush, notpull_request): re-checks at actual deploy time. This is not subject to the Dependabot secrets restriction below (that restriction is specific topull_request-family events;pushnever 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.
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.json -> yarn.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.
Besides the immediate critical block, check-critical-vulns runs a second, independent step
that flags open alerts older than their deadline. It never changes the critical verdict,
critical-count or the CSV report.
| Severity | Overdue when open for more than | Input |
|---|---|---|
| critical | n/a, blocks immediately (main step) | n/a |
| high | 30 days | high-max-age-days |
| medium | 90 days | medium-max-age-days |
| low | never checked | n/a |
sla-mode: warn (default) emits [VULN-SLA] Overdue <severity> alert warnings and a summary
table only. sla-mode: enforce turns those into errors and fails the step; it also fails
closed on an alerts-API error or missing token (warn mode only warns). Enforcement is switched
org-wide by changing the sla-mode input's default in
.github/actions/check-critical-vulns/action.yml from warn to enforce, a one-line PR,
because every caller uses the action's default. A workflow calling the action directly may
also pass sla-mode explicitly. The build-time gate only runs on default-branch builds, so
develop deploys are never blocked. Planned flip: 4 weeks after the last rollout wave,
announced a week ahead. The sla-overdue-count
output carries the count. Logic is tested by test_sla_overdue.sh next to the action.
When check-critical-vulns fails because of real alerts (not an API/token error), it writes
every still-blocking alert to critical-vulns-<repo>-<run_id>.csv and uploads it as a run
artifact. Alerts cleared by PR-branch verification are left out, so the file always matches
the blocking count. The upload uses archive: false, so the artifact downloads as the .csv
itself rather than a zip; it's kept for 5 days, and re-running the job produces a fresh one.
The [VULN-GATE] Fix list annotation and the job summary both name the file.
Getting it: failed run → Summary → Artifacts → critical-vulns-<repo>-<run_id>.csv.
Hand it to your coding agent alongside the repo checkout.
Columns (one row per alert, RFC 4180 quoting — multi-line cells stay inside quotes):
| Column(s) | Meaning |
|---|---|
repository, alert_number, alert_url |
Which alert, and its Dependabot page |
ghsa_id, cve_id, advisory_url |
Advisory identifiers (cve_id empty when none is assigned) |
severity, cvss_score, cvss_vector |
CVSS v4 if present, else v3, else the legacy score (the API reports a missing version as 0.0, treated as absent) |
ecosystem, package, manifest_path |
What to change, and the file Dependabot flagged it in |
relationship, scope |
direct / transitive / unknown; runtime / development |
vulnerable_version_range, first_patched_version |
The target — an empty first_patched_version means no fix is published |
suggested_fix |
Generic hint built from the columns above: direct → bump; transitive → upgrade the parent, or force the version via the ecosystem's override mechanism (npm overrides / yarn resolutions / pnpm overrides, NuGet direct PackageReference, …); no patch → remove or replace |
summary, cwes, references |
Advisory title, CWE IDs with names, reference URLs (space-separated) |
alert_created_at, description |
When the alert opened; the full advisory markdown (last column — often several KB) |
Spreadsheet safety: summary and description are third-party-authored, so a leading
=, +, - or @ gets a ' prefix (CSV-injection guard). Structured columns stay verbatim —
e.g. GitHub's exact-version range = 1.0.0 — because a tool acting on them needs the exact value.
Never changes the verdict: if the CSV can't be built, the step emits a
[VULN-GATE] CSV report not generated warning and uploads nothing; the gate still fails closed
on the alert count alone. The column logic lives in the sibling alerts_to_csv.jq.
No caller changes: upload-artifact authenticates with the runner's own token, not
GITHUB_TOKEN scopes, so every existing call site gets this as-is. Its name: is set to the
file name on purpose — overwrite deletes by name, while archive: false names the artifact
after the file, so without it a job re-run in the same run would hit a 409 name conflict.
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.
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.json — PR-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.
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.
- Deployed to every active repo in the
simplify9org. - Repos with a genuine open critical alert correctly gate
main(or show a non-blocking warning ondevelop) 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 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 level
var isomorphicReactPackageVersion = React.version;
if ("19.2.3" !== isomorphicReactPackageVersion)
throw Error('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 newArchEnabled —
findNodeHandle, 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):
| Gate | Catches it? | Why |
|---|---|---|
| npm/yarn install | No | ^19.2.3 genuinely permits 19.2.8 |
tsc --noEmit, eslint |
No | types and lint are unaffected |
jest + react-test-renderer render |
No | rendering uses Fabric; Paper is never loaded |
npx react-native bundle |
No | Metro builds a static graph and never evaluates module bodies |
rn-contract / check |
Yes | compares resolved react against the renderer's own literal |
| Native build / E2E smoke | Yes | but ~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, notpull_request_target. Unlikecritical-vuln-check.yml, this gate needs the PR's ownpackage.jsonand lockfile, whichpull_request_targetcannot see. It is safe onpull_requestbecause it needs no secrets (a read-onlyGITHUB_TOKENis enough) and never installs or executes PR code. Never add ayarn installstep 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 withfail-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.
The org ships Renovate presets in renovate/. They are inert until a self-hosted Renovate runner points at them; nothing in this repo runs Renovate itself.
| Preset | Schedule (Asia/Amman) | Extra rules |
|---|---|---|
default |
(none) | Base policy for every stack (see below) |
dotnet |
Mon 00:00-08:59 | none |
web |
Tue 00:00-08:59 | none |
react-native |
Wed 00:00-08:59 | React core set grouped as react-native core; Android toolchain (Gradle, AGP, Kotlin) grouped behind dashboard approval; iOS toolchain (CocoaPods, bundler gems) never automerged, as verify builds no iOS |
flutter |
Wed 00:00-08:59 | flutter SDK bumps need dashboard approval; same Android and iOS toolchain rules as react-native |
strapi |
Thu 00:00-08:59 | @strapi/** grouped and pinned; majors carry a migration runbook note |
infra |
Thu 00:00-08:59 | none |
Reference a preset as github>simplify9/.github//renovate/<name>. Every stack preset extends default.
default policy:
- Non-breaking updates (minor, patch, pin, digest) form one weekly group and automerge. This includes 0.x patch updates.
- npm releases wait 7 days; everything else waits 3 days.
- 0.x minor updates and all major updates get their own branch, need dashboard approval, and never automerge.
- Security fixes open immediately with no release-age wait. Non-major security fixes automerge; major or 0.x minor security fixes are left for a human to merge.
- Lock files are regenerated weekly, in the stack's own window, and automerge once
verifypasses. This is how vulnerable transitive dependencies get fixed: Renovate's security PRs cover direct dependencies only, and enrolled repos have Dependabot security updates turned off. A transitive fix that needs a parent's major, or that a parent pins exactly, waits for that parent's own update or shows up in the vulnerability gate. - No hourly PR cap (
prHourlyLimit: 0). Each stack gets one in-window run a week, so security PRs opened in that run must not push the weekly group to the next week. At most 5 PRs are open per repo (prConcurrentLimit).
Rule order matters: a later packageRules entry overrides an earlier one. Stack presets extend default, so their own rules come after it.
To override a repo's policy, add its own renovate.json that extends the preset and then sets its own keys or packageRules. Keep the override narrow; the org presets are the default for every enrolled repo.
Strapi major upgrades change the database schema and the API response shape, so they are never merged on a dependency PR alone. The strapi preset attaches this runbook to every major PR.
- Back up the production database and media.
- Restore a production snapshot into a throwaway Postgres.
- On a branch, run
npx @strapi/upgrade majorand resolve every__TODO__it leaves. - Replace or remove plugins that are incompatible with the new major.
- Boot the new major against the restored copy. Migrations run there, not in production.
- Compare per-table row counts and spot-check content against the snapshot.
- Test every frontend against the new response format. Use the
Strapi-Response-Format: v4header as a temporary bridge if needed. - Run the verify workflow's Strapi rehearsal on the branch (requires the
verifyworkflow withstack: strapi, documented in the "Verification (verify)" section once that PR lands). - Take a production backup right before the maintenance-window deploy.
Validate the presets with bash renovate/validate.sh (needs network for npx). It runs renovate-config-validator --strict on every file in renovate/, pinned to renovate@44.181.5.
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:
| Trigger | cancel-in-progress |
Why |
|---|---|---|
pull_request / pull_request_target checks |
true |
A 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 workflows |
false |
A 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: truegithub.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.
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.
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>).
Per-branch gating lives in the caller (template), not inside the reusable workflows:
| Branch | Typical use | How it's wired |
|---|---|---|
staging |
Staging/dev | Caller job if: github.ref == 'refs/heads/staging' -> staging GitHub Environment |
main / master |
Production | Caller job if: github.ref == 'refs/heads/main' -> production GitHub Environment |
Mobile workflows additionally gate the release job on release-environment != '' && !disable-release.
The most important security pattern in the repo. Never put secrets into helm-set-values.
| Parameter | Passed as | Helm flag | Use for |
|---|---|---|---|
helm-set-values |
Workflow input | --set |
Non-sensitive config: replicas, ingress, environment label |
helm-set-secret-values |
Workflow secret | --set-string |
Sensitive data: DB connection strings, API keys |
--set-string bypasses Helm type coercion and prevents =, SSL:, // in connection strings from being misinterpreted.
# Correct
with:
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 input
with:
helm-set-values: 'db=${{ secrets.DATABASE_URL }}'| Tool | Pinned 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 |
@v6 |
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).
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).
- 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.
3–6 lowercase topics, one per facet, in this order (minimum 2 only where a facet is genuinely unknowable — empty stubs, org-config repos):
| # | Facet | Values |
|---|---|---|
| 1 | Product / client | The product prefix of the repo name (lowercase). Org-level repos (SW-* libraries, infrastructure-*, this repo) use simplify9 |
| 2 | Sub-product | Only when present in the name (e.g. iot, last-mile) |
| 3 | Component (exactly one) | api, web, website, mobile, cms, library, integration, infrastructure, docs, desktop, monorepo, data |
| 4 | Stack (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, … |
| 5 | Lifecycle (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,--applyto 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.
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 pushIf 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 trackedIf the bad commit is on more than one branch, repeat on each affected branch.
- For
reusable-service-cicd.yml, setdeploy: true(publishing happens regardless; deploying is opt-in). - For mobile workflows, the release job needs a non-empty
release-environmentanddisable-release: false. - Confirm the relevant kubeconfig secret is set and base64-encoded (
kubeconfigfor ingress-nginx,kubeconfig-gatewayfor gateway-api).
- Vite (
vite-cloudflare-worker.yml): setassets_dir: dist. - Next.js (
next-cloudflare-worker.yaml): defaultassets_diris.open-next/assets— change only if your build differs.
| Symptom | Cause | Fix |
|---|---|---|
Run fails before any job starts: The nested job 'deploy' is requesting 'packages: read', but is only allowed 'packages: none' |
The caller's permissions: block omits packages: read |
Add packages: read to the caller workflow's permissions: on every branch that triggers it (develop and main) |
Setup Node.js fails: error Error: Failed to replace env in config: ${GITHUB_TOKEN} |
Yarn 1 found ${GITHUB_TOKEN} in .npmrc with the variable unset (only on a workflow version before the fix, or locally) |
Re-run on the current @main; locally, export GITHUB_TOKEN or switch .npmrc to the scope-only form above |
Install dependencies fails: 401 Unauthorized / 403 Forbidden from npm.pkg.github.com |
The consuming repo hasn't been granted access to the package | Package settings -> Manage Actions access -> add the repo with Read |
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).
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.
Increase version-code-offset (default 80000) above your previous CI system's last published versionCode.
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.
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.)
- Create a feature branch.
- 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. - Follow all conventions in AGENTS.md — pinned action versions, the 4-pillar log framework, composite-action shell requirements, and secrets-vs-inputs.
- If you change a workflow that has a starter template, update the paired
workflow-templates/<name>.ymland.properties.json. - Update both
README.mdandAGENTS.mdin the same change.
Key rules:
- Every
run:step in a composite action must haveshell: bash. - All new inputs need
description:and a sensibledefault:orrequired: 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.