Skip to content

Repository files navigation

Pipelines

Latest Release License Build Status Coverage Quality Gate OpenSSF Best Practices

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.

Supported Platforms & Languages

Platforms

Platform Status Documentation
GitHub Actions Full Support Usage Guide
GitLab CI Full Support Usage Guide
Azure DevOps Full Support Usage Guide

Programming Languages

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

Project Structure

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

Pipeline Architecture

Each platform follows a consistent 5-stage pipeline architecture:

  1. Code Check (Style/Quality) - Linting, formatting, code quality, basic checks (rebase verification, changelog validation)
  2. Security (SCA/SAST) - Vulnerability scanning, secret detection
  3. Tests - Unit tests, integration tests, coverage reporting
  4. Management - Dependency tracking, SBOM generation
  5. Delivery - Build artifacts, container images, deployments

Installation

Recommended

mkdir -p $HOME/Development/github.com/rios0rios0
cd $HOME/Development/github.com/rios0rios0
git clone https://github.com/rios0rios0/pipelines.git

make 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/pipelines

The clone.sh one-liner

curl -sSL https://raw.githubusercontent.com/rios0rios0/pipelines/main/clone.sh | bash

clone.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.

Platform Usage

GitHub Actions

GitHub Actions workflows are located in .github/workflows/ and can be used as reusable workflows.

Available 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

Projects in a Subfolder (working_directory)

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.

Usage Example (Go with Docker)

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'

Usage Example (Claude Review and Claude Mention)

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'

Usage Example (Python/PDM with Docker)

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'

Usage Example (Java with Docker)

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'

Usage Example (JavaScript/Yarn with Docker)

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'

Usage Example (.NET with Docker)

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'

Usage Example (JavaScript/npm with Docker)

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'

Usage Example (Flutter with Artifact Delivery)

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 release

Usage Example (Dart Package to pub.dev)

jobs:
  pipeline:
    uses: 'rios0rios0/pipelines/.github/workflows/dart-library.yaml@main'
    secrets:
      PUB_TOKEN: ${{ secrets.PUB_TOKEN }}   # only used by the tag-triggered publish job

The 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.

Usage Example (Java/Maven with Docker)

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'

Usage Example (PHP with Docker)

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'

Usage Example (Ruby with Docker)

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 root

GitHub Actions Example

GitLab CI

GitLab CI templates use remote includes and are organized by language in the gitlab/ directory.

Available Templates

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

Usage Example (Go with Docker)

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'

Usage Example (Python PDM)

include:
  - remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/python/pdm-docker.yaml'

variables:
  PYTHON_VERSION: "3.11"  # Optional: specify a Python version

Usage Example (Dart / Flutter)

include:
  - 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 runner

No 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.

Usage Example (Terraform -- raw terraform/terragrunt)

include:
  - remote: 'https://raw.githubusercontent.com/rios0rios0/pipelines/main/gitlab/terraform/terra.yaml'

Usage Example (Terra CLI)

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.

Terra Test Stage

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.

Root-Module Validation (test:validate, opt-in)

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.

The Provider Mirror

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.

Required GitLab Variables

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

GitLab CI Example

Azure DevOps

Azure DevOps templates are located in the azure-devops/ directory and use template references.

Available Templates

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

Usage Example (Go with Docker)

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'

Usage Example (Dart / Flutter)

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: true

Microsoft-hosted ubuntu-latest agents already carry the Android SDK and a JDK, so the Android target works without extra setup.

Usage Example (Go with ARM Deployment)

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"'

Usage Example (Go with AWS Lambda)

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 environment

Required Variable Groups

Create 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.

Azure DevOps Example

Supply-Chain Pinning

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.

Knowing when a pin is stale

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"] }

Bumping a pinned tool

  1. Change the *_PINNED_VERSION value in global/scripts/shared/pinned-versions.sh.
  2. Replace every *_SHA256_* for that tool, taken from the upstream checksum manifest. Never carry an old digest forward.
  3. 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 gitleaks

Overriding 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 this repository (consumers)

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.

Available Tools & Scripts

Shared Ignore Rules

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.

Security & Analysis Tools

SAST (Static Application Security Testing)

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

SCA (Software Composition Analysis)

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

Quality & Management

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

SonarQube configuration

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.

