Comprehensive, enterprise-grade SDLC pipeline templates for GitHub Actions, GitLab CI, and Azure DevOps with security scanning (SAST), dependency analysis (SCA), supply chain security (SSCA), testing, and deployment automation for multiple programming languages.
| Platform | Status | Documentation |
|---|---|---|
| GitHub Actions | Full Support | Usage Guide |
| GitLab CI | Full Support | Usage Guide |
| Azure DevOps | Full Support | Usage Guide |
| Language | GitHub Actions | GitLab CI | Azure DevOps | Features |
|---|---|---|---|---|
| GoLang | yes | yes | yes | Binary, Docker, ARM deployment |
| Python | yes | yes | yes | PDM, Docker, K8s deployment |
| Java | yes | yes | yes | Maven, Gradle, Docker |
| JavaScript/Node.js | yes | yes | yes | npm, Yarn, Docker, K8s deployment |
| PHP | yes | no | no | Composer, Docker |
| Ruby | yes | no | no | Bundler, Docker |
| .NET/C# | yes | yes | yes | Framework, Core, Docker |
| Dart | yes | yes | yes | pub.dev, Docker, native binary |
| Flutter | yes | yes | yes | Web, Android APK/AAB, Docker |
| Terraform | no | yes | yes | Infrastructure as Code |
| Terra CLI | yes | yes | yes | Terraform/Terragrunt wrapper |
pipelines/
├── .github/workflows/ # GitHub Actions reusable workflows
│ ├── go-docker.yaml # Go with Docker delivery
│ ├── go-render.yaml # Go with Docker delivery + Render deployment
│ ├── go-flyio.yaml # Go with Docker delivery + Fly.io deployment
│ ├── go-binary.yaml # Go binary compilation
│ ├── pdm-docker.yaml # Python/PDM with Docker
│ ├── gradle-docker.yaml # Java/Gradle with Docker delivery
│ ├── maven-docker.yaml # Java/Maven with Docker delivery
│ ├── yarn-docker.yaml # JavaScript/Yarn with Docker delivery
│ ├── npm-docker.yaml # JavaScript/npm with Docker delivery
│ ├── composer-docker.yaml # PHP/Composer with Docker delivery
│ ├── bundler-docker.yaml # Ruby/Bundler with Docker delivery
│ ├── dotnet-docker.yaml # .NET with Docker delivery
│ ├── dart-docker.yaml # Dart with Docker delivery
│ ├── dart-library.yaml # Dart package published to pub.dev
│ ├── flutter-artifacts.yaml # Flutter web bundle + Android APK/AAB
│ └── ...
├── gitlab/ # GitLab CI pipeline templates
│ ├── golang/ # Go language pipelines
│ ├── java/ # Java language pipelines
│ ├── python/ # Python language pipelines
│ ├── javascript/ # JavaScript/Node.js pipelines
│ ├── dotnet/ # .NET language pipelines
│ ├── dart/ # Dart and Flutter pipelines
│ ├── terraform/ # Terraform pipelines (raw terraform/terragrunt)
│ ├── terra/ # Terra CLI pipelines (terraform/terragrunt wrapper)
│ └── global/ # Shared GitLab configurations
├── azure-devops/ # Azure DevOps pipeline templates
│ ├── golang/ # Go language pipelines
│ ├── java/ # Java language pipelines
│ ├── python/ # Python language pipelines
│ ├── javascript/ # JavaScript/Node.js pipelines
│ ├── dotnet/ # .NET language pipelines
│ ├── dart/ # Dart and Flutter pipelines
│ ├── terraform/ # Terraform pipelines (raw terraform/terragrunt)
│ ├── terra/ # Terra CLI pipelines (terraform/terragrunt wrapper)
│ └── global/ # Shared Azure DevOps templates
├── global/ # Shared resources across platforms
│ ├── scripts/ # Automation scripts
│ │ ├── tools/ # Language-agnostic tools
│ │ │ ├── codeql/ # SAST security scanning (CodeQL)
│ │ │ ├── gitleaks/ # Secret scanning
│ │ │ ├── hadolint/ # Dockerfile linting
│ │ │ ├── semgrep/ # Static analysis
│ │ │ ├── sonarqube/ # Code quality
│ │ │ └── dependency-track/ # SCA analysis
│ │ ├── languages/ # Language-specific scripts
│ │ │ ├── golang/ # Go scripts (test, cyclonedx, golangci-lint, init)
│ │ │ ├── dart/ # Dart/Flutter scripts (setup, format, analyze,
│ │ │ │ # test, unused, sca, build, publish)
│ │ │ └── python/ # Python scripts (cyclonedx)
│ │ ├── deploy/ # MVP hosting providers (50-deployment stage)
│ │ │ ├── cloudflare/ # Cloudflare Pages + Workers
│ │ │ ├── vercel/ # Vercel
│ │ │ ├── render/ # Render
│ │ │ ├── netlify/ # Netlify
│ │ │ └── flyio/ # Fly.io
│ │ └── shared/ # Common utilities
│ ├── containers/ # Custom Docker images
│ │ ├── golang.*/ # Go development images
│ │ ├── python.*/ # Python development images
│ │ ├── awscli.latest/ # AWS CLI tools
│ │ └── tor-proxy.latest/ # Network proxy tools
│ └── configs/ # Configuration files
├── makefiles/ # Includable Makefile fragments for local usage
│ ├── common.mk # Security tools (sast) and setup
│ ├── golang.mk # Go targets (lint, test)
│ ├── python.mk # Python/PDM targets (lint, test)
│ ├── java.mk # Java/Gradle targets (lint, test)
│ ├── javascript.mk # JavaScript/Yarn targets (lint, test)
│ ├── dotnet.mk # .NET/C# targets (lint, test)
│ ├── dart.mk # Dart/Flutter targets (lint, test, sca, build)
│ ├── terraform.mk # Terraform targets (lint, test)
│ └── terra.mk # Terra CLI targets (lint, test)
├── .docs/ # Documentation and examples
│ └── examples/ # Per-provider usage examples
└── .github/tests/ # Validation scripts for this repository
Each platform follows a consistent 5-stage pipeline architecture:
- Code Check (Style/Quality) - Linting, formatting, code quality, basic checks (rebase verification, changelog validation)
- Security (SCA/SAST) - Vulnerability scanning, secret detection
- Tests - Unit tests, integration tests, coverage reporting
- Management - Dependency tracking, SBOM generation
- Delivery - Build artifacts, container images, deployments
mkdir -p $HOME/Development/github.com/rios0rios0
cd $HOME/Development/github.com/rios0rios0
git clone https://github.com/rios0rios0/pipelines.gitmake setup does exactly this and is safe to re-run -- it clones on the first
call and fast-forwards afterwards. Override the location with PIPELINES_HOME:
make setup PIPELINES_HOME=/opt/pipelinescurl -sSL https://raw.githubusercontent.com/rios0rios0/pipelines/main/clone.sh | bashclone.sh still exists and does the same two git commands, but nothing in
this repository uses it any more and it is no longer the recommended path.
Piping a remote script into a shell executes whatever that URL returns at that
moment, from a branch, unpinned and unverified, with your user's privileges --
the same pattern the SAST stage here flags in consumers' pipelines, and it is
not worth making an exception for it just because the script is ours.
GitHub Actions workflows are located in .github/workflows/ and can be used as reusable workflows.
| Workflow | Purpose | Languages |
|---|---|---|
go.yaml |
Go testing and quality checks | Go |
go-docker.yaml |
Go with Docker image delivery | Go |
go-render.yaml |
Go with Docker delivery + Render deployment | Go |
go-flyio.yaml |
Go with Docker delivery + Fly.io deployment | Go |
go-library.yaml |
Go module tagged for the proxy | Go |
go-binary.yaml |
Go binary compilation and release | Go |
pdm.yaml |
Python/PDM testing and quality checks | Python |
pdm-docker.yaml |
Python/PDM with Docker image delivery | Python |
pdm-library.yaml |
Python package published to PyPI | Python |
gradle.yaml |
Java/Gradle testing and quality checks | Java |
gradle-docker.yaml |
Java/Gradle with Docker image delivery | Java |
gradle-library.yaml |
Java/Gradle library published to a registry | Java |
yarn.yaml |
JavaScript/Yarn testing and quality checks | JavaScript |
yarn-docker.yaml |
JavaScript/Yarn with Docker image delivery | JavaScript |
yarn-cloudflare.yaml |
JavaScript/Yarn deployed to Cloudflare | JavaScript |
yarn-library.yaml |
JavaScript/Yarn package published to npm | JavaScript |
dotnet.yaml |
.NET testing and quality checks | C# |
dotnet-docker.yaml |
.NET with Docker image delivery | C# |
dotnet-library.yaml |
.NET package published to NuGet | C# |
npm.yaml |
JavaScript/npm testing and quality checks | JavaScript |
npm-docker.yaml |
JavaScript/npm with Docker image delivery | JavaScript |
npm-cloudflare.yaml |
JavaScript/npm deployed to Cloudflare | JavaScript |
npm-library.yaml |
JavaScript/npm package published to npm | JavaScript |
maven.yaml |
Java/Maven testing and quality checks | Java |
maven-docker.yaml |
Java/Maven with Docker image delivery | Java |
maven-library.yaml |
Java/Maven library published to a registry | Java |
composer.yaml |
PHP/Composer testing and quality checks | PHP |
composer-docker.yaml |
PHP/Composer with Docker image delivery | PHP |
composer-library.yaml |
PHP package published to Packagist | PHP |
bundler.yaml |
Ruby/Bundler testing and quality checks | Ruby |
bundler-docker.yaml |
Ruby/Bundler with Docker image delivery | Ruby |
bundler-library.yaml |
Ruby gem published to RubyGems | Ruby |
dart.yaml |
Dart/Flutter quality, security, and tests | Dart/Flutter |
dart-docker.yaml |
Dart/Flutter with Docker image delivery | Dart/Flutter |
dart-cloudflare.yaml |
Dart/Flutter deployed to Cloudflare | Dart/Flutter |
dart-library.yaml |
Dart package published to pub.dev | Dart |
flutter-artifacts.yaml |
Flutter web bundle and Android APK/AAB | Flutter |
terra.yaml |
Terra CLI quality, security, and tests | Terraform/HCL |
checks.yaml |
Rebase and changelog gate for a repository with no build | any |
release.yaml |
Tag and GitHub Release from a bump commit | any |
update-major-version-tag.yaml |
Moving vN tag for action consumers |
any |
dependency-updates.yaml |
Twice-weekly check for stale pinned dependencies | all |
reusable-claude-review.yaml |
Claude Review — automated review on pull requests (not drafts, forks or automation branches) |
any |
reusable-claude-mention.yaml |
Claude Mention — responds to @claude mentions |
any |
A repository whose project does not live at its root -- a Rails API under api/ beside a React
frontend under app/, a gem under gem/, a site under web/ -- passes its path once:
jobs:
api:
uses: 'rios0rios0/pipelines/.github/workflows/bundler.yaml@main'
with:
working_directory: 'api'
app:
uses: 'rios0rios0/pipelines/.github/workflows/npm.yaml@main'
with:
working_directory: 'app'working_directory is optional and defaults to ., so a consumer that does not pass it sees no
change at all.
| Workflows | Inputs |
|---|---|
bundler.yaml, bundler-library.yaml, bundler-docker.yaml |
working_directory, ruby_version |
npm.yaml, yarn.yaml |
working_directory, node_version |
npm-library.yaml, npm-docker.yaml |
working_directory |
yarn-library.yaml, yarn-docker.yaml |
working_directory |
What it scopes. Everything that reads the project's own manifest: bundle install (through
ruby/setup-ruby), RuboCop, debride, bundler-audit, the Ruby test task, the npm/Yarn install,
lint:ci, Prettier, Knip, the audit, test:ci, build, and the coverage, JUnit and artifact
paths those produce. actions/setup-node is pointed at the project's own lockfile, which it
otherwise looks for in the workspace root only -- failing on its second step with
"Dependencies lock file is not found".
What it deliberately does not scope. Gitleaks, Semgrep, CodeQL, Hadolint and basic-checks
keep running from the repository root. They are repository-wide by design: Gitleaks reads the
whole git history, Semgrep and CodeQL analyse every source file wherever it lives, Hadolint looks
for a Dockerfile anywhere, and basic-checks reads the changelog and the branch. Scoping them to
one directory would silently shrink the security surface of a monorepo -- which is exactly the
shape where the unscanned file is in the other folder. CodeQL needs no source-root hint either:
the language is named explicitly rather than autodetected, and the extractor walks the whole
checkout. The delivery jobs of the -library and -docker variants stay repository-scoped for
the same reason -- a release tag and a Docker build are facts about the repository, not about one
directory in it.
Ruby version. bundler.yaml also takes ruby_version. Left empty -- the default -- the
version comes from <working_directory>/.ruby-version when that file exists, and falls back to
3.3, the version the workflow hardcoded before the input existed. .tool-versions is not read:
asdf and mise write one for nodejs or python alone, so its existence is no evidence that a
ruby line is in it, and ruby/setup-ruby handed a .tool-versions without one fails the step
instead of falling back. A project declaring its version anywhere else (.tool-versions,
mise.toml, the ruby directive in the Gemfile) passes ruby_version: 'default' and lets
ruby/setup-ruby find it.
Node version. npm.yaml and yarn.yaml also take node_version, which is handed to
actions/setup-node by every job in those pipelines -- ESLint, Prettier, Knip, the audit, the
tests and the build. It defaults to '20', the value each of those jobs hardcoded before the
input existed, so a consumer that does not set it sees no change at all -- but Node 20 reached
end of life on 30 April 2026, so the default is an unsupported runtime and a project on a
supported major should name it. npm makes the mismatch quiet rather than loud: a project whose
engines say >=24 still installs on Node 20, because npm ci only WARNS EBADENGINE unless
engine-strict is set, so it surfaces two jobs later as a test run that dies on an API the
runtime does not have. The composed variants -- npm-library.yaml, npm-docker.yaml,
yarn-library.yaml, yarn-docker.yaml -- do not expose the input and run the base pipeline on
its default.
Coverage and SonarQube. The coverage artifact is uploaded and unpacked at
<working_directory>/coverage/, so a sonar-project.properties naming
sonar.javascript.lcov.reportPaths=<working_directory>/coverage/lcov.info is what the scan reads.
The scan itself stays repository-wide -- sonar.sources is still the whole repository -- and only
the coverage LOOKUP follows the project, through SONAR_PROJECT_DIR on the Sonar step. Without
that the shared runner would find no coverage at the root and append an empty
sonar.*.reportPaths, overriding the repository's own value and reporting 0% coverage on new
code.
Not threaded. npm-cloudflare.yaml and yarn-cloudflare.yaml do not take the input. Their
deployment job builds and uploads through the shared 50-deployment/cloudflare action, whose
build_command and output_directory are repository-root-relative and shared with the GitLab and
Azure DevOps templates; a subfolder project expresses the path there
(build_command: 'cd app && npm ci && npm run build', output_directory: 'app/dist') until that
contract is changed on its own.
make test-working-directory fails if any of the above regresses.
name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
checks: write # Required for test results
contents: write # Required for releases
packages: write # Required for container registry
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/go-docker.yaml@main'reusable-claude-review.yaml posts an automated review on pull requests — not on drafts, forks
or automation branches; reusable-claude-mention.yaml answers @claude mentions in issues, PR
comments, and reviews.
The reusable- prefix marks the definition; the file you add to your own repository is the
caller below, named without it. Both need the
CLAUDE_CODE_OAUTH_TOKEN secret, set either on the repository or on the organization.
Pass the secret explicitly rather than with secrets: inherit — Semgrep's
yaml.github-actions.security.secrets-inherit rule fails make sast on the inherited form.
The caller's permissions: is a ceiling for the workflow it calls, so it must grant at
least what the definition declares — id-token: write included. The model is pinned with the
ANTHROPIC_MODEL environment variable rather than --model, because parse-sdk-options.ts
resolves model: options.model || modelFromClaudeArgs — the variable wins, and setting it
leaves claude_args free to be only about tools. Without it the CLI default applies, which in
CI resolves to Claude Sonnet, not Opus.
The review workflow runs one job covering three dimensions — correctness, security, and
software design — in a single prompt, posting one comment per pull request that every
later push updates in place (use_sticky_comment, safe precisely because there is a single
job). The consumer-visible check is claude-review / claude-review. The shape — tag mode, a
prose prompt, an explicit tool allowlist, no plugin, no subagent fan-out — is deliberate and
evidence-based: the plugin/agent-mode alternative loses roughly a third of its runs to a known
upstream session-lifecycle bug and fails silently, and an earlier three-job layout tripled the
comment volume. The full forensics, upstream issue links, and every operational fact about the
allowlist live in .docs/claude-review.md; read it before changing
the workflow. The design is modelled on
OneRedOak/claude-code-workflows. The
prompt keeps the disciplines the debugging proved out: the 4-agent pipeline plus security and
design passes run sequentially in-session, the ≥80 confidence filter applied to every finding
from every pass with dropped candidates listed, a posted comment on every run, and re-runs
that verify previous findings instead of hunting new ones.
Neither workflow builds, compiles, installs or tests anything, on purpose. Your pipeline
already does that on the same commit, and it reports on the same pull request. The action's own
prompt template says the opposite — it tells the model to install dependencies and run build
commands, and to explain in its comment when a linter or test suite was denied so the allowlist
can be widened — which is how a review came to end with a "Note on verification: go build ./... and go vet were denied by the tool permissions in this run". Both prompts now state the
tool surface they actually have, name the toolchains they exclude (go, node, npm, yarn,
pnpm, python, pip, pdm, mvn, gradle, dart, flutter, dotnet, composer,
bundle, terraform, terragrunt, docker, make), and override that instruction: no setup or
build step is planned, a denied command is not retried or mentioned, and no verification caveat is
written. The review adds the rule that makes it consistent — a finding that could only be
established by running something is dropped with its score rather than posted with a disclaimer.
make test-workflow-composition keeps each prompt in step with the tools its job grants.
The review skips the pull requests automation opens. A chore/bump-* or bump/* release
pull request moves a version number and folds the pending changelog fragments into
CHANGELOG.md; a chore/autoupdate-* pull request carries a dependency bump, and a dependabot/*
pull request carries the same for the action pins autoupdate will not touch. All four are
generated wholesale by a tool and merged unread, so reviewing one spends a full-diff Claude run
on a diff nobody reads back. The job's if: skips them, and the prefixes are the ones
basic-checks already exempts from the changelog rule — the two bump shapes are literal, and
dependabot/ is literal too, and only
the autoupdate prefix is the optional autoupdate_branch_prefix input (chore/autoupdate- by
default, the counterpart of AUTOUPDATE_BRANCH_PREFIX), so one repository does not have to
teach two jobs two names for the same automation. Emptying that input gives up the autoupdate
exemption only — the two bump shapes are still skipped. Drafts and pull requests from forks are
skipped too, so claude-review / claude-review legitimately reports as skipped, rather than as
a completed review, on a whole class of pull requests.
If you also run Copilot's automatic code review, it cannot be given the same guard. Its
ruleset rule takes only review_draft_pull_requests and review_on_push, and a ruleset's branch
condition targets the base branch, so the feature has no head-branch, author or path filter —
and neither does the account-wide "Automatic Copilot code review" setting. Nor is a bot identity
a way out: GitHub documents an Actions- or bot-opened pull request as reviewed like any other,
with the usage billed to whoever triggered the workflow or to a designated billing owner. The
only way to exclude automation pull requests is to turn the automatic review off and request it
per pull request instead — gh pr edit <number> --add-reviewer @copilot, or the REST equivalent
requesting copilot-pull-request-reviewer[bot].
id-token: write is required. Unless a github_token is passed explicitly, the action's
setupGitHubToken() always requests a GitHub OIDC token and exchanges it for a GitHub App
token, which is how it posts reviews and comments. That is GitHub authentication and is
separate from claude_code_oauth_token, which authenticates to Anthropic. Removing the scope
fails every run with Could not fetch an OIDC token.
Both take an optional runs_on — a JSON array of runner labels, '["ubuntu-latest"]' by
default — for moving the pair onto self-hosted runners. Two things are worth knowing before
using it.
A bare self-hosted host needs provisioning, and not the part most people reach for first.
The Actions runner ships its own Node and runs every JavaScript action with it, self-hosted
included, so host Node is not the gap. What anthropics/claude-code-action shells out to is:
bash (every step of that composite declares it), unzip (the Bun install unpacks through
@actions/tool-cache, which resolves it and throws when it is absent), git, a writable
$HOME (Bun lands in $HOME/.bun/bin), and egress to github.com, registry.npmjs.org,
api.github.com and api.anthropic.com. A missing one does not fail at job selection — it
fails minutes in, inside the action, with an error that never names the runner.
The two workflows differ in what they let onto that host. reusable-claude-review.yaml
runs only for pull requests opened from the repository itself. reusable-claude-mention.yaml
deliberately does not, because answering @claude under a fork's pull request is the point of a
mention responder. An outside contributor is kept out twice over — the job's if: admits only an
OWNER, MEMBER or COLLABORATOR, reading the association of whoever wrote the text that matched, and
the action independently re-checks the actor's write permission — but a maintainer's @claude on
a fork PR then runs holding contents: write and this repository's secrets, and
checks the fork's branch out into the workspace. The action restores its SENSITIVE_PATHS from the
base branch first — .claude, .mcp.json, .claude.json, .gitmodules, .ripgreprc, CLAUDE.md,
CLAUDE.local.md, .husky — so that injection path is closed; the fork's code itself is still on
the runner, and a hosted runner discards it with the VM where a persistent self-hosted one does
not. One consequence of CLAUDE.md being in that set is worth knowing before you rely on a review:
a pull request that edits CLAUDE.md is reviewed against the base branch's copy, so the
instructions it changes are not the ones the reviewing agent read.
The mention responder's caller must stay on the five events below. It passes
track_progress: true, which is what keeps it in tag mode now that it carries a prompt (a prompt
alone would select agent mode and replace the template that answers the @claude). The action
validates that input against pull_request, issues, issue_comment,
pull_request_review_comment and pull_request_review and throws on any other event, so
wiring it to something else fails the run by name rather than quietly not answering.
.github/workflows/claude-review.yaml:
name: 'Claude Review'
on:
pull_request:
types: ['opened', 'synchronize', 'ready_for_review', 'reopened']
jobs:
claude-review:
uses: 'rios0rios0/pipelines/.github/workflows/reusable-claude-review.yaml@main'
# Read the two paragraphs above before uncommenting: a bare self-hosted host
# needs provisioning, and it fails inside the action rather than at selection.
# with:
# runs_on: '["self-hosted"]'
secrets:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
permissions:
contents: 'read'
pull-requests: 'write'
issues: 'write'
id-token: 'write'.github/workflows/claude-mention.yaml:
name: 'Claude Mention'
on:
issue_comment:
types: ['created']
pull_request_review_comment:
types: ['created']
issues:
types: ['opened', 'assigned']
pull_request_review:
types: ['submitted']
jobs:
claude-mention:
uses: 'rios0rios0/pipelines/.github/workflows/reusable-claude-mention.yaml@main'
# Read the two paragraphs above before uncommenting: this one also puts a
# fork's checked-out branch on whatever host you name here.
# with:
# runs_on: '["self-hosted"]'
secrets:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
permissions:
contents: 'write'
pull-requests: 'write'
issues: 'write'
id-token: 'write'
actions: 'read'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: 'write'
contents: 'write'
packages: 'write'
jobs:
default:
uses: 'rios0rios0/pipelines/.github/workflows/pdm-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/gradle-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
pull-requests: write
checks: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/yarn-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/dotnet-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
pull-requests: write
checks: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/npm-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ 'main' ]
tags: [ '*' ]
pull_request:
branches: [ 'main' ]
permissions:
contents: 'write'
checks: 'write' # the Test Results check run on every run
pull-requests: 'write' # the coverage comment on every pull request
# No `security-events: write` is needed: that permission exists for CodeQL,
# which has no Dart extractor and is not part of the Dart pipeline.
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/flutter-artifacts.yaml@main'
with:
flutter_version: '3.47.0' # omit to track the current stable releasejobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/dart-library.yaml@main'
secrets:
PUB_TOKEN: ${{ secrets.PUB_TOKEN }} # only used by the tag-triggered publish jobThe toolchain is detected from pubspec.yaml, so the same workflows serve a
Flutter app and a pure Dart package. See
.docs/examples/github-flutter-artifacts
for a complete project.
Compiling the project. tests > test:build runs beside test:all -- same stage, same
needs: -- and compiles through global/scripts/languages/dart/build/run.sh in --debug mode.
It covers what neither of its neighbours can: quality:analyze resolves and type-checks but never
runs a compiler back end, and test:all runs the suite on the Dart VM and never reaches dart2js,
dart2wasm or the AOT compiler -- so a const evaluation failure, a deferred-loading mistake, a
tree-shaking error on a non-constant icon or a plugin with no implementation for the target passes
both and fails at release time.
This job closes a PLATFORM GAP rather than adding a new idea: test:build has shipped in the
GitLab and Azure DevOps Dart templates all along, and GitHub Actions was the one platform of the
three without it -- so a consumer migrating from GitLab silently lost its compile check. It calls
the same runner with the same defaults. test_build_targets is the GitHub spelling of
DART_TEST_BUILD_TARGETS (empty auto-detects: apk for a Flutter app, exe for a package with
bin/, nothing at all for a library), test_build_mode of DART_BUILD_MODE, and
test_build_command of the new DART_TEST_BUILD_COMMAND -- the escape hatch, on all three
platforms, for a build that is a command rather than a target: flags derived from an environment,
a generated file written beside the bundle, an artifact assertion that runs against what was just
built. Set test_build_node_version when that command needs Node.js. The 40-delivery stage goes
on building the real release artifact; this is a check.
Coverage on a pull request. tests > test:all posts one sticky comment per pull request --
the totals table, the floor it was judged against and the coverage of every source file the change
touches -- updated in place on every push, and writes the same table to the job summary of every
run. It is the parity the JavaScript pipelines carry through vitest-coverage-report-action. Dart
emits LCOV only, so the runner's own lcov_to_markdown.py renders it from the very parse the
coverage_minimum gate evaluates, which is what keeps the comment and the gate agreeing to the
decimal and keeps whatever coverage_exclude drops out of both. The caller grants
pull-requests: write, as above; without it, or on a pull request from a fork (whose token is
read-only whatever the caller grants), the posting step is a warning rather than a failure and the
table still lands in the job summary. GitLab CI and Azure DevOps need nothing of the kind: their
merge-request widget and Code Coverage tab read the Cobertura report the same runner writes.
Test results on the Checks tab. The same job publishes the suite as a Test Results check
run -- every test listed, every failure annotated at the line its stack trace names, on pushes
and pull requests alike -- which is what go.yaml and yarn.yaml have published through
dorny/test-reporter all along. It reads the runner's test-results.json through the action's
Dart or Flutter parser (dart-json or flutter-json, picked by the toolchain the test action
resolves and publishes as its toolchain output) rather than the JUnit through its experimental
java-junit one, because tojunit records each suite's class name as the runner's absolute path
with the separators turned into dots, which that parser can neither read as a file nor show as a
name. The two stream parsers are not interchangeable either: only the Flutter one reads a Flutter
failure's real message out of the print events and matches its #0 ... (file:line:col) frames,
so under the Dart one every Flutter failure would read Test failed. See exception logs above.
and be annotated at the testWidgets( line. The file is the
package:test event stream with pub's progress lines and flutter_tools' array-shaped events
removed, since flutter test --machine shares stdout with both and the parser fails the whole
report on the first line it cannot parse. The caller grants checks: write; without it, or on a
pull request from a fork, the step is a warning and the JUnit is still in the test-results
artifact.
name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/maven-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
contents: write
packages: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/composer-docker.yaml@main'name: 'CI/CD Pipeline'
on:
push:
branches: [ main ]
tags: [ '*' ]
pull_request:
branches: [ main ]
permissions:
security-events: write
contents: write
packages: write
jobs:
pipeline:
uses: 'rios0rios0/pipelines/.github/workflows/bundler-docker.yaml@main'
# with:
# working_directory: 'api' # when the Gemfile is not at the repository rootGitLab CI templates use remote includes and are organized by language in the gitlab/ directory.
| Language | Template | Purpose |
|---|---|---|
| Go | go-docker.yaml |
Go with Docker delivery |
| Go | go-render.yaml |
Go, Docker + Render deploy |
| Go | go-binary.yaml |
Go binary pipeline |
| Go | go-sam.yaml |
Go with AWS SAM deployment |
| Java | gradle-docker.yaml |
Gradle with Docker |
| Java | maven-docker.yaml |
Maven with Docker |
| Python | pdm-docker.yaml |
Python PDM with Docker |
| JavaScript | yarn-docker.yaml |
Node.js Yarn with Docker |
| JavaScript | yarn-cloudflare.yaml |
Yarn + Cloudflare deploy |
| JavaScript | npm-cloudflare.yaml |
npm + Cloudflare deploy |
| .NET | framework.yaml |
.NET Framework pipeline |
| Dart | dart-docker.yaml |
Dart with Docker delivery |
| Dart | dart-library.yaml |
Dart package to pub.dev |
| Flutter | dart-cloudflare.yaml |
Flutter + Cloudflare deploy |
| Flutter | flutter-docker.yaml |
Flutter web in a container |
| Flutter | flutter-artifacts.yaml |
Flutter web + Android |
| Terraform | terra.yaml |
Terraform IaC pipeline |
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/golang/go-docker.yaml'
# Optional: Override delivery stage for custom Docker build
.delivery:
script:
- docker build -t "$REGISTRY_PATH$IMAGE_SUFFIX:$TAG" -f .ci/stages/40-delivery/Dockerfile .
cache:
key: 'test:all'
paths: !reference [ .go, cache, paths ]
policy: 'pull'include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/python/pdm-docker.yaml'
variables:
PYTHON_VERSION: "3.11" # Optional: specify a Python versioninclude:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/dart/flutter-artifacts.yaml'
variables:
DART_FATAL_INFOS: 'true' # fail on lints, not only errors and warnings
DART_COVERAGE_MINIMUM: '80' # fail below this line coverage
DART_COVERAGE_EXCLUDE: '*.g.dart *.freezed.dart' # generated sources, out of the total
ENABLE_ANDROID_DELIVERY: 'true' # needs an Android-SDK-capable runnerNo runner image with Dart preinstalled is required: the SDK is downloaded from
Google's archive and cached in $CI_PROJECT_DIR/.sdk. See
.docs/examples/gitlab-dart-library for a
complete package pipeline.
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/terraform/terra.yaml'The terra CLI wraps Terraform and Terragrunt with a simplified interface, auto-answering prompts, and parallel execution. The terra pipeline provides code check, security, tests, and management stages. Delivery is intentionally excluded because it is project-specific (plan/apply targets, environments, stack ordering). See examples for all providers in the Azure DevOps section below.
Every Terra pipeline (Azure DevOps, GitLab CI, GitHub Actions) exposes a single unified test:all job that delegates to global/scripts/languages/terraform/test-all/run.sh. The runner orchestrates two tiers:
| Tier | Inputs | Tooling | Outputs (under build/reports/) |
|---|---|---|---|
terra-test |
modules/*/tests/*.tftest.hcl |
terraform test -junit-xml |
terra-tests.xml, terra-coverage.{md,json,xml} |
terratest |
tests/terratest/*.go |
go test ./... + go-junit-report |
junit-terratest.xml |
The runner auto-detects which tiers the consumer actually has, runs only those, merges both JUnit files into junit-terra-all.xml for the single-artifact upload contract used by GitLab CI and GitHub Actions, and propagates a non-zero exit from either tier so CI correctly fails. When neither tier has tests (e.g., a stack-only repo without modules/ tests or tests/terratest/), the runner emits an empty-but-valid JUnit and exits 0 so the job passes without a bespoke opt-out.
The tiers above parse; none of them resolves a reference. terra-test covers only reusable modules that carry a test file, terratest reads HCL offline, and structural asserts conventions in bash — so a root module can reference a module, variable, resource or output that does not exist and every tier stays green. That defect then fails every plan and apply of the root module, for every target, before a single resource is touched:
Error: Reference to undeclared module
Error: Reference to undeclared resource
Error: Unsupported argument / Missing required argument
The usual way one lands is a rename or a deletion that updates the definition and the obvious call sites but misses one file.
global/scripts/languages/terraform/validate/run.sh closes that gap by running terraform init -backend=false plus terraform validate over every directory under VALIDATE_ROOTS (default stacks) that holds a .tf file, emitting junit-validate.xml. -backend=false is what makes it a test rather than a deployment step: no backend credentials, no state access, no cloud login. Providers are still downloaded — through a TF_PLUGIN_CACHE_DIR shared by every root module within one run, so a repo with dozens of them does not re-fetch the same providers per directory — and module sources are still resolved, so private module sources need their credentials configured first; the stage's PRE_STEPS hook is spliced in for exactly that.
That cache defaults to build/.terraform-plugin-cache, inside the checkout, and deliberately not to a $HOME path. Terraform's plugin cache is not safe for concurrent use, and $HOME is what is shared when a machine runs several agents under one service account — two jobs initialising at once then write the same provider binary and fail with text file busy and dependency-lock checksum mismatches, neither of which names the cache. Override it only if your jobs cannot overlap.
Caching the provider binary is only half the round trip. Terraform still runs the registry protocol per directory, and the last leg of it fetches the provider's SHA256SUMS and SHA256SUMS.sig — from releases.hashicorp.com for a HashiCorp-namespace provider, and from that provider's GitHub release page for every community one. Those costs are per directory, so they multiply by the number of root modules. A repo with dozens of roots and a handful of community providers therefore asks github.com for the same few checksum files dozens of times inside a single job, from one egress IP, and github.com starts answering 503 Service Unavailable.
global/scripts/shared/terraform-provider-mirror.sh removes those requests instead of retrying them. A Terraform filesystem_mirror is an installation method: when one can satisfy a provider, no registry query and no checksum fetch happens at all. Its on-disk layout for an unpacked provider — <host>/<namespace>/<name>/<version>/<os>_<arch>/ — is byte-for-byte the layout TF_PLUGIN_CACHE_DIR and Terragrunt's provider cache already write, so the stores this machine has been filling for months are already valid mirrors and nothing is repacked or re-downloaded. Measured with TF_LOG=DEBUG on a root module declaring three providers, one of them community-hosted — outbound requests per terraform init:
| Setup | registry.terraform.io | releases.hashicorp.com | github.com |
|---|---|---|---|
| warm plugin cache, no lock file | 7 | 4 | 2 |
| lock file, no plugin cache | 7 | 4 | 2 |
| lock file and warm plugin cache | 4 | 0 | 0 |
filesystem_mirror |
0 | 0 | 0 |
Stores are searched in this order, and each one that exists contributes a mirror: TF_PROVIDER_MIRROR_DIR (explicit override), TERRA_PROVIDER_CACHE_DIR, the terra CLI's ~/.cache/terra/providers, then the tier's own TF_PLUGIN_CACHE_DIR. That last entry is what makes a cold machine converge inside a single run — the fallback writes there, so only the first directory needing a given provider version pays for it.
The fallback is per directory and automatic: anything the mirror cannot serve is initialised again with the registry added as a second source, with the same flags and the same output, wrapped in the bounded retry (TF_INIT_MAX_ATTEMPTS, default 4; TF_INIT_RETRY_DELAY, default 5).
The plugin cache is listed as a store for the primary attempt only. That attempt runs with TF_PLUGIN_CACHE_DIR unset, so the cache is purely a read source there — which is what lets a cold machine converge, since the fallback's downloads land in it and the next directory finds them locally. The fallback keeps the variable in force and therefore must not list the cache: a directory may be an active cache or a mirror source in one init, never both. Listing it as both makes Terraform refuse (cannot install existing provider directory … to itself); listing two different directories while the cache is active makes it write a symlink into the cache that a later direct install cannot overwrite.
Two configurations do that, and the difference between them is the whole design. The primary pairs the mirror with direct { exclude = ["registry.terraform.io/*/*"] }, making the mirror the only permitted method — that is what reaches zero outbound requests, but it is all-or-nothing, since one unsatisfiable provider fails the whole init. The fallback pairs the same mirror with an unrestricted direct {}. Measured, because the intuition is wrong in both directions: an unrestricted direct does re-enable the registry version query for every provider, but it does not re-fetch the github checksums for providers the mirror can serve — those still install from the mirror (unauthenticated), and only the genuine misses are downloaded. Registry queries are the cheap half and have never been the failing half; the github checksum fetches are what gets throttled, and the fallback minimises those rather than the total. A machine with no local store therefore behaves exactly as it did before. Two other guards are worth knowing: a consumer that has already set TF_CLI_CONFIG_FILE is never overridden — Terraform takes one config file and two provider_installation blocks cannot be merged — and a directory that already has a .terraform.lock.hcl is initialised -lockfile=readonly, because an unpacked mirror can only produce h1: hashes and an unguarded run would strip the zh: hashes from a complete lock file. Set TF_PROVIDER_MIRROR=off to disable the whole thing.
Note one deliberate consequence in the terra-test tier, which inits with -upgrade: with a warm mirror, -upgrade resolves to the newest version present in the mirror rather than the newest published. The modules there are exercised with mock_provider against ephemeral state, so that trade buys determinism at no real cost — but a scheduled build that wants true upstream resolution should set TF_PROVIDER_MIRROR=off.
It is opt-in rather than on by default, because unlike its siblings it needs the network and possibly credentials, and because it surfaces pre-existing reference errors — which is the point, but is a consumer's decision to take rather than something to impose on their next build. Each platform opts in the way it already expresses options, and the pre-script hook each already has for private modules is reused rather than adding a second one:
| Platform | Opt in with | Override the roots | Credentials for private modules |
|---|---|---|---|
| Azure DevOps | ENABLE_VALIDATE: true (parameter) |
VALIDATE_ROOTS (parameter) |
PRE_STEPS |
| GitLab CI | ENABLE_VALIDATE: "true" (variable) |
VALIDATE_ROOTS (variable) |
VALIDATE_PRE_SCRIPT |
| GitHub Actions | enable_validate: true (input) |
validate_roots (input) |
pre_script |
Locally: make test-validate. Vendored copies under .terraform/ are excluded, and the runner no-ops with a valid empty report when the roots are absent.
Configure these in your GitLab project settings:
| Variable | Description | Required For |
|---|---|---|
SONAR_HOST_URL |
SonarQube server URL | Code quality |
SONAR_TOKEN |
SonarQube authentication token | Code quality |
DOCKER_REGISTRY |
Container registry URL | Docker delivery |
DOCKER_USERNAME |
Registry username | Docker delivery |
DOCKER_PASSWORD |
Registry password | Docker delivery |
Azure DevOps templates are located in the azure-devops/ directory and use template references.
| Language | Template | Purpose |
|---|---|---|
| Go | go-docker.yaml |
Go with Docker delivery |
| Go | go-arm.yaml |
Go with Azure ARM deployment |
| Go | go-function-arm.yaml |
Go Azure Functions |
| Go | go-lambda.yaml |
Go AWS Lambda deployment (ZIP) |
| Go | go-lambda-sam.yaml |
Go AWS Lambda deployment (SAM) |
| Java | kotlin-gradle.yaml |
Kotlin/Gradle with Docker |
| Python | pdm-docker.yaml |
Python PDM with Docker |
| JavaScript | yarn-docker.yaml |
Node.js Yarn with Docker |
| .NET | core.yaml |
.NET Core pipeline |
| Dart | dart/dart-docker.yaml |
Dart with Docker delivery |
| Dart | dart/dart-library.yaml |
Dart package to pub.dev |
| Flutter | dart/flutter-docker.yaml |
Flutter web in a container |
| Flutter | dart/flutter-artifacts.yaml |
Flutter web + Android |
| Terraform | terra.yaml |
Infrastructure as Code pipeline |
| Terra CLI | terra/terra.yaml |
Terra CLI wrapper pipeline |
trigger:
branches:
include: [ main ]
tags:
include: [ '*' ]
pool:
vmImage: 'ubuntu-latest'
variables:
- ${{ if startsWith(variables['Build.SourceBranch'], 'refs/tags/') }}:
- group: 'production-variables'
- ${{ else }}:
- group: 'development-variables'
resources:
repositories:
- repository: 'pipelines'
type: 'github'
name: 'rios0rios0/pipelines'
endpoint: 'YOUR_GITHUB_SERVICE_CONNECTION' # Configure this
stages:
- template: 'azure-devops/golang/go-docker.yaml@pipelines'resources:
repositories:
- repository: 'pipelines'
type: 'github'
name: 'rios0rios0/pipelines'
ref: 'refs/heads/main'
endpoint: 'github-service-connection'
extends:
template: 'azure-devops/dart/flutter-artifacts.yaml@pipelines'
parameters:
ENABLE_WEB_DELIVERY: true
ENABLE_ANDROID_DELIVERY: trueMicrosoft-hosted ubuntu-latest agents already carry the Android SDK and a JDK,
so the Android target works without extra setup.
resources:
repositories:
- repository: 'pipelines'
type: 'github'
name: 'rios0rios0/pipelines'
endpoint: 'YOUR_GITHUB_SERVICE_CONNECTION'
stages:
- template: 'azure-devops/golang/go-arm.yaml@pipelines'
parameters:
DOCKER_BUILD_ARGS: '--build-arg VERSION=$(Build.BuildNumber)'
RUN_BEFORE_BUILD: 'echo "Preparing build environment"'trigger:
branches:
include: [ main ]
tags:
include: [ '*' ]
pool:
vmImage: 'ubuntu-latest'
variables:
- ${{ if startsWith(variables['Build.SourceBranch'], 'refs/tags/') }}:
- group: 'production-variables'
- ${{ else }}:
- group: 'development-variables'
resources:
repositories:
- repository: 'pipelines'
type: 'github'
name: 'rios0rios0/pipelines'
endpoint: 'YOUR_GITHUB_SERVICE_CONNECTION'
stages:
- template: 'azure-devops/golang/go-lambda.yaml@pipelines'
parameters:
LAMBDA_FUNCTION_NAME: 'my-go-lambda-function'
AWS_REGION: 'us-east-1'
AWS_SERVICE_CONNECTION: 'AWS-Service-Connection' # Configure in Azure DevOps
DEPLOY_STRATEGY: 'zip' # or 'sam'
GOARCH: 'amd64' # or 'arm64'
LAMBDA_TIMEOUT: '30'
LAMBDA_MEMORY_SIZE: '128'For SAM-based deployments:
stages:
- template: 'azure-devops/golang/go-lambda-sam.yaml@pipelines'
parameters:
S3_BUCKET: 'my-deployment-bucket'
AWS_REGION: 'us-east-1'
AWS_SERVICE_CONNECTION: 'AWS-Service-Connection'
SAM_CONFIG_ENV: 'default' # References samconfig.toml environmentCreate these variable groups in Azure DevOps Library:
Shared Variables (All Projects):
| Variable | Description |
|---|---|
SONAR_HOST_URL |
SonarQube server URL |
SONAR_TOKEN |
SonarQube authentication token |
Project-Specific Variables (.NET Example):
| Variable | Description |
|---|---|
SONAR_PROJECT_NAME |
SonarQube project display name |
SONAR_PROJECT_KEY |
SonarQube project unique key |
Project-Specific Variables (Python Example):
| Variable | Description |
|---|---|
SAFETY_API_KEY |
Safety authentication token |
Every credential above may be stored as a secret variable. Azure Pipelines
withholds secret variables from a step's process environment, so each template
that consumes one maps it explicitly through env: -- make test-azure-secret-env
holds that contract across the repository.
AWS Lambda Deployment Variables (Optional):
| Variable | Description | Required For |
|---|---|---|
AWS_ACCESS_KEY_ID |
AWS access key (if not using service connection) | Lambda deployment |
AWS_SECRET_ACCESS_KEY |
AWS secret key (if not using service connection) | Lambda deployment |
LAMBDA_ROLE_ARN |
IAM role ARN for Lambda function | Creating new functions |
Note: For AWS deployments, it is recommended to use Azure DevOps AWS Service Connection instead of storing credentials in variable groups. Configure the service connection in Azure DevOps Project Settings > Service Connections.
Third-party GitHub actions and container images are content-pinned, direct tool
installs declare an exact version, and standalone binary downloads are verified
against committed checksums. First-party GitHub actions declare whether they
intentionally follow @main or must follow the exact running workflow commit
through $/path/to/action. make test-supply-chain and
make test-workflow-composition enforce these guarantees and that distinction.
| What | Pinned to | Where |
|---|---|---|
| Third-party GitHub Actions | 40-character commit SHA, with the version in a trailing # vX.Y.Z comment |
every uses: except internal pipeline references and the two explicitly allowlisted organization-owned Claude workflows |
| First-party GitHub Actions | $/path/to/action where a consumer pin must cover the nested action; explicit @main otherwise |
workflow-composition contract |
| Container images | tag@sha256:<digest> |
every image: and every Dockerfile FROM |
| Downloaded binaries | exact version and a committed SHA-256 | global/scripts/shared/pinned-versions.sh |
go install / pip / gem / npx packages |
exact version | pinned-versions.sh, mirrored inline where a template has no SCRIPTS_DIR |
These controls do not claim offline reproducibility for package-manager transitive dependencies or live service content such as Semgrep Registry packs. Lockfiles, hashes, or a mirrored registry are still required where that stronger guarantee is part of a consumer's threat model.
Pinning stops a dependency moving without a decision. It does not tell you when
to make one -- a pin never announces that it is three CVEs behind. That is what
dependency-updates.yaml is for: it runs twice a week (Monday and Thursday,
06:00 UTC) and fails when any pinned dependency has moved upstream.
make check-dependency-updates # run the same check by hand (needs the network)It reports four things, and exits non-zero on any of them:
| Surface | Question |
|---|---|
| GitHub Actions | is there a newer release for that action? |
| Container images | does the pinned tag still resolve to the digest we pinned? |
| Binaries and packages | is there a newer version upstream? |
| Inline copies | do the two copies of a version still agree? |
Images are checked by digest, not tag, because that is the question worth
asking of a container: python:3.13-slim is rebuilt with patched system packages
under the same tag, so "is there a newer tag" would miss every security rebuild.
Set GITHUB_TOKEN before running it -- around forty of the lookups hit
api.github.com, which allows 60 requests/hour unauthenticated. A lookup that
cannot be completed exits 2 and is deliberately never reported as "up to date".
Each pin in pinned-versions.sh carries a # upstream: annotation naming where
its releases come from; a pin without one is reported as untracked and fails,
rather than being skipped quietly. To silence a genuinely rolling reference such
as alpine:edge, add it to .dependency-updates.json:
{ "ignore": ["alpine:edge"] }- Change the
*_PINNED_VERSIONvalue inglobal/scripts/shared/pinned-versions.sh. - Replace every
*_SHA256_*for that tool, taken from the upstream checksum manifest. Never carry an old digest forward. - Run
make test-supply-chain.
Every version is also overridable from the environment, so an operator can respond to an upstream CVE without waiting for a release here:
GITLEAKS_VERSION=8.31.0 GITLEAKS_SHA256_OVERRIDE=<digest> make gitleaksOverriding the version without a digest is accepted but prints a warning and skips verification -- a committed digest describes one exact build, so reusing it against a different version would fail every time and read like an attack.
Pinning the entry point fixes the reusable workflow file. A nested first-party
action using $/path/to/action follows that exact commit; one using @main
deliberately continues to follow the latest first-party implementation. The Yarn
Semgrep chain uses $/ for both the composite and its scripts-repo checkout,
so a workflow SHA pins the scanner wiring and script tree end to end. This
self-reference requires GitHub Actions runner 2.336.0 or newer and is not
available on GitHub Enterprise Server.
Before scripts-repo honoured explicit refs, even a directly pinned action
still fetched scripts from main. The abstracts now check out the ref the
action itself resolved.
# GitHub Actions -- a release tag, or a commit SHA for full immutability
uses: 'rios0rios0/pipelines/.github/workflows/go-docker.yaml@4.23.0'# GitLab CI
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/4.23.0/gitlab/golang/go-docker.yaml'
variables:
PIPELINES_REF: '4.23.0' # pins the shared scripts too# Azure DevOps
resources:
repositories:
- repository: 'pipelines'
type: 'github'
name: 'rios0rios0/pipelines'
ref: 'refs/tags/4.23.0' # without this, Azure resolves the DEFAULT BRANCH
endpoint: 'YOUR_GITHUB_SERVICE_CONNECTION'
variables:
PIPELINES_REF: '4.23.0' # pins the shared scripts too@vN (e.g. @v4) tracks the major and keeps receiving patches automatically --
weaker than a tag, far better than a branch. Every example under
.docs/examples/ is pinned and shows the shape for its platform.
The pipeline writes report files into the consuming repository's working tree, and until now each
consumer had to know their names and track them by hand — so a script that started writing a new report
leaked it into every repository at once, silently. global/gitignore/ is the canonical list.
make gitignore # write/refresh the shared block in this project's .gitignore
make gitignore-check # fail when that block is out of date (wire into a PR check)The block is delimited by # >>> pipelines:begin / # <<< pipelines:end and everything outside it is
yours and never touched. It is generated rather than referenced because git has no include
directive for ignore files and refuses to follow a symlinked .gitignore; the mechanisms that do take
an external file (core.excludesFile, $GIT_DIR/info/exclude) are local to a clone, so they never
reach CI or a bot running git add -A. The block sits first in the file on purpose — gitignore is
last-match-wins, so your own entries below it, including a ! negation, still win.
| Tool | Purpose | Script Location | Configuration |
|---|---|---|---|
| Gitleaks | Secret detection | global/scripts/tools/gitleaks/ |
.gitleaks.toml |
| CodeQL | SAST security scanning | global/scripts/tools/codeql/ |
Auto-configured |
| Semgrep | Static analysis | global/scripts/tools/semgrep/ |
Auto-configured |
| Hadolint | Dockerfile linting | global/scripts/tools/hadolint/ |
.hadolint.yaml |
| Tool | Purpose | Languages | Script / Integration |
|---|---|---|---|
| govulncheck | Go vulnerability scanning | Go | global/scripts/languages/golang/govulncheck/ |
| Safety | Python dependency scanning | Python | pdm run safety-scan |
| OWASP Dependency-Check | Java dependency scanning | Java | global/scripts/languages/java/dependency-check/ |
| yarn npm audit | JS/Node.js dependency scanning | JavaScript | yarn npm audit --recursive |
| npm audit | JS/Node.js dependency scanning | JavaScript | npm audit --audit-level=high |
| Composer Audit | PHP dependency scanning | PHP | composer audit |
| bundler-audit | Ruby dependency scanning | Ruby | bundle-audit check --update |
| Tool | Purpose | Script Location | Configuration |
|---|---|---|---|
| Basic Checks | PR/MR rebase and changelog verification | global/scripts/shared/rebase-check.sh, changelog-check.sh |
Auto-configured |
| SonarQube | Code quality & security | global/scripts/tools/sonarqube/ |
Project settings |
| Dependency Track | SBOM tracking | global/scripts/tools/dependency-track/ |
Environment variables |
global/scripts/tools/sonarqube/run.sh completes the repository's sonar-project.properties before it
runs sonar-scanner -- the file is optional, and most repositories ship none. Every key below is a
default: a repository that declares the key in sonar-project.properties keeps its own value, and
that is how any of them is overridden. The four test-classification keys are the one exception -- they are
derived only as a set, as Test classification below explains.
| Key | Derived value | Derived from |
|---|---|---|
sonar.projectKey |
<owner>_<repo> (characters outside A-Za-z0-9._:- become _) |
SONAR_PROJECT_KEY, else GITHUB_REPOSITORY, SYSTEM_TEAMPROJECT/BUILD_REPOSITORY_NAME (Azure DevOps) or CI_PROJECT_PATH (GitLab CI) |
sonar.projectName |
<repo> |
SONAR_PROJECT_NAME, else the same platform variables |
sonar.projectVersion |
the latest tag, or latest |
git describe --tags (always written) |
sonar.*.reportPaths |
the Go or JaCoCo report the test stage left behind, or cleared when there is none | the files under coverage/, build/reports/, target/site/jacoco/ and TestResults/ |
sonar.sources / sonar.tests |
. / . |
SonarSource's layout for sources and tests sharing one tree; derived only as part of the classification set below |
sonar.test.inclusions |
**/*_test.go,**/test/**,**/tests/**,**/test_*.py,**/*_test.py,**/conftest.py,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.test.js,**/*.spec.js,**/__tests__/**,**/src/test/**,**/*Tests/**,**/*.Tests/**,**/spec/** |
the Go, Python, JavaScript/TypeScript, Java, .NET and Ruby test conventions |
sonar.exclusions |
the test patterns above plus **/vendor/**,**/node_modules/**,**/build/**,**/dist/**,**/coverage/**,**/.pipelines/** |
a file matched by the test inclusions must not be indexed as a source file as well |
sonar.test.exclusions |
**/vendor/**,**/node_modules/** |
vendored tests are not the repository's; an independent default, because it only ever shrinks the test set |
Test classification. Without sonar.tests, every *_test.go, test_*.py or *.spec.ts is analyzed
as production code, and the scaffolding that table-driven tests legitimately repeat is what fails the
"Duplication on New Code" condition of the quality gate.
sonar.sources, sonar.tests, sonar.test.inclusions and sonar.exclusions are derived as one unit,
and only when sonar-project.properties declares none of them: they describe a partition of the tree, and
they are only valid together. When the main and test sets overlap, sonar-scanner does not warn -- it aborts
the analysis with File <path> can't be indexed twice. So a repository that declares any part of the
classification owns all of it, and nothing is derived onto its half. Describe your layout with the whole
set, for example:
sonar.sources=src
sonar.tests=qa
sonar.test.inclusions=qa/**
sonar.exclusions=qa/**,src/generated/**First-party workflow references (githubactions:S7637). The rule "Use full commit SHA hash for this
dependency" flags every uses: that is not pinned to a 40-character commit, which includes this
repository's own reusable workflows referenced as <owner>/pipelines/...@main -- deliberate, because this
repository is the single source of truth and pins every third-party action itself (see
Supply-Chain Pinning). The runner therefore ignores the rule for a workflow or
composite-action file (.github/workflows/*.yml|yaml, .github/actions/**/action.yml|yaml) only when
every uses: in it is first-party (<owner>/... with a trusted owner), local (./...) or pinned to a
commit SHA. A file with one unpinned third-party action keeps every finding it has, and the job log names
the reference that blocked it. The trusted owners come from SONAR_FIRST_PARTY_OWNERS (comma separated),
else the owner of GITHUB_REPOSITORY -- so GitHub Actions needs no configuration, while a pipeline on
another platform or a repository trusting several organizations sets the variable. Without either, nothing
is ignored.
The ignore rules are written as sonar.issue.ignore.multicriteria.fp<N> entries, and the
sonar.issue.ignore.multicriteria list is merged with the ids a repository already declares (a second
sonar.issue.ignore.multicriteria= line would replace the first). Keep per-repository ignore rules in
sonar-project.properties rather than in the SonarQube UI: a list in the file overrides the one on the
server.
The uploader is driven entirely by environment variables. Only the first two are required.
| Variable | Default | Purpose |
|---|---|---|
DEPENDENCY_TRACK_HOST_URL |
— | Base URL of the instance. A trailing / or /api is stripped, so both forms work |
DEPENDENCY_TRACK_TOKEN |
— | API key. Sent through a curl config file on stdin, never on argv |
DEPENDENCY_TRACK_DEFAULT_BRANCH |
— | The repository's default branch, as main or refs/heads/main. Needed only on Azure DevOps and GitHub Actions, neither of which publishes it (see below) |
DEPENDENCY_TRACK_PARENT_NAME / _PARENT_VERSION |
— | Collection parent for newly created projects |
DEPENDENCY_TRACK_PROJECT_NAME / _PROJECT_VERSION |
from the BOM | Override the identity taken from metadata.component |
DEPENDENCY_TRACK_IS_LATEST |
auto-detected | Force the isLatest flag on or off |
DEPENDENCY_TRACK_UPLOAD_ON_PULL_REQUEST |
false |
Upload from merge/pull-request builds too |
DEPENDENCY_TRACK_INSECURE |
unset | Skip TLS verification (prefer trusting the CA on the agent) |
Three behaviours are worth knowing before adopting it, because each one is silent:
- Merge/pull-request builds do not upload. A project's identity in Dependency-Track is the pair
(name, version), so a pull request whose version file is already bumped would create that version's project before the merge — and keep it if the merge never happens. SetDEPENDENCY_TRACK_UPLOAD_ON_PULL_REQUEST=trueif you want per-pull-request inventory. isLatestis claimed only on a default branch or a tag. GitLab CI publishesCI_DEFAULT_BRANCH, so it needs no help. Azure DevOps publishes no variable carrying the repository's default branch (Build.Repository.DefaultBranchdoes not exist), and GitHub Actions exposes it only through thegithub.event.repositorycontext — on those two, setDEPENDENCY_TRACK_DEFAULT_BRANCHor a default-branch build will upload without claiming the flag.- A collection parent applies only to projects being created. Dependency-Track resolves
parentNamesolely when it auto-creates the project; for one that already exists the field is read and ignored, with no error. Re-parenting an existing portfolio needsPATCH /api/v1/project/{uuid}from an administrative job.
Verified against Dependency-Track 4.14.x and 5.0.5: the upload endpoint, its multipart parameters and
its authentication header are identical across both, so one code path serves them.
Every pipeline includes basic checks that run in parallel with linting during the Code Check stage. These checks verify:
-
Rebase verification — the PR/MR branch is rebased on top of the target branch (usually
main). If the branch is behind, the pipeline fails with clear instructions to rebase. This enforces a linear commit history and prevents merge conflicts from reaching the test and delivery stages. -
Changelog validation — every branch carries its own changelog entry. Which form is required depends on the repository's layout, and the check decides by looking for a
.chlog.yaml/.chlog.yml, or a.changes/unreleased/directory, at the root:- chlog repositories — an ordinary branch must add a new fragment under
.changes/unreleased/, and the failure message quotes thechlog new --kind <Kind> --body "<description>"that writes one. Abump/*orchore/bump-*branch carries no fragment —chlog mergehas already folded the pending ones intoCHANGELOG.md— so on those the requirement flips to an updatedCHANGELOG.md. - Every other repository — the
CHANGELOG.mdfile must be modified and the new entries must be placed under the[Unreleased]section. If entries appear below an existing version section (e.g., due to an erroneous rebase), the pipeline fails with instructions to fix the placement.
Either way the check is skipped when the branch HEAD is already an ancestor of the target branch, since a branch that is already merged has nothing left to gate.
Automation branches (
chore/autoupdate-*by default,$AUTOUPDATE_BRANCH_PREFIXto change it) are held to a different requirement: a new entry, or one already pending on the target branch. autoupdate deliberately does not restate an entry the target branch already records as pending — it runs unattended on a schedule against the same repositories, so without that check yesterday's bullet is written again verbatim on every run until a release moves it away. A correct scheduled dependency PR therefore carries no fragment and noCHANGELOG.mdedit, and the strict rule above failed it. This is not a blanket skip: nothing added and nothing pending still fails, because then the change is written down nowhere. Only.yaml/.ymlfiles count as pending, so a.gitkeepholding the directory open is not mistaken for an entry.Dependency-bot branches (
dependabot/*) are exempt outright — no fragment, noCHANGELOG.mdedit, no condition. The asymmetry with the automation branches above is deliberate: autoupdate can write an entry and merely declines to restate a pending one, while Dependabot cannot write one at all. Its branches carry a read-only token, so nothing running on them can commit a fragment back, and auto-appending one would mean apull_request_targetjob holding a write credential on a bot branch. autoupdate is not a substitute either: it deliberately skips SHA pins, and a SHA pin with a trailing# vX.Y.Zcomment is exactly what a Dependabotgithub-actionsupdate advances. Without this exemption a repository that adopts the stage makes every Dependabot pull request permanently red and loses the only mechanism that keeps its action pins current.The exemption does not extend to human branches. "Something was already pending" is a defence only for a producer that actually compared before deciding; for a person who forgot the entry it is a coincidence, and catching that is the point of the check.
The rule is implemented four times — once inline per platform template, plus the standalone
global/scripts/shared/changelog-check.sh— becausequality:basic-checksruns in a minimal image holding only the consumer's repository and cannot reach this repository's scripts..github/tests/test-basic-checks.shruns the same fixtures against the shared script and asserts that all four still carry the chlog path and the automation arm, so the copies cannot drift apart quietly. - chlog repositories — an ordinary branch must add a new fragment under
The Java sca:dependency-check job scans dependencies against a local copy of the NVD (~350,000 CVE records). Building that copy is the only slow part of the job, and the NVD API rate limits it per source IP: 5 requests per rolling 30 seconds anonymously, 50 with an API key. Hosted CI runners share their egress IPs with every other project on the platform, so an unauthenticated first run spends most of its time in 429 backoff.
Set NVD_API_KEY. Request a free key at nvd.nist.gov/developers/request-an-api-key, then expose it to the pipeline:
| Platform | How to provide it |
|---|---|
| GitHub Actions | Repository secret NVD_API_KEY; the wrapper workflows already forward it |
| GitLab CI | Masked CI/CD variable NVD_API_KEY |
| Azure DevOps | Pipeline variable (or variable group) NVD_API_KEY |
Without a key the scan still works: it falls back to NIST's gzipped JSON data feeds, which are not rate limited, and logs a warning. A key is still recommended — it is faster and keeps findings fresher.
The database is cached at .owasp/ on every platform and reused for 24 hours before Dependency-Check refreshes it, so the usual run is an incremental update rather than a rebuild. On GitHub Actions the cache key rotates daily and falls back to the most recent snapshot; note that a cache written on a PR branch is visible only to that branch, so the snapshot every PR restores from is the one written by the default branch. The job is capped at 30 minutes on all three platforms.
Optional environment variables:
| Variable | Default | Purpose |
|---|---|---|
NVD_API_KEY |
(unset) | Authenticates against the NVD API, raising the rate limit tenfold |
NVD_DATAFEED_URL |
NIST's public feeds when no key is set | Points at a self-hosted vulnz mirror; {0} expands to each year |
NVD_VALID_FOR_HOURS |
24 |
How long a cached database is reused before refreshing |
DEPENDENCY_CHECK_DATA_DIR |
./.owasp |
Where the CVE database lives — this is the directory to cache |
| Tool | Purpose | Script Location |
|---|---|---|
| golangci-lint | Go linting suite | global/scripts/languages/golang/golangci-lint/ |
| Go Test Runner | Comprehensive testing | global/scripts/languages/golang/test/ |
| CycloneDX | SBOM generation | global/scripts/languages/golang/cyclonedx/ |
| Tool | Purpose | Script Location |
|---|---|---|
| format | Prettier gate (--fix rewrites in place) |
global/scripts/languages/javascript/format/ |
| knip | Unused exports and unused files detection (advisory) | global/scripts/languages/javascript/knip/ |
The style:format job is blocking, while style:eslint beside it is
advisory, and the difference is deliberate rather than an oversight.
eslint-config-prettier — which almost every JavaScript project installs —
switches off every ESLint rule that overlaps with Prettier, so the ESLint job is
silent about formatting by design. With no formatting job the two halves cancel
out and nothing checks it at all: a repository can hold a committed
.prettierrc, a curated .prettierignore, hundreds of unformatted files and a
green pipeline at the same time. A linter's findings need judgement, so it is
right that they do not fail a build; prettier --write . needs none.
A project with no Prettier configuration and no prettier dependency is
skipped, so adopting these workflows cannot fail a repository for a tool it
never chose. A project that does use it runs its OWN Prettier — the version in
its lockfile — because a formatter's output is the verdict, and a floating
resolve would reformat the whole tree on a major release nobody asked for.
Adopting this on an existing repository: run
make format(oryarn format) once and commit the result, in its own commit. Until then the job reports every file the formatter would rewrite, which on a repository that has never run it is most of them.
| Tool | Purpose | Script Location |
|---|---|---|
| setup | Installs the Dart or Flutter SDK from Google's archive | global/scripts/languages/dart/setup/ |
| format | dart format gate (--fix rewrites in place) |
global/scripts/languages/dart/format/ |
| analyze | dart analyze with JUnit/JSON reports and a severity gate |
global/scripts/languages/dart/analyze/ |
| test | Tests + coverage → JUnit, Cobertura and LCOV | global/scripts/languages/dart/test/ |
| unused | Unused code and unused file detection (dart_code_linter) |
global/scripts/languages/dart/unused/ |
| sca | OSV-Scanner over pubspec.lock (Pub advisory database) |
global/scripts/languages/dart/sca/ |
| build | Release artifacts (APK, AAB, web, exe, …) | global/scripts/languages/dart/build/ |
| publish | pub.dev publication with validation gate | global/scripts/languages/dart/publish/ |
The toolchain is detected from the project's own pubspec.yaml — a flutter: sdk: flutter dependency selects flutter, anything else selects dart — so
the same eight scripts serve a Flutter app and a pure Dart package. Override with
DART_TOOLCHAIN=dart|flutter.
The SDK is downloaded from storage.googleapis.com rather than pulled as a
Docker image, for the same reason every other tool here is installed natively:
Docker Hub rate-limits anonymous pulls, and a large SDK image is exactly what
trips that limit.
Three tools in this repository's standard stack do not support Dart. Each gap is handled by a deliberate absence or substitution, not by a job that silently checks nothing:
| Tool | Dart support | What the pipeline does |
|---|---|---|
| CodeQL | ❌ No extractor (dart-lang/sdk#52953) | The sast:codeql job is omitted from every Dart template. make sast skips it with an explanation. |
| Semgrep (registry) | p/dart is a 404 and r/dart returns an empty rules: [] |
The job runs. The shared runner skips the unpublished pack instead of failing, and loads the first-party Dart ruleset shipped at global/scripts/tools/semgrep/rules/dart.yaml. |
| OWASP Dependency-Check | ❌ No pub analyzer | Replaced by OSV-Scanner, which queries the Pub advisory database directly. |
| Semgrep (engine) | ✅ Experimental | Runs the language-agnostic packs plus the shipped Dart rules. |
| Gitleaks / Hadolint / ShellCheck | ✅ Language-agnostic | Run unchanged. |
| SonarQube | ✅ First-party Dart analyzer (Server 10.7+/Cloud); community sonar-flutter on older servers |
Both sonar.dart.lcov.reportPaths and sonar.flutter.coverage.reportPath are written, so either implementation finds the coverage. |
| Dependency-Track | ❌ No BOM generator | No SBOM job. pub has no native CycloneDX generator (package:sbom emits SPDX only; cdxgen would need a Node.js toolchain), and the Trivy-backed generator was removed with Trivy. |
The shipped Semgrep ruleset covers the Dart and Flutter issues that are both
high-consequence and reliably expressible as a pattern — TLS verification
bypass via badCertificateCallback, WebView JavaScript and file-URL access,
shell and SQL injection through string interpolation, weak randomness for
security-sensitive values, and secrets written to SharedPreferences. Style and
correctness lints are deliberately left to dart analyze, which does them
better with the project's own analysis_options.yaml deciding what counts.
Because CodeQL is absent, dart analyze carries more weight here than the
equivalent job does in other pipelines. Its gate is therefore configurable:
DART_FATAL_WARNINGS (default true) and DART_FATAL_INFOS (default false,
since that is where every lint lands).
| Tool | Purpose | Script Location |
|---|---|---|
| order-check | File-ordering checker/auto-fixer (see below) | global/scripts/languages/terraform/order-check/ |
| tftest-gen | Smoke-test generator for single-module repos | global/scripts/languages/terraform/tftest-gen/ |
| terra-test | terraform test runner over module test suites |
global/scripts/languages/terraform/terra-test/ |
| validate | terraform validate over root modules (opt-in) |
global/scripts/languages/terraform/validate/ |
See MVP Hosting & Deployment for the ranked comparison and usage.
| Provider | Deploys via | Script Location |
|---|---|---|
| Cloudflare | wrangler (Pages or Workers) |
global/scripts/deploy/cloudflare/ |
| Vercel | vercel CLI |
global/scripts/deploy/vercel/ |
| Render | REST API (polled to a terminal state) | global/scripts/deploy/render/ |
| Netlify | netlify-cli |
global/scripts/deploy/netlify/ |
| Fly.io | flyctl (remote build, no local Docker) |
global/scripts/deploy/flyio/ |
Dense Terragrunt monorepos keep their *.hcl / *.tf files in a consistent order. The order-check job runs in the Code Check stage of the terra and terraform pipelines (all three platforms) and enforces:
environments/**/root.hcl—dependencyblocks and theinputsblock ordered ascending by dependency number (theenvironments/NN_prefix).stacks/*/variables.tf— a// SET ON .HCLsection before a// SET ON .ENVsection; dependency-derived variables ordered by dependency number inside.HCL.**/providers.tf(stacks + modules) —required_providersandproviderblocks ordered heaviest → lightest (cloud → data → orchestration → network → utility → trivial likerandom/null/local).stacks/*/outputs.tf— outputs ordered to follow the declaration order of the modules/resources they reference inmain*.tf.- dead inputs — every
inputs = {}key (in aroot.hclor a leafterragrunt.hcl) must be declared as avariablein the target stack. An undeclared input is dead code: Terraform silently drops theTF_VAR_Terragrunt exports for it, so the value is passed and never read. These are reported only, never auto-removed — the finding names the exact keys so you can delete them before pushing.
# check (CI gate; writes build/reports/junit-order-check.xml)
"$SCRIPTS_DIR/global/scripts/languages/terraform/order-check/run.sh"
# auto-sort, then re-align with your formatter
"$SCRIPTS_DIR/global/scripts/languages/terraform/order-check/run.sh" --fix
terra format # or: terraform fmt -recursive && terragrunt hcl formatThe provider ranking and path exclusions can be overridden per-repo with an optional .terraform-order.json in the repo root:
{
"provider_order": ["azurerm", "helm", "kubernetes", "random"],
"ignore": ["modules/legacy/**"]
}Only python3 is required (no Terraform binary). The --fix rewriter is round-trip-safe: it only reorders existing blocks and leaves any file it cannot parse cleanly untouched. Dead inputs are the one exception to --fix — they are reported but never deleted, since removing content would break that invariant.
make setup # Clone/update pipelines repo
make lint # Run golangci-lint
make test # Run Go tests with coverage
make security # Run all security tools (CodeQL, Gitleaks, Hadolint, Semgrep)# Symlink the shared golangci-lint config for IDE integration
SCRIPTS_DIR=$HOME/Development/github.com/rios0rios0/pipelines
ln -s $SCRIPTS_DIR/global/scripts/languages/golang/golangci-lint/.golangci.yml ~/.golangci.ymlThe 50-deployment stage ships ready-made jobs for the five platforms most worth using to host an
MVP cheaply. Each provider is wired identically on GitHub Actions, GitLab CI and Azure
DevOps, and all three delegate to one shared script under global/scripts/deploy/<provider>/, so
the deploy behaves the same wherever the pipeline runs.
Ranked by a composite of the three dimensions below, weighted for the MVP case: price 50%, reliability 30%, popularity 20%. Each dimension is scored separately so the ranking can be re-derived under different weights. Figures verified August 2026 — free tiers in this market change often, so treat the vendor's own pricing page as authoritative before committing.
| # | Platform | Free tier (price) | Reliability | Popularity | Best for |
|---|---|---|---|---|---|
| 1 | Cloudflare Pages + Workers | Best. Permanently free, commercial use allowed, bandwidth unmetered on Pages. Workers: 100k req/day, 10ms CPU/invocation. D1 5 GB, R2 10 GB, KV 1 GB | Best. Global anycast edge, no cold starts, no sleep | 3rd of the frontend trio, rising | Static sites, SPAs, and APIs that fit the edge runtime |
| 2 | Vercel | Free Hobby: 100 GB transfer, 1M edge requests, 6k build min, 1M function calls. Non-commercial only — revenue means Pro at $20/seat/mo | Excellent; 45-min build cap, 1 concurrent build | #1 — ~33% market share | Next.js and frontend-first projects, pre-revenue |
| 3 | Render | Free web service: 512 MB RAM, 0.1 CPU. Sleeps after 15 min idle (30–60s cold start). Free Postgres expires after 30 days. Commercial use allowed | Good when warm; the sleep is the caveat | Moderate | Full-stack apps — the closest true Heroku replacement |
| 4 | Netlify | 300 credits/mo. Deploys cost 15 each, bandwidth 20/GB, compute 10/GB-hr, requests 2/10k. Roughly 20 deploys/month if nothing else draws on it | Very good; mature platform | #2 — ~19.5% market share | Jamstack and content sites with infrequent deploys |
| 5 | Fly.io | No free tier (withdrawn 2024; new accounts get a 2-hour trial). ~$2/mo for shared-cpu-1x/256 MB — the cheapest always-on option here | Very good; ~35 regions | Moderate | Containers that must not cold-start: webhooks, bots, daemons |
Picking one:
- Cloudflare unless you have a reason not to. It is the only free tier here that is permanent, permits commercial use, and does not sleep — which is exactly the combination an MVP needs.
- Vercel if the project is Next.js and pre-revenue. Move to Cloudflare or pay for Pro before you turn on billing, not after.
- Render if you need a long-running server process and a database on a free tier, and can live with cold starts. Budget for the Postgres expiry at day 30 — it is a trial, not a free tier.
- Fly.io if ~$2/month is acceptable and cold starts are not. It is the only one that runs an
ordinary
Dockerfileacross many regions. - Netlify if you are already on it. Its free tier is now the tightest of the five.
Two things changed recently enough to catch people out: Railway removed its free tier (a one-off $5 trial credit replaced it) and Fly.io withdrew its permanent free allowance in 2024. Neither is a "free" option today, whatever older comparisons say.
| Platform | Reference |
|---|---|
| GitHub Actions | rios0rios0/pipelines/github/global/stages/50-deployment/<provider>@main |
| GitLab CI | remote: '.../main/gitlab/global/stages/50-deployment/<provider>.yaml' |
| Azure DevOps | template: 'azure-devops/global/stages/50-deployment/<provider>.yaml@pipelines' |
GitHub Actions — deploy to Cloudflare Pages after building:
jobs:
deployment-cloudflare:
name: 'deployment > cloudflare'
runs-on: 'ubuntu-latest'
steps:
- uses: 'rios0rios0/pipelines/github/global/stages/50-deployment/cloudflare@main'
with:
cloudflare_api_token: '${{ secrets.CLOUDFLARE_API_TOKEN }}'
cloudflare_account_id: '${{ secrets.CLOUDFLARE_ACCOUNT_ID }}'
project_name: 'my-mvp'
build_command: 'npm ci && npm run build'
output_directory: 'dist'
if: "github.ref == 'refs/heads/main'"Gating a tag-triggered deploy. If your CI skips its expensive jobs on tags — the usual optimisation, since the commit already passed on main — add require-checks ahead of the deploy. Nothing otherwise stops a tag cut from an untested or red commit, and the delivery job still runs because GitHub counts a skipped needs: as satisfied:
permissions:
contents: 'read'
checks: 'read' # not in the restricted default set
steps:
- uses: 'rios0rios0/pipelines/github/global/stages/50-deployment/require-checks@main'
with:
# the delivery job's own `needs:` list — one definition of "fit to ship".
# Write the stage names as this repository publishes them: the gate matches a whole
# trailing ` / ` segment, so it does not matter that GitHub records a check called
# through a reusable workflow as `<caller job> / <callee job> / tests > test:all`.
required_checks: |
code-check > style:golangci-lint
tests > test:all
- uses: 'rios0rios0/pipelines/github/global/stages/50-deployment/render@main'
# `render_service_name` names the service instead of identifying it, which suits a name
# derived per environment (`api-staging` / `api-production`) — no per-environment secret,
# and a stale name fails loudly where a stale id deploys the wrong service and reports
# success. `render_service_id` still works and wins when both are given.
with: { render_api_key: '${{ secrets.RENDER_API_KEY }}', render_service_name: 'api-staging' }GitLab CI — add the include and set the CI/CD variables; the job self-gates on them:
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/global/stages/50-deployment/render.yaml'Azure DevOps:
stages:
- template: 'azure-devops/global/stages/50-deployment/flyio.yaml@pipelines'
parameters:
APP_NAME: 'my-mvp'Every provider reads its token from an environment variable and never takes it on the command
line. That is deliberate: argv is readable via ps for the process's lifetime on a shared or
self-hosted runner, and each job publishes build/reports/deploy-<provider>/ as an artifact, so a
token on argv would also be written into command.txt and kept for the artifact's retention period.
Render, which publishes no CLI, is driven with curl --config - so its bearer token arrives on
stdin instead.
| Provider | Required | Optional |
|---|---|---|
| Cloudflare | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_PROJECT_NAME (Pages) |
CLOUDFLARE_TARGET (pages|workers), CLOUDFLARE_OUTPUT_DIRECTORY, CLOUDFLARE_BRANCH, CLOUDFLARE_PRODUCTION_BRANCH |
| Vercel | VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID |
VERCEL_WORKING_DIRECTORY, VERCEL_COMMERCIAL, VERCEL_PLAN |
| Render | RENDER_API_KEY + RENDER_SERVICE_ID, or RENDER_DEPLOY_HOOK_URL |
RENDER_POLL_TIMEOUT, RENDER_POLL_INTERVAL |
| Netlify | NETLIFY_AUTH_TOKEN, NETLIFY_SITE_ID |
NETLIFY_OUTPUT_DIRECTORY, NETLIFY_DEPLOY_MESSAGE |
| Fly.io | FLY_API_TOKEN |
FLY_APP_NAME, FLY_ORG, FLY_MACHINE_COUNT, FLY_CONFIG, FLY_STRATEGY |
Set FLY_MACHINE_COUNT to pin how many machines the app runs. Fly adds a spare machine the first
time it fills a process group, which is right for a stateless app and wrong for a process that is
not safe to run twice — an in-process event bus, an in-memory rate limiter, background workers with
no leader election. Such an app is silently wrong on two machines rather than broken, so nothing
reports it, and min_machines_running in fly.toml is a floor that cannot express a ceiling. A
count of 1 also passes --ha=false so the spare is never created in the first place.
On GitHub Actions, go-flyio.yaml also accepts fly_app_name_var / fly_org_var / fly_machine_count_var — the name of a
caller variable rather than a value — so one repository can deploy a differently-named app per
environment without spelling either name in a workflow file. A calling job cannot declare
environment:, so an environment-scoped variable has to be named by the caller and read inside the
job that has the environment; passing vars.FLY_APP_NAME as an input would silently resolve to the
repository-level value instead. A named variable that resolves to nothing fails the job.
Set FLY_ORG to have the Fly.io deploy create the app when it does not exist yet — flyctl deploy does not create apps, so without it the first pipeline of every new environment is red for a
reason that is fixed by hand and never written down. It is opt-in because of what it costs in token
scope: an app-scoped deploy token (flyctl tokens create deploy --app <app>) cannot create apps, so
auto-creation requires an org-scoped token (flyctl tokens create org --org <org>) that manages
every app in the organisation. Leave FLY_ORG unset to keep the tighter app-scoped token, one per
environment, and create the apps deliberately.
The app to create is named by FLY_APP_NAME, or by app = "..." in the committed fly.toml when
that variable is unset — the same two sources the deploy itself uses, so the configuration
fly_app_name documents as optional is not one where FLY_ORG quietly does nothing. When neither
yields a name the step is skipped with a warning rather than in silence.
Prefer Render's API key over its deploy hook. A deploy hook is fire-and-forget — Render returns success for "request accepted", so the job goes green even when the build that follows fails. With an API key the script polls the deploy to a terminal state, and the job's status means something.
Set DEPLOY_ENVIRONMENT to anything other than production to get a preview/draft deploy where the
provider supports one. Set DEPLOY_DRY_RUN=true to resolve and record the deploy command without
performing it — this is how make test-deploy-providers exercises all five offline.
Pre-built container images optimized for CI/CD environments:
| Image | Purpose | Registry |
|---|---|---|
golang.1.26-awscli |
Go 1.26 + AWS CLI | ghcr.io/rios0rios0/pipelines |
python.3.10-pdm-bookworm |
Python 3.10 + PDM | ghcr.io/rios0rios0/pipelines |
python.3.13-pdm-bookworm |
Python 3.13 + PDM | ghcr.io/rios0rios0/pipelines |
awscli.latest |
AWS CLI tools | ghcr.io/rios0rios0/pipelines |
bfg.latest |
BFG Repo-Cleaner | ghcr.io/rios0rios0/pipelines |
mssql-tools18.latest |
Microsoft SQL Server tools | ghcr.io/rios0rios0/pipelines |
tor-proxy.latest |
Network proxy with health check | ghcr.io/rios0rios0/pipelines |
# Build and push a custom container
make build-and-push NAME=awscli TAG=latest
# Build both architectures without publishing anything (the verification mode)
make build NAME=awscli TAG=latest
# Local build for testing
docker build -t my-image -f global/containers/awscli.latest/Dockerfile global/containers/awscli.latest/The Container Images workflow builds these on every push to main that touches
global/containers/**, and on demand via workflow_dispatch with container_folder
(a folder name such as tor-proxy.latest; empty builds all of them). Its push input
is off by default, so a dispatch verifies a Dockerfile from a branch: both
architectures are still built, and nothing is pushed over the tag main publishes.
Turn it on only to republish an image by hand — pushes to main publish either
way, since they never read the input.
The recommended way to use this repository locally is through the includable .mk files. GNU Make's -include directive imports targets from the pipelines repository, so your project Makefile only needs to declare SCRIPTS_DIR and the includes:
Before (repeated in every project):
SCRIPTS_DIR = $(HOME)/Development/github.com/rios0rios0/pipelines
.PHONY: lint
lint:
${SCRIPTS_DIR}/global/scripts/languages/golang/golangci-lint/run.sh --fix .
.PHONY: test
test:
${SCRIPTS_DIR}/global/scripts/languages/golang/test/run.sh .
.PHONY: sast
sast:
${SCRIPTS_DIR}/global/scripts/tools/codeql/run.sh "go"After (include once, get all targets):
# Pipeline targets: setup, sast, lint, test
SCRIPTS_DIR ?= $(HOME)/Development/github.com/rios0rios0/pipelines
-include $(SCRIPTS_DIR)/makefiles/common.mk
-include $(SCRIPTS_DIR)/makefiles/golang.mk
build:
go build -o bin/app .
run:
go run .This gives you the following targets for free:
| Target | Source | Description |
|---|---|---|
make setup |
common.mk |
Clone or update the pipelines repository |
make sast |
common.mk |
Run all security SAST tools — fails on findings |
make lint |
<language>.mk |
Run language-specific linter |
make test |
<language>.mk |
Run language-specific tests |
Available language files:
| File | Language | lint |
test |
|---|---|---|---|
golang.mk |
Go | golangci-lint --fix |
Go test + coverage |
python.mk |
Python (PDM) | isort + black + flake8 + mypy |
pytest |
java.mk |
Java (Gradle) | ./gradlew check |
./gradlew test |
javascript.mk |
JavaScript (Yarn) | prettier --write + yarn lint + unused-code scan |
yarn test |
dotnet.mk |
.NET/C# | dotnet format |
dotnet test |
dart.mk |
Dart / Flutter | dart format --fix + dart analyze + unused-code scan |
dart/flutter test + coverage → JUnit, Cobertura, LCOV |
terraform.mk |
Terraform | terraform fmt + validate |
terraform plan |
terra.mk |
Terra CLI | terra format + git diff check |
unified test-all runner (terraform test on all modules + Terratest suite when present) |
dart.mk adds make sca (OSV-Scanner over pubspec.lock) to the suite make sast runs, by
appending to SAST_TOOLS_EXTRA, and leaves CODEQL_LANGUAGE unset so make sast skips CodeQL
with an explanation rather than failing — CodeQL has no Dart extractor. Include order does not
affect which tools run.
The -include prefix means Make silently skips the includes if the repository is not cloned yet. Run make setup (or curl ... | bash) to bootstrap.
make sast runs every tool, then exits non-zero if any of them reported findings, ending with
a line that names them:
$ make sast
...
SAST FAILED: semgrep gitleaks
Reports are under build/reports/. Fix the findings, or record the
ones you have triaged in that tool's suppression file (.codeql-false-positives,
.semgrepignore, .semgrepexcluderules, .hadolint.yaml, .gitleaksignore).
$ echo $?
2Each individual target fails on its own tool too, so make lint && make sast short-circuits the
way the standard assumes, and make gitleaks is safe to put in a hook.
| Variable | Default | Purpose |
|---|---|---|
SAST_TOOLS |
codeql semgrep hadolint shellcheck gitleaks |
The suite sast runs. Override to narrow it |
SAST_TOOLS_EXTRA |
(empty) | Appended to by language fragments; never set by hand |
Findings a team has accepted belong in the tool's own suppression file — that is what those files
are for, and a reviewer can see them. If a pipeline genuinely wants SAST to be advisory, it says so
at its own call site (make sast || true, or the platform's continueOnError / continue-on-error
/ allow_failure), which is where this repository's published templates already make that choice
and where it stays visible.
See the .docs/examples/ directory for complete per-provider examples including Makefiles.
If you prefer calling scripts directly without Makefile includes:
export SCRIPTS_DIR=$HOME/Development/github.com/rios0rios0/pipelines
# Dart/Flutter: format, analyze, test with coverage, dependency scan
$SCRIPTS_DIR/global/scripts/languages/dart/format/run.sh --fix
$SCRIPTS_DIR/global/scripts/languages/dart/analyze/run.sh
$SCRIPTS_DIR/global/scripts/languages/dart/test/run.sh
$SCRIPTS_DIR/global/scripts/languages/dart/sca/run.sh
# Go linting
$SCRIPTS_DIR/global/scripts/languages/golang/golangci-lint/run.sh --fix
# Go tests
$SCRIPTS_DIR/global/scripts/languages/golang/test/run.sh
# Security scans
$SCRIPTS_DIR/global/scripts/tools/gitleaks/run.sh
$SCRIPTS_DIR/global/scripts/tools/codeql/run.sh go
$SCRIPTS_DIR/global/scripts/tools/hadolint/run.sh
$SCRIPTS_DIR/global/scripts/tools/semgrep/run.shWhen developing pipeline modifications, you can test against development branches:
export BRANCH=your-feature-branch-name
# Update all pipeline references to use your branch
find . -type f -name "*.yaml" -exec sed -i.bak -E "s|(remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/)[^/]+(/.*)|\\1$BRANCH\\2|g" {} +# Update your project's pipeline reference
# Before:
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/golang/go-docker.yaml'
# After:
include:
- remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/your-feature-branch/gitlab/golang/go-docker.yaml'A bump merged to main cuts the tag and the GitHub Release (delivery > release), and that is where it used to stop. Every deploy job in this library resolves production from github.ref_type == 'tag', so production ships from the tag run -- and the tag the release job creates never started one: it is pushed with the job's GITHUB_TOKEN, and GitHub creates no workflow run from an event that token caused. A merged bump therefore deployed staging from its main run and then waited for a human to push the same tag again by hand.
The tag is pushed from the release job's own checkout and so points at github.sha, the bump merge that run verified. Creating it through the Releases API tags the default branch's current head instead, and the release job runs last: on medhub-life/frontend on 2026-09-14 a second pull request merged during the 36 minutes the quality gate took, 0.3.0 landed on it, and the promotion was refused by require-checks because that commit's own run had not started. A tag that already exists on that commit is kept, so pushing one by hand before the run remains the recovery path; one that exists on another commit fails the job by name rather than being released again. The GitLab and Azure DevOps templates already tagged the pipeline's own commit; GitHub was the outlier.
promote_release: true closes that gap. workflow_dispatch is the one event GitHub exempts from the rule, so the release job dispatches the calling workflow on refs/tags/X.Y.Z with the same token (global/scripts/shared/promote-release.sh). The run that starts has github.ref_type == 'tag' -- the deploy resolves production, require-checks verifies the tagged commit, the quality jobs skip as on any tag -- and differs from a pushed tag's run only in github.event_name, which every gate here already accepts.
Available on the workflows that deploy on a tag or feed one that does: yarn-cloudflare.yaml, npm-cloudflare.yaml and dart-cloudflare.yaml (which now carry delivery > release as the *-docker.yaml family does), go-docker.yaml, go-flyio.yaml and go-render.yaml. Off by default. The caller needs three things:
on:
push:
branches: [ 'main' ]
tags: [ '*' ]
workflow_dispatch: # 1. the promotion IS a dispatch of this file, on the tag
permissions:
contents: 'write' # 2. the release itself
actions: 'write' # 3. the dispatch
jobs:
default:
uses: 'rios0rios0/pipelines/.github/workflows/yarn-cloudflare.yaml@main'
with:
promote_release: truecontents: write is required of every caller of these three workflows now, not only of one that promotes: the release job is not gated on promote_release -- a bump merge cuts the tag either way, exactly as it does on go-docker.yaml -- and creating a GitHub Release needs it. A caller still on contents: read fails that job on its next bump merge, which is why this ships as a breaking change.
A refused dispatch fails the release job with the fix spelled out -- actions: write not granted, no workflow_dispatch: at the tag, the file not found at the tag -- and leaves the release in place; gh workflow run <file> --ref <tag> starts the same run by hand. A tag ref is never dispatched again: that run is the promotion.
Releases are cut by the delivery-release job, which runs only on a push to main whose commit message is a bump (chore(bump) / chore/bump-) and depends on the quality gate (go / composer / maven / yarn / npm / dart). When a bump PR merges but that main run fails the gate, the tag and GitHub Release are never created — yet the PR already committed [X.Y.Z] to CHANGELOG.md. The changelog then runs ahead of the tags: bumped, but never released.
Two mechanisms guard against this:
-
Tag-push recovery. Pushing a version tag runs only the delivery step (the quality-gate jobs skip on tags), and the release stage derives the version from the tag ref — so a failed bump is recovered by re-pushing its tag. This is wired across all three platforms: GitHub Actions (
github/global/stages/40-delivery/release+ thego-library/composer-library/maven-libraryworkflows;go-binaryalready delivered on tags via GoReleaser), GitLab CI (gitlab/global/stages/40-delivery/release.yaml, which now fires on a$CI_COMMIT_TAG), and Azure DevOps (azure-devops/global/stages/40-delivery/release.yaml, whoseconditionnow also matchesrefs/tags/*). Recover a failed bump with:git tag 1.2.3 <bump-commit-sha> && git push origin 1.2.3
-
Scheduled reconciliation.
global/scripts/shared/reconcile-releases.shdiffs the releasedCHANGELOG.mdversions against the git tags and resolves each gap to its bump commit. It is run org-wide on a schedule byconfig-automation— the same place the compliance audit and config/docs refresh already run — which enumerates everyrios0rios0repo, (re-)pushes any missing tag at its bump commit (triggering the recovery path above), and reports the result. Run it against any repo locally with:global/scripts/shared/reconcile-releases.sh /path/to/repo
It prints one
version<TAB>commit<TAB>statusrow per gap (empty output means the changelog and tags agree). A tag re-pushed to recover a release must be pushed with a PAT, not the defaultGITHUB_TOKEN, for it to re-trigger delivery; aGITHUB_TOKEN-pushed tag is still created (enough for tag-driven ecosystems such as Go modules and Packagist) but starts no workflow.promote_release(see Release Promotion above) is the automated form of that hand push: the release job dispatches the workflow on the tag with the sameGITHUB_TOKEN, which GitHub does allow to start aworkflow_dispatchrun.
Issue: "No directories found to test it" (Go projects)
- Cause: a Go module that uses none of the
cmd/,pkg/, orinternal/directories -- for example one that keeps its packages at the repository root or under a differently named directory - Solution: none required -- the test runner now falls back to testing the whole module (
./...) when none of those directories exist, so the run proceeds instead of aborting - Note: modules that do use
cmd/,pkg/, orinternal/keep their existing, narrower test scope
Issue: "golangci-lint: command not found"
- Cause: golangci-lint not installed or not in PATH
- Solution: The script automatically downloads golangci-lint, ensure Docker is available
Issue: Docker build fails with SSL certificate errors
- Cause: Network restrictions in CI environment
- Solution: This is expected in restricted environments; contact your platform administrator
Issue: CodeQL analysis fails
- Cause: CodeQL CLI not installed or language not supported
- Solution: Ensure network access to download CodeQL CLI bundle; supported languages: go, python, java, javascript, csharp, ruby (PHP is not supported)
Issue: Gitleaks takes too long or fails
- Cause: Large repository or network issues
- Solution: Increase timeout values, ensure network access to GitHub releases for the Gitleaks binary download
Issue: Semgrep timeout or hangs
- Cause: Large codebase, downloading security rules
- Solution: Allow 10+ minutes for completion, do not cancel the operation
Issue: Hadolint skips analysis
- Cause: No Dockerfiles found in the project
- Solution: This is expected for projects without Dockerfiles; Hadolint auto-skips gracefully
GitHub Actions:
- Issue: Workflow does not trigger
- Solution: Check repository permissions, ensure workflow file is in
.github/workflows/
GitLab CI:
- Issue: "Remote file could not be fetched"
- Solution: Verify the remote URL is accessible, check branch name in URL
Azure DevOps:
- Issue: "Template not found"
- Solution: Ensure GitHub service connection is configured correctly
Minimum Requirements:
- Docker (for container builds and security tools)
- Git (for repository operations)
- Network access (for downloading tools and dependencies)
- GitHub Actions runner 2.327.1+ — every third-party action pinned here declares
runs.using: node24, which older runners cannot execute. GitHub-hosted runners already satisfy this; self-hosted runners must be upgraded before consuming these workflows.
Language-Specific Requirements:
- Go: Go 1.18+ (automatically installed in CI)
- Python: Python 3.8+ (automatically managed in CI)
- Java: JDK 11+ (automatically managed in CI)
- Node.js: Node 16+ (automatically managed in CI)
| Operation | Expected Duration | Notes |
|---|---|---|
| Script downloads | 1-5 seconds | First-time tool downloads |
| Go linting | 10-30 seconds | Depends on codebase size |
| Security scanning | 2-10 minutes | Depends on tools and project size |
| Container builds | 5-30 minutes | Depends on base image and dependencies |
| Semgrep analysis | 5-15 minutes | Downloads large rule sets |
Important: Never cancel operations that appear to be hanging - they may be downloading large Docker images or rule sets.
Contributions are welcome. See CONTRIBUTING.md for guidelines.
This project is licensed under the MIT License.
Note: This repository provides pipeline templates and automation scripts, not a runnable application. Users consume these templates in their own projects to establish comprehensive CI/CD pipelines with security, quality, and testing automation.