Dependency-Track configuration

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. Set DEPENDENCY_TRACK_UPLOAD_ON_PULL_REQUEST=true if you want per-pull-request inventory.
  • isLatest is claimed only on a default branch or a tag. GitLab CI publishes CI_DEFAULT_BRANCH, so it needs no help. Azure DevOps publishes no variable carrying the repository's default branch (Build.Repository.DefaultBranch does not exist), and GitHub Actions exposes it only through the github.event.repository context — on those two, set DEPENDENCY_TRACK_DEFAULT_BRANCH or a default-branch build will upload without claiming the flag.
  • A collection parent applies only to projects being created. Dependency-Track resolves parentName solely 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 needs PATCH /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.

Basic Checks

Every pipeline includes basic checks that run in parallel with linting during the Code Check stage. These checks verify:

  1. 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.

  2. 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 the chlog new --kind <Kind> --body "<description>" that writes one. A bump/* or chore/bump-* branch carries no fragment — chlog merge has already folded the pending ones into CHANGELOG.md — so on those the requirement flips to an updated CHANGELOG.md.
    • Every other repository — the CHANGELOG.md file 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_PREFIX to 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 no CHANGELOG.md edit, 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/.yml files count as pending, so a .gitkeep holding the directory open is not mistaken for an entry.

    Dependency-bot branches (dependabot/*) are exempt outright — no fragment, no CHANGELOG.md edit, 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 a pull_request_target job 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.Z comment is exactly what a Dependabot github-actions update 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 — because quality:basic-checks runs in a minimal image holding only the consumer's repository and cannot reach this repository's scripts. .github/tests/test-basic-checks.sh runs 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.

OWASP Dependency-Check and the NVD Database

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

Language-Specific Tools

Go Tools

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/

JavaScript / TypeScript Tools

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 (or yarn 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.

Dart / Flutter Tools

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.

Dart & Flutter Tool Coverage

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) ⚠️ Engine parses Dart (experimental); registry publishes zero Dart rules — 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).

Terraform / Terra Tools

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/

MVP Hosting Providers

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/
File-Ordering Standard (order-check)

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.hcldependency blocks and the inputs block ordered ascending by dependency number (the environments/NN_ prefix).
  • stacks/*/variables.tf — a // SET ON .HCL section before a // SET ON .ENV section; dependency-derived variables ordered by dependency number inside .HCL.
  • **/providers.tf (stacks + modules) — required_providers and provider blocks ordered heaviest → lightest (cloud → data → orchestration → network → utility → trivial like random/null/local).
  • stacks/*/outputs.tf — outputs ordered to follow the declaration order of the modules/resources they reference in main*.tf.
  • dead inputs — every inputs = {} key (in a root.hcl or a leaf terragrunt.hcl) must be declared as a variable in the target stack. An undeclared input is dead code: Terraform silently drops the TF_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 format

The 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.

Usage Examples

Run Security Scanning Locally (via Makefile)

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)

Configure Go Linting Globally

# 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.yml

MVP Hosting & Deployment

The 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.

The Top 5, Ranked

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 Dockerfile across 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.

Usage

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'

Credentials

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 yetflyctl 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.

Container Images

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

Building Custom Images

# 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.

Makefile Integration

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 Is a Gate

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 $?
2

Each 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.

Direct Script Usage

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.sh

Testing Pipeline Changes

When developing pipeline modifications, you can test against development branches:

Switch to Development Branch

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" {} +

Test Your Changes

# 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'

Release Promotion

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: true

contents: 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.

Release Reconciliation

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:

  1. 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 + the go-library/composer-library/maven-library workflows; go-binary already 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, whose condition now also matches refs/tags/*). Recover a failed bump with:

    git tag 1.2.3 <bump-commit-sha> && git push origin 1.2.3
  2. Scheduled reconciliation. global/scripts/shared/reconcile-releases.sh diffs the released CHANGELOG.md versions against the git tags and resolves each gap to its bump commit. It is run org-wide on a schedule by config-automation — the same place the compliance audit and config/docs refresh already run — which enumerates every rios0rios0 repo, (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>status row 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 default GITHUB_TOKEN, for it to re-trigger delivery; a GITHUB_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 same GITHUB_TOKEN, which GitHub does allow to start a workflow_dispatch run.

Troubleshooting

Common Issues & Solutions

Pipeline Failures

Issue: "No directories found to test it" (Go projects)

  • Cause: a Go module that uses none of the cmd/, pkg/, or internal/ 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/, or internal/ 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

Security Tool Issues

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

Platform-Specific Issues

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

Environment Requirements

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)

Performance Expectations

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.

Contributing

Contributions are welcome. See CONTRIBUTING.md for guidelines.

License

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.

About

Production‑ready SDLC pipelines for every language, every stage, and every DevOps ecosystem.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

23 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages